.LAB
RETURN 에서 AWAIT 까지·RETURN 에서 AWAIT 까지 #3

싱글 스레드에서 동시에 한다는 것

·5분 분량
    ConcurrencyParallelismEvent LoopYieldCoroutine

같은 축이 아니다

동기/비동기와 동시/병렬은 흔히 뒤섞이지만, 서로 다른 차원의 이야기다. 하나는 호출자의 태도에 관한 것이고, 다른 하나는 실행이 물리적으로 어떻게 배치되느냐에 관한 것이다.

동기/비동기는 호출자가 결과를 어떻게 기다리느냐의 문제다. 함수를 호출한 쪽이 결과를 받을 때까지 멈춰 있으면 동기, 결과에 관심을 등록해두고 다른 일로 넘어가면 비동기다. 동시/병렬은 실행의 물리적 배치다. 두 작업이 시간상 겹쳐 진행되면 동시(concurrent)이고, 별개의 코어에서 같은 순간에 실행되면 병렬(parallel)이다.

이 두 축은 직교한다. 조합하면 네 가지 경우가 나온다.

순차 (Sequential)동시 (Concurrent)
동기 (Sync)일반 함수 호출멀티스레드 블로킹 I/O
비동기 (Async)단일 콜백 체인이벤트 루프, 코루틴
비동기와 병렬은 다르다

JavaScript의 이벤트 루프는 싱글 스레드 비동기다. Unity 코루틴도 마찬가지다. 반대로 동기이면서 병렬인 경우도 있다. 여러 스레드가 각각 블로킹 I/O를 동시에 기다리는 상황이 그렇다.

이벤트 루프의 구조

싱글 스레드에서 동시성을 만드는 구조 자체는 단순하다. 큐에서 작업을 꺼내 실행하고, 끝나면 다음 작업을 꺼낸다. C로 쓰면 이런 형태다.

c
while (1) {
    task_t* task = queue_dequeue(&q);
    task->execute(task);
    // execute가 끝나야 다음 task로 넘어간다
    // 하나의 스레드 — 물리적으로 동시 실행은 불가
}

이 루프는 한 번에 하나의 작업만 처리한다. 병렬성은 없다. 그런데 작업이 스스로를 일시 정지하고 나중에 재개할 수 있다면 상황이 달라진다. 루프가 여러 작업을 번갈아 실행할 수 있게 되고, 이 번갈아 실행이 동시성의 실체다. 물리적으로는 순차 실행이지만 외부에서 보면 여러 작업이 동시에 진행되는 것처럼 보인다.

그러면 작업이 스스로를 일시 정지하는 방법이 필요하다.

yield의 등장

return과 yield의 차이

1편에서 다뤘듯 return은 "끝났다, 제어를 돌려준다"는 의미다. yield는 다르다. "아직 끝나지 않았지만, 지금은 제어를 돌려준다"는 뜻이다. 이 차이 하나가 싱글 스레드 동시성을 가능하게 한다.

C에는 yield 키워드가 없다. 그래서 이 동작을 직접 만들어야 했다. 상태를 구조체에 저장하고, 함수가 호출될 때마다 이전 상태를 보고 분기하는 방식이다.

c
typedef struct {
    int state;
    int result;
} coroutine_t;

int coroutine_resume(coroutine_t* co) {
    switch (co->state) {
    case 0:
        start_download();
        co->state = 1;
        return 0;  // 제어를 루프에 돌려준다 (yield)
    case 1:
        if (!is_download_complete()) return 0;  // 아직 안 끝남, 다시 yield
        co->result = get_download_result();
        co->state = 2;
        return 1;  // 완료
    }
    return 1;
}

coroutine_resume이 호출될 때마다 state를 확인해서 이전에 어디까지 진행했는지 파악한다. return 0이 yield에 해당하고, 루프는 이 반환을 받으면 다른 작업으로 넘어간다. C에서는 이런 구조를 직접 만들어야 했다. C#의 yield return이나 Python의 yield는 이 상태 머신을 언어가 대신 생성해준다.

