Skip to main content

[This is C#] Chapter 19. 스레드와 태스크

이것이 C#이다 책을 읽고 공부한 노트입니다.




프로세스와 스레드 #

  • 프로세스(Process)
    • 프로그램이 실행되어 메모리에 적재된 인스턴스이다.
  • 스레드(Thread)
    • 프로세스 내에서 실행되는 흐름의 단위이다.
    • 운영체제가 CPU 시간을 할당하는 기본 단위이다.

  • 멀티 스레드의 장점
    • 응답성을 높일 수 있다.
    • 자원 공유가 쉽다.
    • 자원을 공유하므로 경제적이다.
  • 멀티 스레드의 단점
    • 구현이 까다롭다.
    • 스레드를 너무 많이 사용하면 문맥교환이 잦아져서 오히려 성능이 더 저하된다.

스레드 시작하기 #

  • .NET은 스레드를 제어하는 System.Threading.Thread 클래스를 제공한다.
    1. Thread의 인스턴스를 생성한다. 인수로 실행할 메소드를 넘긴다.
    2. Thread.Start()메소드를 호출해서 스레드를 시작한다.
    3. Thread.Join()메소드를 호출해서 스레드가 끝날 때까지 기다린다.

스레드 임의로 종료시키기 #

  • Thread.Abort()메소드를 호출하면 즉시 종료되지는 않는다.
    • CLR은 해당 스레드가 실행 중이던 코드에 ThreadAbortException을 던진다. 이 때 예외를 catch하는 코드가 있으면 처리한 다음 finally 블록까지 실행한 후에야 해당 스레드는 완전히 종료된다.
    • 왠만하면 사용하지 않는 것이 좋다. 해당 스레드가 독점 자원을 잠근 후 종료되면, 그 자원은 해제되지 못하기 때문이다.

스레드의 상태 변화 #

  • .NET은 스레드의 상태를 ThreadState 열거형에 정의해두었다.
상태 설명
Unstarted 스레드 객체를 생성한 후 Thread.Start() 메소드가 호출되기 전의 상태이다.
Running 스레드가 시작하여 동작 중인 상태이다. Unstarted에서 Thread.Start()하면 이 상태가 된다.
Suspended 스레드의 일시 중단 상태이다. Thread.Suspend() 메소드를 통해 이 상태로 만들 수 있으며, Thread.Resume() 메소드를 통해 다시 Running 상태로 만들 수 있다.
WaitSleepJoin 스레드가 블록(Block)된 상태이다. Monitor.Enter()Thread.Sleep()Thread.Join() 메소드를 호출하면 이 상태가 된다.
Aborted 스레드가 취소된 상태이다. Thread.Abort() 메소드를 호출하면 이 상태가 된다. 이후 Stopped 상태로 전환되어 완전히 중지된다.
Stopped 스레드가 중지된 상태이다. Thread.Abort() 메소드를 호출하거나 실행 중인 메소드가 종료되면 이 상태가 된다.
Background 스레드가 백그라운드로 동작하고 있음을 나타낸다. 포어그라운드의 경우 스레드가 하나라도 살아 있으면 프로세스가 죽지 않는다. 반면 백그라운드의 경우 스레드가 살아 있는지와 관계없이 프로세스가 죽으면 스레드도 모두 죽는다.

  • ThreadState 열거형은 Flags애트리뷰트를 갖고 있다.
    • 따라서 이 열거형의 요소들의 집합으로 표현할 수 있다. 즉, 비트 필드(Bit field)로 처리할 수 있다.
[Flags]
enum MyEnum
{
    Apple = 1 << 0,
    Orange = 1 << 1,
    Kiwi = 1 << 2,
    Mango = 1 << 3,
};

Console.WriteLine((MyEnum) 1);       // Apple
Console.WriteLine((MyEnum) (1 | 4)); // Apple, Kiwi 
// Flags 애트리뷰트를 갖고 있는 열거형은 요소의 집합으로 표현될 수 있다.

  • 비트 연산을 통해 ThreadState 필드의 값을 확인할 수 있다.
상태 10진수 2진수
Running 0 000000000
StopRequested 1 000000001
SuspendRequested 2 000000010
Background 4 000000100
Unstarted 8 000001000
Stopped 16 000010000
WaitSleepJoin 32 000100000
Suspended 64 001000000
AbortRequested 128 010000000
Aborted 256 100000000
Thread th = new Thread(...);

if ((th.ThreadState & ThreadState.Aborted) == ThreadState.Aborted)
    Console.WriteLine("스레드가 정지했습니다.");
else if ((th.ThreadState & ThreadState.Stopped) == ThreadState.Stopped)
    Console.WriteLine("스레드가 취소되었습니다.");

인터럽트: 스레드를 임의로 종료하는 다른 방법 #

  • Thread.Interrupt()메소드
    • Thread.Abort()메소드는 강제로 종료하지만, Thread.Interrupt()메소드는 스레드가 동작중인 상태(Running 상태)를 피해서 WaitJoinSleep상태에 들어갔을 때 ThreadInterruptedException 예외를 던져서 스레드를 중지시킨다.
    • 절대로 중단되면 안 되는 작업을 하고 있을 때는 중단되지 않는다는 보장을 받을 수 있다.

스레드 간의 동기화 #

  • 동기화(Synchronization)
    • 스레드의 자원에 대한 접근을 제어하는 방법이다.
    • 스레드 동기화에서 가장 중요한 것은 자원을 한 번에 하나의 스레드가 사용하도록 보장하는 것이다.
    • .NET은 lock 키워드와 Monitor 클래스를 제공한다.

  • lock 키워드로 동기화하기

    • lock 키워드로 코드 영역을 감싸주면 크리티컬 섹션으로 바꿀 수 있다.
    • 한 스레드가 그 코드를 실행하고 마칠 때까지 다른 스레드는 그 코드 영역을 실행할 수 없다.
  • lock 키원드의 매개변수로 사용하는 객체로 부적절한 것들이 있다. 외부 코드에서도 접근할 수 있는 것은 안 된다.

    • this
    • Type 형식
      • typeof 연산자나 GetType()메소드는 피하자.
    • string 형식

class Counter
{
    const int LOOP_COUNT = 100;

    private readonly object thisLock;

    private int count;
    public int Count { get => count; }

    public Counter()
    {
        thisLock = new object();
        count = 0;
    }

    public void Increase()
    {
        int loopCount = LOOP_COUNT;
        while (loopCount-- > 0)
        {
            lock (thisLock) // 여기는 크리티컬 섹션이다
            {
                count++;
                Console.WriteLine($"Increase: {count}");
            }
            Thread.Sleep(1);
        }
    }

    public void Decrease()
    {
        int loopCount = LOOP_COUNT;
        while (loopCount-- > 0)
        {
            lock (thisLock) // 여기는 크리티컬 섹션이다
            {
                count--;
                Console.WriteLine($"Decrease: {count}");
            }
            Thread.Sleep(1);
        }
    }
}

class MainApp
{
    static void Main(string[] args)
    {
        Counter counter = new Counter();

        // 두 개의 스레드 
        Thread inc = new Thread(new ThreadStart(counter.Increase));
        Thread dec = new Thread(new ThreadStart(counter.Decrease));

        // 스레드 시작
        inc.Start();
        dec.Start();

        // 스레드가 끝날 때까지 기다린다
        inc.Join();
        dec.Join();

        Console.WriteLine($"Count: {counter.Count}"); // 0
    }
}

  • Monitor 클래스로 동기화하기
    • lock 키워드로 똑같은 기능을 하는 Monitor.Enter()(크리티컬 섹션 만들기)와 Monitor.Exit()(크리티컬 섹션 제거하기)이 있다.
    • lock 키워드도 사실은 이 두 메서드들을 바탕으로 구현되어있다.
public void Increase()
{
    int loopCount = LOOP_COUNT;
    while (loopCount-- > 0)
    {
        Monitor.Enter(thisLock);
        try
        {
            count++;
        }
        finally
        {
            Monitor.Exit(thisLock);
        }
        Thread.Sleep(1);
    }
}

  • lock보다 좀 더 섬세하게 멀티 스레드 간의 동기화를 가능하게 해주는 메서드들…
    • Monitor.Wait()메서드
      • 스레드를 WaitSleepJoin상태로 만든다. 해당 스레드는 갖고 있던 lock을 내려놓고 Waiting Queue에 입력된다.
    • Monitor.Pulse()메서드
      • CLR은 Waiting Queue에서 첫 번째 위치에 있는 스레드를 꺼내서 Ready Queue에 입력시킨다. 그 스레드는 차례로 lock을 얻어서 Running상태에 들어간다.
public void Increase()
{
    int loopCount = LOOP_COUNT;
    while (loopCount-- > 0)
    {
        lock (thisLock)
        {
            // count가 0보다 크거나 
            // 다른 스레드에 의해 lockedCount가 true가 되면
            // Wait()으로 블록된다. 
            while (count > 0 || lockedCount == true)
                Monitor.Wait(thisLock);

            lockedCount = true;  // 다른 스레드가 사용하지 못하도록한다. 
            count++;

            lockedCount = false;  
            Monitor.Pulse(thisLock); // 다른 스레드를 깨운다.
        }
        Thread.Sleep(1);
    }
}



Task와 Task 그리고 Parallel #

  • 지금까지 보았던 멀티 스레드는…
    • 여러 개의 작업을 각각 처리해야하는 상황이었다. (쪼갠 것 아님)
  • 지금부터 볼 병렬 처리는…
    • 하나의 작업을 쪼갠 뒤 쪼개진 작업들을 동시에 처리하는 것이다.
  • 비동기(Asynchronous) 처리는…
    • 메소드를 호출한 뒤에 메소드의 종료를 기다리지 않고 바로 다음 코드를 실행하는 것이다.

System.Threading.Tasks.Task 클래스 #

  • Task 클래스
    • 비동기적으로 실행되는 단일 작업이다.
    • 인스턴스를 생성할 때 Action 대리자를 넘겨받는다. 즉, 반환형을 갖지 않는 메소드, 익명 메소드, 무명 함수 등을 넘겨받는다.
// 실행할 작업물
Action action = () =>
{
    Thread.Sleep(1000); // 이 코드에 상관없이 "동기"가 출력된다. 
    Console.WriteLine("비동기");
};

// Task로 할당
Task task = new Task(action);
// 비동기로 실행한다. 
task.Start(); 

Console.WriteLine("동기");

// 비동기 코드가 완료될 때까지 대기한다. 
task.Wait(); 
// 위와 같은 코드인데

// Task의 생성과 시작을 한번에 한다.
var task = Task.Run(() => 
{
    Thread.Sleep(1000);
    Console.WriteLine("비동기");
});

Console.WriteLine("동기");

task.Wait();

코드의 비동기 실행 결과를 주는 Task 클래스 #

  • Task<Result> 클래스
    • 비동기 실행 결과를 손쉽게 얻을 수 있다.
    • 인스턴스를 생성할 때 Func 대리자를 넘겨받아서 결과를 반환한다.
var task = Task<List<int>>.Run(() => 
{
    Thread.Sleep(1000);

    List<int> list = new List<int>();
    list.Add(3);
    list.Add(4);
    list.Add(5);

    return list;
});

task.Wait();

List<int> total = new List<int>();
total.Add(0);
total.Add(1);
total.Add(2);

total.AddRange(task.Result.ToArray()); // 0, 1, 2, 3, 4, 5

손쉬운 병렬 처리를 가능케 하는 Parallel 클래스 #

  • System.Threading.Tasks.Parallel 클래스
    • For(), Foreach() 등의 메소드로 병렬 처리를 좀 더 쉽게 구현할 수 있다.
    • 해당 메소드를 병렬로 호출할 때 몇 개의 스레드를 사용할지는 Parallel클래스가 내부적으로 판단하여 최적화한다.
List<int> total = new List<int>();

// 0부터 100사이의 정수를 메소드의 인수로 넘긴다. 
// 병렬로 처리된다. 
Parallel.For(0, 100, (int i) =>
{
    if (i % 2 == 0)
        lock (total)
            total.Add(i);
});

for (int i = 0; i < total.Count; i++)
    Console.Write($"{total[i]} ");



async 한정자와 await 연산자로 만드는 비동기 코드 #

  • async 한정자

    • 컴파일러에게 해당 메서드가 비동기 작업이 포함된 await를 가지고 있음을 알려준다.
    • 내부에서 await 연산자를 찾으면 그곳에서 호출자에게 제어를 돌려준다.
    • 내부에서 await 연산자를 만나지 못하면 제어를 돌려주지 않으므로, 그냥 동기적으로 실행하게 된다.
    • 반환 형식
      • void: 실행하고 잊어버릴 작업을 수행하는 메소드일 때. 리턴형이 없으므로 비동기 메서드를 호출하는 쪽에서 비동기 제어를 할 수 없게 된다.
      • Task: 리턴값이 없는 경우.
      • Task<Result>: 리턴값이 있는 경우.
  • await 연산자

    • 단항 연산자이며 awaitable 클래스(GetAwaiter()메서드를 갖는다)를 인수로 갖는다.
      • 이것은 Task 혹은 Task<T> 가 일반적이다.
    • 이 작업을 하는 동안 스레드가 차단되지 않도록(다른 일도 할 수 있도록)한다. 그리고 그 결과를 사용하기 전에 해당 Task가 끝날 때까지 기다린다. 완료 되면 다음 실행문부터 실행을 계속한다.

  • async 메소드의 리턴형 종류
// void
public async void AsyncFunc()
{ }

// Task
public async Task AsyncFunc()
{ 
        await Task.Delay(1000);
}

// Task<TResult>
public async Task<int> AsyncFunc()
{ 
        await Task.Delay(1000);

        return 1;
}

  • void 를 반환하는 async 메서드
public static void Main(string[] args)
{
    TaskTest();
    System.Console.WriteLine("Main Thread is NOT Blocked");
    Console.ReadLine();
}
private static async void TaskTest()
{
    await Task.Delay(5000); // 5초를 기다린다. 
    System.Console.WriteLine("TaskTest Done");
}

  • Task를 반환하는 async 메서드
// async 한정자가 있으므로 await를 가질 수 있다. 
public static async Task Main(string[] args)
{
    Task t = TaskTest1();
    
    System.Console.WriteLine("Do Something Before TaskTest");

    await t; // 여기서 t가 끝날 때까지 기다린다. 

    System.Console.WriteLine("Do Something after TaskTest");

    Console.ReadLine();
}

private static async Task TaskTest1()
{
    await Task.Delay(5000); // 5초를 기다린다. 
    System.Console.WriteLine("TaskTest Done");
}

  • Task<TResult>를 반환하는 async 메서드
public static async Task Main(string[] args)
{
    Task<int> t = TaskTest();
            
    for(int i = 0; i < 10; i++)
    {
        System.Console.WriteLine("Do Something Before TaskTest");
    }
 
    int UID = await t; // await을 통해서 반환값을 추출할 수 있다. 
 
    Console.WriteLine($"UserID : {UID}");
 
    Console.ReadLine();
}

private static async Task<int> TaskTest()
{
    int UID = await ...; // DB or server에서 UID 얻어오는 비동기 메서드 호출
    System.Console.WriteLine("TaskTest Done");
 
    return UID;
}

  • awaitasync 예제
async static private void AsyncMethod(int count)
{
    Console.WriteLine("B");

    // 호출자에게 제어를 돌려주고 아래 코드는 비동기적으로 처리된다. 
    // 아래 Task가 끝나야지 다음 문장이 실행 된다. 
    await Task.Run(async () =>
    {
        for (int i = 0; i < count; i++)
        {
            Console.WriteLine($"{i} / {count} ...");
            await Task.Delay(100); // Thread.Sleep()의 비동기 버전이다. 
        }
    });

    Console.WriteLine("D");
}

static void Caller()
{
    Console.WriteLine("A");

    AsyncMethod(3);

    Console.WriteLine("C");
}

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

    Console.ReadLine(); // (프로그램 종료 방지)
}
A
B
C
0 / 3 ...
1 / 3 ...
2 / 3 ...
D