스레드(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(); } }