C#에서는 같은 로직이 이렇게 줄어든다.

C#
IEnumerator DownloadAndProcess()
{
    var request = StartDownload();
    yield return request;    // 제어를 루프에 돌려준다
    // 루프는 그 사이에 다른 작업을 처리한다
    Process(request.result); // 다운로드가 끝나면 여기서 재개
}

컴파일러가 위의 C 코드와 유사한 상태 머신을 자동으로 만들어주기 때문에 프로그래머가 state 변수를 직접 관리할 필요가 없다. 시간축으로 보면 이렇다.

sequenceDiagram
    participant 루프
    participant 작업 A
    participant 작업 B
    루프->>작업 A: 실행
    Note over 작업 A: 시작
    작업 A-->>루프: yield (제어 반환)
    루프->>작업 B: 실행
    Note over 작업 B: 시작
    작업 B-->>루프: yield (제어 반환)
    루프->>작업 A: 재개
    Note over 작업 A: 완료
    루프->>작업 B: 재개
    Note over 작업 B: 완료
yield는 협력적 모델이다

각 작업이 자발적으로 제어를 양보한다. yield를 하지 않는 작업이 있으면 다른 모든 작업이 멈춘다. 이벤트 루프 위에서 오래 걸리는 연산을 돌리면 UI가 얼어붙는 이유가 이것이다.

네 가지 조합

두 축을 조합한 네 가지 경우를 C# 코드로 보면 구분이 선명해진다. C#은 두 축 모두를 언어 수준에서 지원하기 때문에 비교하기 좋다.

C#
// 비동기 + 동시(비병렬) — 싱글 스레드 이벤트 루프
await Task.WhenAll(FetchA(), FetchB());
// 동시에 진행되지만 물리적으로는 같은 스레드에서 번갈아 실행

// 동기 + 병렬 — 멀티 스레드 블로킹
Parallel.Invoke(
    () => File.ReadAllBytes("a.dat"),  // 스레드 1에서 블로킹
    () => File.ReadAllBytes("b.dat")   // 스레드 2에서 블로킹
);
// 각 스레드는 동기적으로 블로킹하지만 물리적으로 동시에 실행

"비동기로 바꾸면 빨라진다"는 말의 진위는 이 두 축의 어디에 위치하느냐에 달려 있다. I/O 바운드 작업을 싱글 스레드에서 비동기로 처리하면 대기 시간을 겹칠 수 있으니 빨라진다. 네트워크 요청 세 개를 순차로 보내면 3초씩 9초지만, Task.WhenAll로 동시에 보내면 대기 시간이 겹쳐 3초 정도에 끝난다.

반면 CPU 바운드 작업을 싱글 스레드에서 비동기로 감싸면 빨라지지 않는다. 연산 총량은 그대로인데 yield 오버헤드만 추가될 뿐이다. 행렬 곱셈 1000번을 비동기로 감싸도 싱글 스레드에서는 한 코어가 전부 계산해야 한다. 이런 경우에는 비동기가 아니라 병렬이 필요하다. Parallel.For로 여러 코어에 분산해야 실행 시간이 줄어든다.

yield에서 async/await로

yield를 쓸 때마다 제어를 돌려주고 다시 받는다. 그 사이에 다른 작업이 실행되었을 수 있고, 참조하던 객체가 사라졌을 수 있으며, 상태가 바뀌었을 수 있다. yield 이전과 이후는 같은 함수 안에 있지만 같은 세상이 아닐 수 있다.

async/await는 이 yield를 문법적으로 평탄화한 것이다. yield return을 직접 쓰는 대신 await 키워드가 같은 역할을 맡는다. 컴파일러가 상태 머신을 생성하고, 재개 지점을 관리하고, 콜백 연결을 처리한다. 코드 표면은 순차적으로 읽히지만, 실행 구조는 yield 기반의 협력적 동시성과 같다.

다음 편에서는 그 평탄화의 실체를 본다. async 메서드가 어떤 상태 머신으로 변환되는지, 그리고 실행 위치를 누가 결정하는지를 다룬다.