
싱글 스레드에서 동시에 한다는 것
- Concurrency
- Parallelism
- Event Loop
- Yield
- Coroutine
축이 다르다
동기/비동기와 동시/병렬은 같은 축이 아니다. 하나는 호출자의 태도이고, 다른 하나는 실행의 물리적 배치다.
동기/비동기는 호출자의 태도다. 함수를 호출한 쪽이 결과를 받을 때까지 멈춰 있으면 동기, 결과에 관심을 등록하고 다른 일로 넘어가면 비동기다. 동시/병렬은 실행의 물리적 배치다. 두 작업이 시간상 겹쳐 진행되면 동시(concurrent), 별개의 코어에서 같은 순간에 실행되면 병렬(parallel)이다.
이 두 축은 직교한다. 조합하면 네 가지 경우가 나온다.
| 순차 (Sequential) | 동시 (Concurrent) | |
|---|---|---|
| 동기 (Sync) | 일반 함수 호출 | 멀티스레드 블로킹 I/O |
| 비동기 (Async) | 단일 콜백 체인 | 이벤트 루프, 코루틴 |
JavaScript의 이벤트 루프는 싱글 스레드 비동기다. Unity 코루틴도 마찬가지다. 반대로 동기이면서 병렬인 경우도 있다. 여러 스레드가 각각 블로킹 I/O를 동시에 기다리는 상황이 그렇다.
이벤트 루프의 구조
싱글 스레드에서 동시성을 만드는 구조는 단순하다. 큐에서 작업을 꺼내 실행하고, 끝나면 다음 작업을 꺼내는 루프다.
while (true)
{
var task = queue.Dequeue();
task.Execute();
// Execute가 끝나야 다음 task로 넘어간다
// 하나의 스레드 — 물리적으로 동시 실행은 불가능
}이 루프는 한 번에 하나의 작업만 처리한다. 병렬성은 없다. 그런데 작업이 스스로를 일시 정지하고 나중에 재개할 수 있다면 이야기가 달라진다. 루프가 여러 작업을 번갈아 실행할 수 있게 된다. 이 번갈아 실행이 동시성의 실체다. 물리적으로는 순차 실행이지만, 외부에서 보면 여러 작업이 동시에 진행되는 것처럼 보인다.
그러면 작업이 스스로를 일시 정지하는 방법이 필요하다.
yield의 등장
return은 "끝났다, 제어를 돌려준다"는 의미다. yield는 다르다. "아직 끝나지 않았지만, 제어를 잠시 돌려준다"는 의미다. 이 차이가 싱글 스레드 동시성의 전부다.
// yield 없이 — 블로킹
void DownloadAndProcess()
{
var data = Download(); // 3초 블로킹
Process(data);
}
// yield 있으면
IEnumerator DownloadAndProcess()
{
var request = StartDownload();
yield return request; // 제어를 루프에 돌려준다
// 루프는 그 사이에 다른 작업을 처리한다
Process(request.result); // 다운로드가 끝나면 여기서 재개
}첫 번째 코드에서 Download()가 3초 걸리면 그 3초 동안 루프 전체가 멈춘다. 다른 작업은 실행될 수 없다. 두 번째 코드에서는 yield return이 제어를 루프에 돌려준다. 루프는 그 3초 동안 다른 작업을 처리하다가, 다운로드가 완료되면 yield 다음 줄부터 재개한다.
시간축으로 보면 이렇다.
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를 하지 않는 작업이 있으면 다른 모든 작업이 멈춘다. 이벤트 루프에서 오래 걸리는 연산을 돌리면 UI가 얼어붙는 이유가 이것이다.
네 가지 조합
두 축을 조합한 네 가지 경우를 코드로 보면 구분이 명확해진다.
// 비동기 + 동시(비병렬) — 싱글 스레드 이벤트 루프
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 이전과 이후는 같은 함수 안에 있지만 같은 세상이 아닐 수 있다.
이 yield를 문법적으로 평탄화한 것이 async/await다. yield return을 직접 쓰는 대신 await 키워드가 같은 역할을 한다. 컴파일러가 상태 머신을 생성하고, 재개 지점을 관리하고, 콜백 연결을 처리한다. 코드 표면은 순차적으로 읽히지만 실행 구조는 yield 기반의 협력적 동시성과 동일하다.
다음 편에서는 그 평탄화의 실체, 즉 async 메서드가 어떤 상태 머신으로 변환되는지를 본다.