C# 기초 정리: 스레드, 태스크

Table of Content

스레드(Thread)

  • 스레드: OS가 CPU 시간을 할당하는 기본 단위
  • .NET 프레임워크는 System.Threading.Thread 클래스를 제공
  • 장점
    • 응답성 제고 (예: 파일 복사 중에 사용자 명령을 입력받도록 할 수 있음)
    • 자원공유 용이: 멀티 프로세스 방식은 IPC(Inter Process Communication) 방싱을 사용하지만 멀티 스레드 방식은 코드 내 변수를 사용
    • 경제적: 멀티 프로세스 방식은 메모리/CPU 자원할당 비용이 비싸지만 멀티 스레드 방식은 이미 프로세스에 할당된 자원을 그대로 활용
  • 단점
    • 구현 및 디버깅이 어려움
    • 자식 스레드 중 하나에 문제가 생기면 프로세스에 문제를 줄 수 있음
    • 너무 많이 사용하면 성능 저하: 작업 간 전환(Context Switching) 비용 상승
  • 단일 스레드를 사용할지, 멀티 스레드를 사용할 지는 프로그래머의 판단에…
class Program
{
    /* 스레드가 실행할 메소드 */
    public static void DoSomething()
    {
        for (int i = 0; i < 10; i++)
        {
            Console.WriteLine("DoSomething(): {0}", i);
            Thread.Sleep(10);
        }
    }
 
    public static void Main(string[] args)
    {
        /* 1. Thread 인스턴스 생성. 
         * 생성자의 매개변수는 스레드가 실행할 메소드​ */
        Thread t1 = new Thread(new ThreadStart(DoSomething));
 
        /* 2. Thread 시작: Thread.Start() */
        t1.Start();
 
        /* Thread 작동 중에 아래 for문도 작동 */
        for(int i=0; i<10; i++)
        {
            Console.WriteLine("Main(): {0}", i);
            Thread.Sleep(10);
        }
 
        /* 참고: Thread.Abort()는 스레드를 임의로 종료 
         * Abort() 메소드를 호출한다고 해서 동작하던 스레드가 즉시 종료된다는 보장은 없음​
         * 1) Abort() 메소드가 호출되면 CLR은 ThreadAbortException 을 호출​
         * 2) 예외를 catch하는 코드와 finally 블록까지 실행한 후에 스레드는 완전 종료​
         * ※ Thread.Abort()는 사용하지 않는 것이 좋음​
         *  - 자원을 독점한 스레드가 자원을 해재하지 못한 채 죽어버리면​ 문제가 생길 수 있음​ */
        t1.Abort();
 
        /* 3. Thread가 완전히 정지할 때까지 대기: Thread.Join() */
        t1.Join();
    }
}

 

스레드의 상태 변화

  • NET 프레임워크는 스레드의 상태를 ThreadState 열거형에 정의: Flags 애트리뷰트를 갖고 있으므로 비트 필드(연산)를 사용하여​ 두 가지 이상이 상태를 정의할 수 있음​
    • ThreadState.Unstarted: Thread.Start() 호출 전
    • ThreadState.Running: Thread.Start()를 호출하여 스레드가 동작중인 상태
    • ThreadState.Suspended: Thread.Suspended()에 의해 스레드가 일시 중단된 상태. Thread.Resume()에 의해 Running 상태로 변화
    • ThreadState.WaitSleepJoin: Thread.Sleep(), Thread.Join(), Moniter.Enter() 메소드에 의해​ 스레드가 블록(Block)된 상태
    • ThreadState.Aborted: Thread.Abort()에 의해 스레드가 취소된 상태
    • ThreadState.Stopped: Thread.Abort()를 호출하거나 ​스레드가 실행 중인 메소드가 종료된 상태
    • Thread.Background: 스레드가 백그라운드에서 동작중인 상태​. 포어그라운드(Foreground) 스레드가 하나라도 살아있는 한 프로세스는 죽지 않지만, 백그라운드(Background) 스레드가 여러 개 살아있어도 프로세스의 생애엔 영향을 미치지 않음.​ Thread.IsBackground 속성에 true를 입력하면 Background 상태가 됨​

 

인터럽트(Interrupt)

  • Thread.Interrup() 메소드는 Running 상태를 피해서 WaitJoinSleep 상태일 때 ThreadInterruptedException 예외를 던져 스레드를 중지시킴​
  • WaitJoinSleep 상태일 땐 스레드를 즉시 중지시키지만​ 이 상태가 아닌 경우 WaitJoinSleep 상태가 될 때까지 지켜보고 있다가 중지시킴​
  • “절대로 중단되면 안 되는” 작업 중일 때 중단되지 않음을 보장​
