비동기 프로그래밍을 이해하기에 앞서, 동시성(Concurrency) 개념을 명확히 해야 한다. 동시성은 단순히 여러 작업을 번갈아 처리하는 시분할(Time-sharing)과 동일한 개념이 아니며, 여러 작업을 동시에 처리하는 능력을 의미하는 추상적인 정책에 가깝다.
초기 싱글 코어 컴퓨터 시절에는 시분할 방식인 컨텍스트 스위칭(Context Switching)이 동시성을 구현하는 주요 방식이었기에 두 개념이 혼용되었다. 하지만 현대의 동시성은 다중 코어를 활용한 병렬 처리(Parallelism)나 시분할을 모두 포함하는 더 넓은 개념이다.
- 멀티 스레딩: 다수의 스레드를 만들어 동시 실행
- 병렬 처리: 여러 작업을 다수의 코어(또는 스레드)에 분배하여 동시 실행
동시성을 코드 레벨에서 구현하는 방식 중 하나가 비동기 프로그래밍이다. 특정 작업의 완료를 기다리며 프로그램의 주 흐름이 멈추지 않고, 해당 작업을 독립적으로 처리하는 방식이다. 이를 통해 서버는 한 유저의 요청을 처리하는 동안 다른 유저의 요청에도 응답할 수 있어 효율을 극대화한다.
비동기 프로그래밍은 코드 흐름이 순차적이지 않아 특정 작업이 끝난 뒤 '무엇을 할 것인가'를 명시해야 한다. 이를 후속 작업(Continuation)이라고 한다. 과거에는 다음 패턴으로 후속 작업을 표현했다.
IAsyncResult.CompletedSynchronously
속성으로 해결할 수 있지만, 동일한 로직이 호출부와 콜백 내부에 분산되어 유지보수성 하락void
를 반환하여 개별 작업 추적이 어렵고, EventArgs
를 통해 결과 확인. SynchronizationContext
를 도입하여 후속 작업이 특정 스레드(예: UI 스레드)에서 실행가능 하게 한 발전기존 모델의 문제점을 해결하기 위해 TPL(Task Parallel Library)과 Task
가 등장했다.
과거에는 Thread
를 직접 사용해 멀티스레딩을 구현했다. 하지만 각 스레드는 OS 스레드에 직접 매핑되어 생성과 소멸 비용이 컸고, Join
메서드는 스레드를 블로킹하여 비효율적이었다.
Task
는 비동기 작업을 나타내는 고수준으로 추상화된 객체다. Task
는 특정 스레드를 의미하는 것이 아니라, 미래에 완료될 작업 자체를 의미한다. 작업의 상태, 결과, 예외를 캡슐화하여 호출자가 작업의 상태를 안전하고 간단하게 처리할 수 있다.
Task.Run
: CPU 바운드 작업을 스레드 풀에서 비동기적으로 실행할 때 쓴다.Task.Factory.StartNew
에 비해 파라미터가 단순화되었다.async/await
: I/O 바운드 작업에 사용됩니다. (자세한 내용은 아래에서 설명)Task.ContinueWith
: 후속 작업을 연결할 때 사용한다. 선행 Task
가 후속 작업 델리게이트에 인자로 전달되며, 여러 ContinueWith
를 호출하여 작업을 체이닝할 수 있다.async/await
는 비동기 코드를 동기 코드처럼 보이게 만들어 가독성과 유지보수성이 높다. async
메서드가 반환한 Task
에 await
키워드를 사용하면, 현재 코드의 진행 흐름은 잠시 멈추고 스레드 풀(ThreadPool)에서 다른 작업을 가져와 실행한다. 이 간단한 처리 이면에 다음 과정이 숨어있다.
상태 머신(State Machine) 생성
async
한정자가 붙은 메서드를 상태 머신 객체를 생성하고 시작하는 코드로 대체한다. (디버그 모드에서는 struct
, 릴리즈 모드에서는 class
)AsyncTaskMethodBuilder
의 역할
AsyncTaskMethodBuilder
생성async
메서드의 호출자에게 반환될 Task
를 생성한다.await
키워드가 있는 코드에서, awaiter
에게 다음 작업(MoveNext
)을 후속 작업으로 등록한다.Task
의 상태를 변경한다.상태 머신의 실행 흐름 (MoveNext
)
async
메서드의 실제 로직은 MoveNext
메서드 안으로 옮겨진다.state
필드 값을 기준으로 실행 흐름을 분기한다.await
지점에서 작업이 완료되지 않았다면, state
를 변경하고 awaiter
를 통해 후속 작업(MoveNext
호출)을 등록한다.MoveNext
가 다시 호출되면, 중단되었던 지점부터 실행한다.await
키워드는 덕 타이핑(Duck Typing) 방식으로 동작한다. await
하려는 대상의 GetAwaiter()
메서드를 찾는다. 이 메서드가 반환하는 awaiter
객체는 다음 멤버를 포함한다.
bool IsCompleted
{ get; }void OnCompleted(Action continuation)
continuation
(상태 머신의 MoveNext
를 호출하는 래퍼 델리게이트)을 작업 완료 후 스케줄링하여 실행한다.TResult GetResult()
또한 awaiter
객체는 INotifyCompletion
인터페이스를 구현해야 한다 (OnCompleted
메서드).
미완료일 때의
task.GetAwaiter().GetResult()
와 task.Result, Task.Wait()를 사용하면 스레드가 블로킹 될 수 있으니 주의
이러한 Task
들은 어디서 어떻게 수행되는 걸까? TaskScheduler
에 의해 ThreadPool
의 스레드에서 실행되도록 스케줄링된다.
ThreadPool: 미리 생성된 스레드 집합을 유지하고 재사용하여 스레드 생성/제거 오버헤드를 줄인다. TPL은 CPU 코어 수와 현재 부하를 고려하여 스레드 수를 동적으로 조정한다. TaskContinuationOptions
열거형으로 간단히 후속 작업의 실행과 관련된 제어를 할 수 있다. 해당 풀의 스레드는 백그라운드 스레드로 애플리케이션 종료 시 같이 종료된다.
TaskScheduler: ThreadPool
보다 더 높은 수준의 추상화 계층으로, 어떤 작업을, 언제, 어디서 실행할지 결정한다.
ThreadPool
을 사용하며, 전역 큐(Global Queue)와 각 스레드가 가진 로컬 큐(Local Queue)를 사용한다.ExecutionContext
: 비동기 작업 간에 보안, 호출 컨텍스트 등 실행에 필요한 문맥 정보를 유지한다. 후속 작업이 실행될 때 이전에 캡처된 ExecutionContext
를 복원한다. 상태 머신의 필드로 관리되기도 한다.
SynchronizationContext
: 후속 작업을 특정 스레드에서 실행한다.
ConfigureAwait(false)
를 호출하면, 스레드 풀 스레드에서 후속 작업을 실행한다. 라이브러리 같이 실행 환경을 특정할 수 없는 코드를 작성할 때는 ConfigureAwait(false)
를 호출해야 한다.