누군가 만나면 들려주고 싶은 C# 비동기 이야기

Oak_Cassia·2025년 7월 27일
0

동시성 (Concurrency)

비동기 프로그래밍을 이해하기에 앞서, 동시성(Concurrency) 개념을 명확히 해야 한다. 동시성은 단순히 여러 작업을 번갈아 처리하는 시분할(Time-sharing)과 동일한 개념이 아니며, 여러 작업을 동시에 처리하는 능력을 의미하는 추상적인 정책에 가깝다.

초기 싱글 코어 컴퓨터 시절에는 시분할 방식인 컨텍스트 스위칭(Context Switching)이 동시성을 구현하는 주요 방식이었기에 두 개념이 혼용되었다. 하지만 현대의 동시성은 다중 코어를 활용한 병렬 처리(Parallelism)나 시분할을 모두 포함하는 더 넓은 개념이다.

  • 멀티 스레딩: 다수의 스레드를 만들어 동시 실행
  • 병렬 처리: 여러 작업을 다수의 코어(또는 스레드)에 분배하여 동시 실행

동시성을 코드 레벨에서 구현하는 방식 중 하나가 비동기 프로그래밍이다. 특정 작업의 완료를 기다리며 프로그램의 주 흐름이 멈추지 않고, 해당 작업을 독립적으로 처리하는 방식이다. 이를 통해 서버는 한 유저의 요청을 처리하는 동안 다른 유저의 요청에도 응답할 수 있어 효율을 극대화한다.


Continuation

비동기 프로그래밍은 코드 흐름이 순차적이지 않아 특정 작업이 끝난 뒤 '무엇을 할 것인가'를 명시해야 한다. 이를 후속 작업(Continuation)이라고 한다. 과거에는 다음 패턴으로 후속 작업을 표현했다.

  • 콜백(Callback) 패턴: 코드가 깊어지며 복잡해지는 단점
  • APM(Asynchronous Programming Model): 작업이 동기적으로 완료될 경우 스택 오버플로우 위험성, IAsyncResult.CompletedSynchronously 속성으로 해결할 수 있지만, 동일한 로직이 호출부와 콜백 내부에 분산되어 유지보수성 하락
  • EAP(Event-based Asynchronous Pattern): 메서드가 대부분 void를 반환하여 개별 작업 추적이 어렵고, EventArgs를 통해 결과 확인. SynchronizationContext를 도입하여 후속 작업이 특정 스레드(예: UI 스레드)에서 실행가능 하게 한 발전

기존 모델의 문제점을 해결하기 위해 TPL(Task Parallel Library)Task가 등장했다.


Task

과거에는 Thread를 직접 사용해 멀티스레딩을 구현했다. 하지만 각 스레드는 OS 스레드에 직접 매핑되어 생성과 소멸 비용이 컸고, Join 메서드는 스레드를 블로킹하여 비효율적이었다.

Task는 비동기 작업을 나타내는 고수준으로 추상화된 객체다. Task는 특정 스레드를 의미하는 것이 아니라, 미래에 완료될 작업 자체를 의미한다. 작업의 상태, 결과, 예외를 캡슐화하여 호출자가 작업의 상태를 안전하고 간단하게 처리할 수 있다.

  • Task.Run: CPU 바운드 작업을 스레드 풀에서 비동기적으로 실행할 때 쓴다.Task.Factory.StartNew에 비해 파라미터가 단순화되었다.
  • async/await: I/O 바운드 작업에 사용됩니다. (자세한 내용은 아래에서 설명)
  • Task.ContinueWith: 후속 작업을 연결할 때 사용한다. 선행 Task가 후속 작업 델리게이트에 인자로 전달되며, 여러 ContinueWith를 호출하여 작업을 체이닝할 수 있다.

async/await