class Program
{
    public static void DoSomething()
    {
        try
        {
            for(int i=0; i < 10000; i++)
            {
                Console.WriteLine("DoSomething(): {0}", i);
                Thread.Sleep(10);
            }
        }
 
        catch(ThreadInterruptedException e)
        {
            Console.WriteLine(e);
        }
 
        finally { }
    }
 
    public static void Main(string[] args)
    {
        Thread t1 = new Thread(new ThreadStart(DoSomething));
        t1.Start();
        t1.Interrupt();
        t1.Join();
    }
}

 

스레드 동기화(Thread Synchronization)

  • 한 자원을 여러 스레드가 사용하면 연산 값이 달라질 수 있음
  • 자원을 한 번에 하나의 스레드만 사용하도록 해야 이 문제를 해결
  • 이를 위해 크리티컬 섹션(Critical Section, 한 번에 한 스레드만 사용할 수 있는 코드 영역)을 생성해야 함

 

lock 키워드를 이용한 크리티컬 섹션 생성

  • lock 키워드를 사용하는 건 간단하지만 데드락 발생 가능성이 있음
  • lock 키워드의 매개변수는 참조형이면 어느 것이든 사용 가능
    • public 키워드 등을 통해 외부에서 접근 가능한 형식(this, Type, string)은 사용하면 안 됨
class Counter
{
    const int LOOP_MAX = 1000;
    public int Count { get; set; } = 0;
    private readonly object thisLock = new object(); // 동기화 객체 필드
 
    public void Increase()
    {
        /* 크리티컬 섹션 */
        lock (thisLock)
        {
            for (int i = 0; i < LOOP_MAX; i++)
            {
                Count++;
            }                
        }
    }
 
    public void Decrease()
    {
        /* 크리티컬 섹션 */
        lock (thisLock)
        {
            for (int i = 0; i < LOOP_MAX; i++)
                Count--;
        }
    }
}
 
class Program
{
    public static void Main(string[] args)
    {
        Counter c = new Counter();
        Thread t1 = new Thread(new ThreadStart(c.Increase));
        Thread t2 = new Thread(new ThreadStart(c.Decrease));
 
        t1.Start();
        t2.Start();
 
        t1.Join();
        t2.Join();
 
        Console.WriteLine(c.Count); // 항상 0 출력
    }
}

 

Monitor 클래스를 이용한 크리티컬 섹션 생성

  • Monitor.Enter() 메소드: 크리티컬 섹션 생성
  • Monitor.Exit() 메소드: 크리티컬 섹션 해제
    • ※ lock 키워드도 Monitor.Enter(), Monitor.Exit() 메소드를 바탕으로 구현됨
class Counter
{
    const int LOOP_MAX = 1000;
    public int Count { get; set; } = 0;
    private readonly object thisLock = new object(); // 동기화 객체 필드
 
    public void Increase()
    {
        for (int i = 0; i < LOOP_MAX; i++)
        {
            Monitor.Enter(thisLock); // 크리티컬 섹션 생성
            try { Count++; }
            finally { Monitor.Exit(thisLock); } // 크리티컬 생성 해제
        }
    }
 
    public void Decrease()
    {
        for (int i = 0; i < LOOP_MAX; i++)
        {
            Monitor.Enter(thisLock); // 크리티컬 섹션 생성
            try { Count--; }
            finally { Monitor.Exit(thisLock); } // 크리티컬 생성 해제
        }
    }
}
 
class Program
{
    public static void Main(string[] args)
    {
        Counter c = new Counter();
        Thread t1 = new Thread(new ThreadStart(c.Increase));
        Thread t2 = new Thread(new ThreadStart(c.Decrease));
 
        t1.Start();
        t2.Start();
 
        t1.Join();
        t2.Join();
 
        Console.WriteLine(c.Count); // 항상 0 출력
    }
}
  • Monitor.Wait(): 스레드를 WaitSleepJoin 상태로 만듦
    • WaitSleepJoin 상태가 된 스레드는 lock을 내려놓은 뒤 Waiting Queue에 입력됨
    • 이후 다른 스레드가 lock을 얻어 작업 수행
  • Monitor.Pulse(): Waiting Queue의 첫 요소 스레드를 꺼내 Ready Queue에 입력
    • Ready Queue에 입력된 스레드는 다시 작업 수행
  • ※ Thread.Sleep()메소드
    • 스레드를 WaitSleepJoin 상태로 만들지만 Monitor.Wait()에 의해 Waiting Queue 진입이 안 되고 Monitor.Pulse()에 의해 깨어날 수도 없음.
    • 다시 Running 상태로 돌아오려면 매개변수로 입력된 시간이 경과되거나 Interrupt() 메소드 호출을 받아야 함
