상세 컨텐츠

본문 제목

[C#] Thread(스레드)와 Task(태스크)

.NET/C#

by 클리엘 2021. 10. 24. 01:22

본문

728x90

1. 프로세스와 스레드

 

프로세스라 함은 실행파일이 실행되어 실행파일과 관련된 모든 데이터가 메모리에 적재되어 실행되는 상태를 말합니다. 프로세스는 자신만의 스레드를 가질 수 있으며 이 스레드로 프로그램에 필요한 동작을 수행합니다. 따라서 프로세스는 반드시 하나이상의 스레드를 가지며 스레드는 CPU가 프로그램 실행을 위해 시간을 할애하는 기본 단위가 됩니다.

 

프로그램이 내부에 여러작업을 수행해야 하는 경우라면 스레드를 여러 개 사용하는 '멀티스레드'를 구현합니다. 예를 들어 문서편집기 프로그램의 경우 사용자가 문서를 작성하고 있으면 주기적으로 자동 저장을 해야 할 때 문서작성용 스레드와 자동 저장을 수행하는 스레드가 각각 필요할 것입니다. 시간이 오래 걸리는 계산 작업을 수행하는 경우에도 사용자가 중간에 취소할 수 있어야 하는 상황이라면 계산 작업을 수행하는 스레드와 사용자의 취소 요청에 반응해야 하는 스레드가 필요합니다.

 

이와 같이 여러가지 상황에서 다중 작업이 필요한 경우 스레드가 사용될 수 있는데 스레드 말고도 프로세스 자체를 여러 개 사용하는 '멀티 프로세스'방식이 사용되는 경우도 있습니다. 스레드는 하나의 프로세스 안에서 실행되는 작업을 나누는 것이므로 프로세스의 자원을 쉽게 공유할 수 있고 프로세스에 할당된 자원을 사용하므로 스레드를 실행하기 위한 별도의 메모리나 기타 자원을 할당하는 하는 과정을 거치지 않아도 된다는 이점이 있습니다.

다만 멀티프로세스의 경우 하나의 프로세스에 문제가 생겨도 다른 프로세스에는 영향을 끼치지 않지만 스레드는 하나의 스레드에 문제가 생기면 프로세스 전체에 영향을 끼칠 수 있습니다. 또한 하나의 프로세스에 너무 많은 스레드를 생성하는 경우 CPU가 스레드 간 작업을 전환할 때 그만큼 전환되는 비용이 커질 수 있으므로 스레드를 너무 많이 생성하지 않도록 주의해야 합니다.

 

C#에서 스레드를 생성하는데는 Thread클래스가 사용됩니다.

class Program
{
    static void Main(string[] args)
    {
        Thread t = new Thread(new ThreadStart(Working));

        t.Start();

        for (int i = 0; i < int.MaxValue; i++)
        {
            WriteLine($"메인 스레드 처리 중...{i}");
        }

        t.Join();
    }

    static void Working()
    {
        for (int i = 0; i < int.MaxValue; i++)
        {
            WriteLine($"스레드 처리 중...{i}");
        }
    }
}

Thread의 인스턴스를 생성한 후 Start() 메서드를 호출하면 해당 스레드는 작업에 필요한 메모리를 할당받고 주어진 처리(Working)를 시작합니다. Join() 메서드는 Start() 메서드에 의해 해당 작업이 모두 종료될 때까지 대기하도록 하는 역할을 하는데 스레드는 별도의 동작으로 메인 스레드와 분리되어 실행하다가 실행이 종료되면 다신 메인 스레드로 Join을 하도록 하는 것입니다. Join() 메서드는 선택사항이며 스레드의 종료까지 대기가 필요한 상황이 아니라면 사용하지 않아도 됩니다.

 

스레드는 Start()메서드로 시작하며 필요한 경우 Interrupt() 메서드를 통해 스레드 실행을 정지할 수 있습니다. 다만 Interrupt() 메서드를 호출한 시점에 정확히 정지되는 것은 아니니 시간차를 고려해야 합니다. 스레드를 중지하는 메서드로 Abort() 메서드가 있지만 .NET 5.0부터는 더 이상 해당 메서드를 사용하지 말 것을 경고하고 있습니다.

class Program
{
    static void Main(string[] args)
    {
        Thread t = new Thread(new ThreadStart(Working));
        t.Start();

        for (int i = 0; i < int.MaxValue; i++)
        {
            WriteLine($"메인 스레드 처리 중...{i}");

            if (i == 300)
                t.Interrupt();
        }

        t.Join();
    }

    static void Working()
    {
        for (int i = 0; i < int.MaxValue; i++)
        {
            WriteLine($"스레드 처리 중...{i}");
        }
    }
}

Abort() 메서드와 달리 Interrupt()메서드는 동작중인 상태가 아닌 스레드가 차단된 상태(WaitSleepJoin)에서만 ThreadInterruptedException예외를 던져 스레드를 중지시킵니다. 때문에 좀 더 안전하게 스레드를 중지할 수 있습니다.

 

2. 스레드의 상태관리

 

스레드는 동작 상황에 따라 여러 가지 상태 값을 가질 수 있고 해당 상태값을 통하여 현재 스레드의 상태를 확인할 수 있습니다. 스레드에서 가질 수 있는 상태 값으로는 다음과 같은 것들이 있으며 아래 값은 ThreadState열거형으로 정의되어 있습니다.

Unstarted 객체 생성 후 Start()메서드가 호출되지 전상태
Running Start()메서드가 호출되어 스레드가 동작중인 상태
Suspended Suspend()메서드가 호출되어 스레드가 일시중지된 상태
WaitSleepJoin Enter(), Sleep(), Join()메서드가 호출되어 스레드가 Block된 상태
Aborted Aborted()메서드가 호출되어 스레드의 동작이 취소된 상태, 이 후 Stopped상태로 바뀌어 스레드가 완전지 중지됨
Stopped 스레드가 완전히 종료된 상태
Background 스레드가 백그라운드로 동작하는지의 여부

스레드가 만약 포어그라운드(Foreground) 면 프로세스가 종료되지 않는 이상 죽지 않지만 백그라운드(Background)는 프로세스의 종료 여부에 영향을 주지 않습니다.(물론 프로세스가 죽으면 스레드는 모두 소멸합니다.) 만약 스레드를 Background상태로 만들려면 IsBackground속성에 true를 설정해 줍니다.

 

스레드의 상태는 상황에 따라 바뀔 수 있을 뿐만 아니라 경우에 따라 Suspended면서 WaitSleepJoin상태일 수 있는 2가지 이상의 상태 값을 가질 수 있습니다. 따라서 2가지 상태를 표현하는 방법이 필요한데 이를 위해 ThreadState에서 각 상태 값이 다음과 같이 정의되어 있으며

Running 0
StopRequested 1
SuspendRequested 2
Background 4
Unstarted 8
Stopped 16
WaitSleepJoin 32
Suspended 64
AbortRequested 128
Aborted 255

2의 제곱으로 되어 있는 각 값을 2진수로 바꾸면 한자리만을 제외하고 모두 0으로 채워진 것을 알 수 있습니다.

32 000100000

이 상태에서 비트 연산을 수행해 각 자리에 차지하고 있는 1 값으로 현재 스레드의 상태를 모두 알 수 있는 것입니다.

1100000 Suspended and WaitSleepJoin

따라서 C#에서는 비트 연산자를 통해 스레드의 상태를 파악할 수 있습니다. 예를 들어 만약 스레드가 실행 중인지를 확인하려면 다음과 같은 방법으로 상태를 확인합니다.

static void Main(string[] args)
{
    Thread t = new Thread(Working);

    if ((t.ThreadState & (ThreadState.Stopped | ThreadState.Unstarted)) == 0) {
        
    }
}

3. 스레드 동기화

 

하나의 스레드가 특정 자원을 점유해서 동작중인데 다른 스레드가 점유 중인 자원에 또다시 접근하려 하면 문제가 생길 수 있습니다. 따라서 스레드 간 동기화를 통해 불안성을 제거하고 스레드간 일정 순서로 동작시킬 필요가 있습니다.

 

● lock

 

특정 영역의 코드를 한 번에 하나의 스레드만 사용할 수 있도록 합니다. 예를 들어 다음과 같은 경우

class Program
{
    static int count = 0;

    static void Main(string[] args)
    {
        Thread t1 = new Thread(Working);
        Thread t2 = new Thread(Working);
        Thread t3 = new Thread(Working);

        t1.Start();
        t2.Start();
        t3.Start();

        t1.Join();
        t2.Join();
        t3.Join();
        
        WriteLine(count);
    }
    static void Working()
    {
        ++count;
    }
}

마지막에서 count의 값은 전혀 예상할 수 없습니다. 모든 스레드가 t1, t2, t3순서로 정상 진행되었다면 3을 표시하겠지만 t1이 작업을 끝내기도 전에 t2가 실행될 수 있고 t2가 작업을 끝내기도 전에 t3가 실행될 수 있습니다. 스레드 순서대로 동작한다는 보장이 없는 것입니다.

 

이런 경우 lock을 사용해 하나의 스레드가 동작중일 때 다른 스레드가 끼어들지 못하게 할 수 있습니다.

class Program
{
    static int count = 0;
    static readonly object lck = new Object();

    static void Main(string[] args)
    {
        Thread t1 = new Thread(Working);
        Thread t2 = new Thread(Working);
        Thread t3 = new Thread(Working);

        t1.Start();
        t2.Start();
        t3.Start();

        t1.Join();
        t2.Join();
        t3.Join();

        WriteLine(count);
    }

    static void Working()
    {
        lock (lck)
        {
             ++count;
        }
    }
}

참고로 하나의 스레드만 사용할 수 있는 영역을 '크리티컬 섹션'이라고 하며, lock를 보면 참조형 매개변수로 object의 객체를 전달하고 있는데 여기서는 외부에서 접근할 수 있는 객체의 사용을 피해야 합니다. 반드시 lock의 전용으로 객체를 생성하고 사용하는 것이 좋습니다.

 

lock사용 시 주의해야 할 것이 하나 더 있습니다. 하나의 스레드가 크리티컬 섹션을 얻어서 동작중이면 다른 스레드는 이전에 스레드가 작업을 종료할 때까지 대기해야 하는 상황이 발생합니다. 이는 만큼 성능을 떨어질 수 있으므로 주의가 필요합니다.

 

● Monitor

 

lock과 Monitor가 하는 일은 비슷하지만 Monitor 쪽이 좀 더 세밀한 컨트롤을 가능하게 합니다.

static void Working()
{
    Monitor.Enter(lck);
    ++count;
    Monitor.Exit(lck);
}

위 예제는 lock와 동일하게 구현하기 위해 Enter()와 Exit() 메서드만 사용한 것입니다. Enter()에서 크리티컬 섹션이 만들어지고 Exit()에서 크리티컬 섹션을 해제하므로 결국 lock의 {}와 같은 역활을 하는 것입니다.

 

Monitor는 lock와 함께 사용하거나 Enter()와 Exit()메서드 사이에서 Wait()와 Pulse() 메서드를 사용해 스레드를 좀더 세부적으로 제어할 수 있도록 합니다. Wait()메서드는 스레드를 WaitSleepJoin상태로 만들며 해당 스레드가 실행중인 lock을 해제하도록 하는데 그 뒤 스레드는 Waiting Queue라는 큐에 입력되고 이 후 다른 스레드가 lock을 잡게 됩니다.

 

다른 스레드가 작업을 마치고 나서 Pulse()메서드를 호출하면 Waiting Queue에 있는 스레드를 꺼내 Ready Queue에 입력시키고 이렇게 입력된 스레드를 순서대로 동작시켜 해당 스레드를 Running상태로 만듭니다. Wait() 메서드와 마찬가지로 Sleep() 메서드도 스레드를 WaitSleepJoin상태로 만들기는 하지만 해당 스레드를 Waiting Queue에 넣지 않고 때문에 당연히 Pulse() 메서드로 스레드를 깨울 수는 없다는 차이가 있습니다.

class Program
{
    static int count = 0;
    static readonly object lck = new Object();
    
    static Queue q = new Queue();
    static bool threadLock = true;
    static bool workEnd = false;

    static void Main(string[] args)
    {
        Thread t1 = new Thread(SetQueue);
        Thread t2 = new Thread(GetQueue);

        t1.Start();
        t2.Start();

        t1.Join();
        t2.Join();
    }

    static void SetQueue()
    {
        lock(lck)
        {
            
            while (true)
            {
                ++count;
                q.Enqueue(count);

                if (count == 10 && threadLock == true && workEnd == false)
                {
                    threadLock = false;
                    Monitor.Wait(lck);
                }
                else if (workEnd == true)
                    break;
            }
        }
    }

    static void GetQueue()
    {
        lock(lck)
        {
            foreach (var item in q)
            {
                WriteLine(item);
            }

            if (threadLock == false && workEnd == false) {
                workEnd = true;
                Monitor.Pulse(lck);
            }
        }
    }
}

예제는 스레드를 2개를 실행합니다. 첫 번째 스레드는 SetQueue() 메서드를 실행해서 1부터 10까지의 값을 Queue에 담아두고 이 상태에서 count값이 10에 도달하면 Monitor의 Wait()메서드를 호출하여 자기자신을 Block상태로 만듭니다. 이 후 두번째 스레드가 GetQueue()메서드를 실행해 첫번째 스레드가 담아둔 Queue값을 출력하고 Monitor의 Pulse()메서드를 호출하여 첫번째 스레드가 다시 실행되도록 합니다.

 

첫번째 스레드는 복귀 후 다시 SetQueue()메서드를 실행하지만 workEnd값이 true이므로 while반복문을 빠져나가게 됩니다.

 

4. Task/Task<TResult>

 

Task는 비동기 처리에 사용됩니다. (Task클래스는 내부적으로 Thread로 구현되었습니다.)

 

프로그램에서 처리되는 기본 방식은 동기인데 처음 하나를 실행하고 그 하나의 작업이 끝나야 다음 작업으로 넘어가는 형태입니다. 하지만 비동기는 하나를 실행하고 나면 결과가 어찌 되었든 바로 다음 작업을 실행할 수 있습니다.

class Program
{
    static void Main(string[] args)
    {
        Action action = () =>
        {
            Thread.Sleep(3000);
            WriteLine("비동기 작업이 실행되었습니다.");
        };

        Task t = new Task(action);
        t.Start();

        WriteLine("비동기 작업이 실행됩니다.");

        t.Wait();
    }
}

Task클래스는 인스턴스를 생성할 때 Action대리자를 생성자 매개변수로 넘겨줘야 합니다. 두 번째 매개변수는 만약 Action대리자에 별도의 매개변수가 필요한 경우 해당 매개변수를 넘겨줄 수 있는 부분입니다. 이렇게 Action에 비동기로 실행할 작업을 정의해 두고 Start() 메서드를 호출하여 해당 작업을 비동기로 실행하면 '비동기 작업이 실행됩니다.'를 출력하고 그 뒤 3초의 시간이 지나 '비동기 작업이 실행되었습니다.'라는 메시지를 출력할 것입니다.

 

또는 작업 실행을 위해 Run() 메서드를 사용할 수 있는데 이런 경우 함수 자체를 매개변수로 넘겨줄 수 있습니다.

class Program
{
    static void Main(string[] args)
    {
        var t = Task.Run(() =>
        {
            Thread.Sleep(3000);
            WriteLine("비동기 작업이 실행되었습니다.");
        });

        WriteLine("비동기 작업이 실행됩니다.");

        t.Wait();
    }
}

마지막에 Wait() 메서드는 비동기 작업이 완전히 종료될때까지 기다리는 메서드입니다. 비동기작업은 일단 실행만 하고 그 뒤는 신경쓰지 않습니다. 따라서 비동기가 여진히 실행중인데도 프로세스(프로그램)이 종료되는 상황이 발생할 수 있으므로 비동기로 실행된 작업이 완전히 종료된 뒤에 프로세스가 뒤이어 종료되도록 Wait()메서드를 사용한 것입니다.

 

만약 Wait()메서드를 사용하지 않고 비동기작업이 종료되는 시점까지 대기하려면 Start() 메서드 대신 RunSynchronously() 메서드를 사용할 수도 있습니다.

 

Task에서는 Action대리자를 했지만 Task <TResult> 클래스에서는 Func대리자를 사용합니다. Func는 결과를 반환하는 대리자로 이 말은 비동기를 실행하고 나서 그 결과를 가져올 수 있다는 의미가 됩니다.

class Program
{
    static void Main(string[] args)
    {

        var Cal = new Cal(10, 20);
        var result = Task.Run(Cal.Add).Result;

        WriteLine(result);
    }
}

class Cal
{
  public Cal(int i, int j) { this.i = i; this.j = j; }
  public int i { get; set; }
  public int j { get; set; }
  public int Add() { return this.i + this.j; }
}

예제에서는 결과를 받기 위해 Result 속성을 사용했는데 실제 비동기 작업이 종료되어야 Result를 반환합니다.

 

5. Parallel

 

Parallel클래스는 직접 Task를 활용해 병렬 처리를 구현하는 것보다 더 쉽게 병렬 처리를 구현할 수 있도록 해주는 클래스입니다.

class Program
{
    static void Main(string[] args)
    {
      Parallel.For(1, 100, printNumber);
    }

    static void printNumber(int i)
    {
      WriteLine(i);
    }
}

예제는 Parallel의 For() 메서드를 통해 printNumber()라는 메서드를 호출하고 있습니다. printNumber()는 하나의 정수를 매개변수로 받고 있는데 For()에서는 1부터 100까지 printNumber() 메서드에 값을 넘기면서 병렬도 메서드를 실행하고 있습니다. 이때 printNumber() 병렬로 실행하기 위해 몇 개의 스레드를 사용할 것인지는 Parallel클래스가 알아서 판단합니다.

 

다만 스레드를 다수 생성하여 printNumber() 메서드를 실행하는 방식이므로 차례로 값을 넘기면서 메서드를 실행한다고 하더라도 정확히 1부터 100까지 순서대로 실행되다는 보장은 할 수 없습니다.

 

6. async와 await를 이용한 비동기 구현

 

특정 메서드를 실행할 때 async한정자와 await연산자를 통해 직접 비동기 작업을 수행할 수 있습니다.

class Program
{
    static void Main(string[] args)
    {
      printNumber();

      for (int i = 0; i <= int.MaxValue; i++)
      {
          WriteLine("실행" + i.ToString());
      }
    }

    async static void printNumber()
    {
      await Task.Run(() => {
        for (int i = 0; i <= int.MaxValue; i++)
        {
            WriteLine("비동기실행" + i.ToString());
        }
      });
    }
}

컴파일러는 async한정자를 만나면 결과를 기다리지 않고 바로 다음 작업이 진행되도록 하는 비동기 코드를 생성합니다. async한정자는 메서드, 이벤트, 태스크, 람다식에서 사용될 수 있으며 이 한정자를 사용하는 것만으로 비동기 코드를 만들게 됩니다.

 

다만 반환형 식이 Task/Task <TResult> 형식이거나 void여야 하므로 결과를 대기해야 하는 경우 Task/Task <TResult>를 실행만 하고 결과를 받아볼 필요가 없는 경우라면 void를 사용할 수 있습니다.

 

async가 사용된 메서드 내부에서는 await 연산자가 사용되었는데 메서드가 실행된 이후 await연산자가 등장하면 제어권을 호출자에게 돌려주게 됩니다. await를 통해 작업은 작업대로 진행하고 제어권을 돌려받은 호출자는 자신의 처리를 계속 진행해 나가면 됩니다. 예제에서 호출자는 Main() 메서드에 해당하는데 만약 await가 없는 경우라면 제어권을 돌려주지 못하는 상황이 발생하므로 동기 실행과 동일하게 처리됩니다.

 

참고로 Task작업의 지연을 위해 Task의 Delay() 메서드를 사용할 수 있는데 지정된 시간의 지나면 Task객체를 반환하도록 합니다. Thread의 Sleep() 메서드와 동일하지만 스레드 자체가 잠기지 않는다는 차이가 있습니다.

 

7. .NET Framework의 비동기 클래스 라이브러리

 

.NET Framework에서는 기존의 많은 라이브러리 중에서 일부에 비동기 기능을 추가한 메서드를 제공하고 있습니다. 이들 메서드는 메서드의 이름에 Async라는 이름을 사용하고 있으므로 메서드의 이름만으로도 비동기 메서드임을 알 수 있습니다.

class Program
{
    static int i = 0;

    static void Main(string[] args)
    {
        FileRead();

        for (i = 0; i < int.MaxValue ; i++)
        {
            //
        }
    }

    async static void FileRead()
    {
        Stream sf = new FileStream("sample.txt", FileMode.Open);
        
        byte[] b = new byte[sf.Length];
        await sf.ReadAsync(b, 0, (int)b.Length);

        WriteLine(System.Text.Encoding.ASCII.GetString(b));
        WriteLine(i);
    }
}

예제는 파일을 읽기 위해 FileStream클래스의 ReadAsync비동기 메서드를 사용한 경우입니다. 예제와 같이 .NET의 비동기 메서드를 사용하는 경우라 하더라도 역시 async한정자가 수식되어 있어야 합니다.

728x90

'.NET > C#' 카테고리의 다른 글

[C#] TCP/IP 통신  (0) 2021.10.28
[C#] Thread(스레드)와 Task(태스크)  (0) 2021.10.24
[C#] 파일과 디렉터리 다루기  (0) 2021.10.21
[C#] dynamic 형식  (0) 2021.10.21
[C#] 리플렉션과 애트리뷰트  (0) 2021.10.20
[C#] LINQ  (0) 2021.10.19

관련글 더보기

댓글 영역