async/await비동기 코드를 동기 코드처럼 보이게 만들어 가독성과 유지보수성이 높다. async 메서드가 반환한 Taskawait 키워드를 사용하면, 현재 코드의 진행 흐름은 잠시 멈추고 스레드 풀(ThreadPool)에서 다른 작업을 가져와 실행한다. 이 간단한 처리 이면에 다음 과정이 숨어있다.

  1. 상태 머신(State Machine) 생성

    • 컴파일러는 async 한정자가 붙은 메서드를 상태 머신 객체를 생성하고 시작하는 코드로 대체한다. (디버그 모드에서는 struct, 릴리즈 모드에서는 class)
  2. AsyncTaskMethodBuilder의 역할

    • 상태 머신 내부에 AsyncTaskMethodBuilder 생성
    • 이 빌더는 async 메서드의 호출자에게 반환될 Task를 생성한다.
    • await 키워드가 있는 코드에서, awaiter에게 다음 작업(MoveNext)을 후속 작업으로 등록한다.
    • 상태 머신이 완료되거나 예외가 발생하면 Task의 상태를 변경한다.
  3. 상태 머신의 실행 흐름 (MoveNext)

    • 대체된 코드에서 Start 함수를 통해 ExecutionContext를 캡처/복원하고 MoveNext를 호출한다.
    • async 메서드의 실제 로직은 MoveNext 메서드 안으로 옮겨진다.
    • state 필드 값을 기준으로 실행 흐름을 분기한다.
    • await 지점에서 작업이 완료되지 않았다면, state를 변경하고 awaiter를 통해 후속 작업(MoveNext 호출)을 등록한다.
    • 작업이 완료되어 MoveNext가 다시 호출되면, 중단되었던 지점부터 실행한다.

Awaiter 패턴

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()를 사용하면 스레드가 블로킹 될 수 있으니 주의


ThreadPool과 TaskScheduler

이러한 Task들은 어디서 어떻게 수행되는 걸까? TaskScheduler에 의해 ThreadPool의 스레드에서 실행되도록 스케줄링된다.

  • ThreadPool: 미리 생성된 스레드 집합을 유지하고 재사용하여 스레드 생성/제거 오버헤드를 줄인다. TPL은 CPU 코어 수와 현재 부하를 고려하여 스레드 수를 동적으로 조정한다. TaskContinuationOptions 열거형으로 간단히 후속 작업의 실행과 관련된 제어를 할 수 있다. 해당 풀의 스레드는 백그라운드 스레드로 애플리케이션 종료 시 같이 종료된다.

    • Worker Threads: CPU 바운드 작업을 처리한다.
    • IOCP (I/O Completion Port) Threads: I/O 작업의 완료를 처리합니다. 두 스레드 유형을 분리하여 I/O 대기가 CPU 연산을 방해하는 것을 막는다. (정확한 동작은 아직 모르겠다. 네이티브 완료 신호를 수신한 뒤 Worker Thread가 실행하도록 하는 건지...)
  • TaskScheduler: ThreadPool보다 더 높은 수준의 추상화 계층으로, 어떤 작업을, 언제, 어디서 실행할지 결정한다.

    • 기본적으로 ThreadPool을 사용하며, 전역 큐(Global Queue)와 각 스레드가 가진 로컬 큐(Local Queue)를 사용한다.
    • Work-Stealing: 자신의 로컬 큐가 비면, 전역 큐를 확인한다. 그래도 작업이 없으면 다른 스레드의 로컬 큐에서 가져온다.
      • 자신의 로컬 큐 접근: LIFO (후입선출) -> 캐시 지역성(Cache Locality) 극대화
        • 자식 작업은 부모 작업이 사용 중이던, 캐시에 올라가 있는 데이터를 사용할 확률이, 다른 작업보다 높다.
      • 다른 스레드의 로컬 큐 접근: FIFO (선입선출) -> 경합 감소

Execution Context와 Synchronization Context

  • ExecutionContext: 비동기 작업 간에 보안, 호출 컨텍스트 등 실행에 필요한 문맥 정보를 유지한다. 후속 작업이 실행될 때 이전에 캡처된 ExecutionContext를 복원한다. 상태 머신의 필드로 관리되기도 한다.

  • SynchronizationContext: 후속 작업을 특정 스레드에서 실행한다.

    • UI, HTTP 요청 스레드로 설정될 수 있다.
    • 일반적인 콘솔 앱 등에서는 스레드 풀 스레드이다.
    • ConfigureAwait(false)를 호출하면, 스레드 풀 스레드에서 후속 작업을 실행한다. 라이브러리 같이 실행 환경을 특정할 수 없는 코드를 작성할 때는 ConfigureAwait(false)를 호출해야 한다.
profile
https://velog.io/@oak_cassia/A-Game-Developers-Vision

0개의 댓글