class Counter
{
    const int LOOP_MAX = 100000000;
    public int Count { get; set; } = 0;
    private readonly object thisLock = new object(); // 동기화 객체 필드
    bool lockedCount = false; // 다른 스레드의 Count 사용여부 판별용
 
    public void Increase()
    {
        for (int i = 0; i < LOOP_MAX; i++)
        {
            Monitor.Enter(thisLock); // 크리티컬 섹션 생성
            try
            {
                /* 다른 스레드가 lock을 반납할 때까지 대기 */
                while (lockedCount == true)
                    Monitor.Wait(thisLock);
 
                /* 다른 스레드가 lock을 반납하면 작업 수행 */
                lockedCount = true;
                Count++;
                lockedCount = false;
                Monitor.Pulse(thisLock);
            }
 
            finally { Monitor.Exit(thisLock); } // 크리티컬 생성 해제
        }
    }
 
    public void Decrease()
    {
        for (int i = 0; i < LOOP_MAX; i++)
        {
            Monitor.Enter(thisLock); // 크리티컬 섹션 생성
            try
            {
                /* 다른 스레드가 lock을 반납할 때까지 대기 */
                while (lockedCount == true)
                    Monitor.Wait(thisLock);
 
                /* 다른 스레드가 lock을 반납하면 작업 수행 */
                lockedCount = true;
                Count--;
                lockedCount = false;
                Monitor.Pulse(thisLock);
            }
 
            finally { Monitor.Exit(thisLock); } // 크리티컬 생성 해제
        }
    }
}
 
class Program
{
    public static void Main(string[] args)
    {
        Counter c = new Counter();
        Thread t1 = new Thread(new ThreadStart(c.Increase));
        Thread t2 = new Thread(new ThreadStart(c.Decrease));
 
        t1.Start();
        t2.Start();
 
        t1.Join();
        t2.Join();
 
        Console.WriteLine(c.Count); // 항상 0 출력
    }
}

 

동기 코드와 비동기 코드

  • 동기 코드(Synchronous Code): 한 메소드의 실행이 완전히 종료되어야만 다음 코드를 수행
  • 비동기 코드(Asynchronous Code)
    • 한 메소드의 종료를 기다리지 않고 바로 다음 코드를 수행
    • async 한정자와 await 연산자를 이용하여 구현

 

Task 클래스

  • (병렬 처리를 위한) 병행서 코드나 비동기 코드를 개발자가 손쉽게 작성할 수 있도록 돕는 클래스
  • 인스턴스 생성 시 void형 메소드, 익명 메소드, 무명 함수 등을 넘겨받음
class Program
{
    static void VoidMethod()
    {
        Console.WriteLine("# VoidMethod 비동기 호출");
    }
 
    public static void Main(string[] args)
    {
        /* void형 메소드를 이용한 Task 클래스 인스턴스 */
        Task voidTask = new Task(VoidMethod);
        voidTask.Start();
        voidTask.Wait(); // 비동기 호출 완료 시까지 대기
 
        /* Action 델리게이트를 이용한 Task 클래스 인스턴스 */
        Action someAction = () =>
        {
            Console.WriteLine("# someAction 비동기 호출");
        };
        Task actionTask = new Task(someAction);
        actionTask.Start();
        actionTask.Wait(); // 비동기 호출 완료 시까지 대기
 
        /* 일반적인 Task 인스턴스 생성: Task.Run() 메소드 이용 */
        Task myTask1 = Task.Run( () =>
        {
            Console.WriteLine("# myTask1 비동기 호출");
        });
        myTask1.Wait();
 
        /* 동기적으로 실행하는 Task 인스턴스 */
        Task myTask2 = new Task(() =>
        {
            Console.WriteLine("# myTask2 비동기 호출");
            Console.WriteLine(Task.CurrentId); // 현재 실행중인 Task의 ID
            Console.WriteLine(Thread.CurrentThread.ManagedThreadId); // 현재 관리중인 스레드의 ID
        });
        myTask2.RunSynchronously();
        myTask1.Wait();
    }
}

 

Task<TResult> 클래스

  • 비동기 실행 결과를 반환
 클래스 예제" >class Program
{
    public static void Main(string[] args)
    {
        /* List<int> 형식을 반환하는 Task */
        Task<List<int>> myTask = Task<List<int>>.Run(() =>
        {
            List<int> list = new List<int>();
            list.Add(3);
            list.Add(4);
            list.Add(5);
            return list;
        });
 
        List<int> myList = new List<int>();
        myList.Add(0);
        myList.Add(1);
        myList.Add(2);
        myList.AddRange(myTask.Result.ToArray());
 
        foreach (int i in myList)
            Console.WriteLine(i);
    }
}

 

Parallel 클래스

  • Task보다 더 쉽게 병렬처리를 구현- For(), Foreach() 등의 메소드를 제공
  • 몇 개의 스레드를 사용할지는 자동으로 판단하여 최적화
class Program
{
    static void MyMethod(int i)
    {
        Console.WriteLine(i);
    }
 
    public static void Main(string[] args)
    {
        Parallel.For(0, 100, MyMethod); // 0부터 100 사이의 정수를 메소드의 매개변수로 넘김
    }
}

 

async 한정자와 await 연산자

  • async 한정자로 수식된 메소드, 이벤트 처리기, 태스크, 람다식 등의 코드를 만난 C# 컴파일러는 호출 결과를 기다리지 않고 바로 다음 코드로 이동
  • async 한정자의 제약조건: 반환 형식이 Task, Task<TResult> 또는 void여야 함
    • 실행하고 잊어버릴(Shoot and Forget) 메소드 구현 시 void로 선언
    • 작업이 완료될 때까지 기다리는 메소드 구현 시 Task 또는 Task<TResult>로 선언
  • async로 선언한 void형식 메소드는 완전한 비동기 코드가 됨
  • async로만 선언한 Task, Task<TResult> 형식의 메소드는 보통의 동기코드와 다름없음
    • await 연산자를 만난 곳에서 호출자에게 제어를 돌려 줌
class Program
{
    /* async 한정자로 선언한 void형식 메소드는 완전한 비동기 코드가 됨 */
    async static private void MyMethodAsync(int count)
    {
        /* 실행순서: 3 */
    Console.WriteLine("C");
        Console.WriteLine("D");
 
        /* 실행순서: 4 */
        await Task.Run( async () =>
        {
            /* 실행순서: A */
            for (int i = 1; i <= count; i++)
            {
                Console.WriteLine("{0}/{1}", i, count);
                await Task.Delay(100);
            }
        });
 
        Console.WriteLine("G");
        Console.WriteLine("H");
    }
 
    public static void Caller()
    {
        /* 실행순서: 1 */
        Console.WriteLine("A");
        Console.WriteLine("B");
 
        /* 실행순서: 2 */
        MyMethodAsync(5);
 
        /* 실행순서: B */
        Console.WriteLine("E");
        Console.WriteLine("F");
    }
 
    /* 이 프로그램의 실행순서: 1 -> 2 -> 3 -> 4 -> A, B */
    public static void Main(string[] args)
    {
        Caller();
        Console.ReadLine(); // 프로그램 종료 방지용 코드
    }
}

 

.NET 프레임워크가 제공하는 비동기 API

  • .NET 프레임워크 클래스 라이브러리에 ‘~Async()’라는 명칭의 메소드들은 비동기 메소드
    • 예: System.IO.Stream 클래스가 제공하는 비동기 메소드는ReadAsync(), WriteAsync()
class Program
{
    /* 파일 복사 후 파일 크기를 반환하는 Task */
    static async Task<long> CopyAsync(string fromPath, string ToPath)
    {
        using (var fromStream = new FileStream(fromPath, FileMode.Open))
        {
            long totalCopied = 0; // 복사된 파일 크기
 
            using(var toStream = new FileStream(ToPath, FileMode.Create))
            {
                byte[] buffer = new byte[1024];
                int nRead = 0;
                while((nRead = await fromStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
                {
                    await toStream.WriteAsync(buffer, 0, nRead);
                    totalCopied += nRead;
                }
            }
 
            return totalCopied;
        }
    }
 
    static async void DoCopy(string FromPath, string ToPath)
    {
        long totalCopied = await CopyAsync(FromPath, ToPath);
        Console.WriteLine("총 {0} 바이트가 복사되었습니다.", totalCopied);
    }
 
    public static void Main(string[] args)
    {
        if (args.Length < 2)
        {
            Console.WriteLine("사용법: MyFirstConsoleApp <Source> <Destination>");
            return;
        }
 
        DoCopy(args[0], args[1]);
        Console.ReadLine();
    }
}

 

댓글 남기기