async 는 스레드를 만들지 않는다
앞에서 본 코드에 async 와 await 가 계속 나왔습니다. 송신 루프도, 수신 루프도 그렇습니다.
private async Task SendLoopAsync(CancellationToken ct) { ... }
그럼 세션이 천 개면 스레드가 천 개일까요.
아닙니다. 그리고 이 오해가 async 를 잘못 쓰게 만드는 원인의 대부분입니다.
async는 일을 나눠 하는 것이 아니라 기다리는 동안 자리를 비켜 주는 것입니다.
두 가지를 갈라야 한다
이름이 비슷해서 섞이는데 목적이 다릅니다.
| 무엇을 푸나 | 도구 | |
|---|---|---|
| 병행성 | CPU 를 여럿 쓰고 싶다 | Thread, Task.Run, Parallel |
| 비동기 | 기다리는 동안 놀고 싶지 않다 | async / await |
경쟁 상태, lock, false sharing 같은 문제는 전부 왼쪽입니다. 스레드가 여럿이라 같은 값을 동시에 건드리는 문제입니다.
오른쪽은 다릅니다. 스레드는 하나여도 됩니다. 파일을 읽거나 네트워크 응답을 기다리는 동안 CPU 는 아무것도 안 합니다. 그 시간을 남에게 넘기는 것이 목적입니다.
await 는 메서드를 쪼갠다
await 를 만나면 메서드가 거기서 끊깁니다.
컴파일러가 그 메서드를 상태 기계로 바꿉니다. await 마다 “여기까지 왔다” 는 표시를 남기고,
기다리던 일이 끝나면 그 자리부터 이어서 갑니다.
앞에서 본 yield return 과 정확히 같은 기계입니다.
그때 값을 하나 내놓고 멈췄다가 다음에 이어서 갔던 것처럼, await 는 기다림을 시작하고 멈췄다가 이어서 갑니다.
그래서 중요한 사실이 나옵니다. 기다리는 동안 스레드는 반납됩니다.
int sent = await Socket.SendAsync(...);
이 줄에서 스레드가 서 있는 게 아닙니다. 메서드가 멈추고 스레드는 다른 일 하러 갑니다. 네트워크가 응답하면 그때 아무 스레드나 잡아서 다음 줄을 이어 갑니다.
await 앞뒤가 같은 스레드라는 보장이 없습니다. 이건 나중에 문제가 되는 자리가 있어서 기억해 둘 만합니다.
그래서 세션 천 개가 스레드 천 개가 아니다
스레드는 만드는 것도 비싸고 바꿔 끼우는 것도 비쌉니다. 그리고 스택으로 메모리를 먹습니다. 대부분의 시간을 기다리는 데 쓰는 서버에서 세션마다 스레드를 두는 것은 자리만 차지하는 셈입니다.
async 로 짜면 기다리는 동안 스레드를 안 잡으니 스레드 몇 개로 세션 수천 개를 돌 수 있습니다.
그럼 언제 스레드가 늘어나나
async 를 붙였다고 늘지 않습니다. Task.Run 을 부를 때 늘어납니다.
await Task.Run(() => 무거운계산()); // 스레드풀에서 하나 빌린다
이건 “CPU 를 쓰는 일을 다른 스레드에서 하라” 는 뜻입니다. 병행성 쪽입니다.
그래서 기준이 하나 나옵니다.
- 기다리는 일(파일, 네트워크, DB) → 그냥
await.Task.Run으로 감싸지 않습니다 - CPU 를 쓰는 일(무거운 계산) →
Task.Run
기다리는 일을 Task.Run 으로 감싸면 스레드 하나를 빌려서 거기서 또 기다립니다. 손해만 봅니다.
async 를 안 붙여도 Task 를 돌려줄 수 있다
여기가 이 글에서 제일 실용적인 부분입니다.
Task 를 돌려주는 메서드가 전부 async 일 필요는 없습니다. 제 서버에 그런 자리가 있습니다.
public Task SendPacketAsync(Packet packet, CancellationToken ct = default)
{
if (!Connected) return Task.CompletedTask;
byte[] frame = /* 직렬화 */;
if (!_sendQueue.Writer.TryWrite(frame))
Disconnect();
return Task.CompletedTask;
}
이 메서드는 큐에 넣기만 합니다. 기다릴 일이 없습니다.
그래서 async 를 안 붙이고 이미 완료된 Task 를 돌려줍니다.
async 를 붙이면 상태 기계가 만들어집니다. 기다릴 것도 없는데 만들면 그만큼 낭비입니다.
이름은 그대로 뒀는데, 이유를 주석에 적어 뒀습니다.
/// 이름을 SendPacketAsync 로 유지한 이유: 호출부 ~20곳을 건드리지 않기 위해서다
/// (연결 계층 변경의 위험 표면을 줄인다). await 는 무해하다 — 완료된 Task 다.
완료된 Task 를 await 하면 아무 일도 안 일어납니다. 멈추지도 않고 스레드를 놓지도 않고 그냥 다음 줄로 갑니다.
그래서 부르는 쪽 스무 곳을 안 고쳐도 됐습니다.
await 가 항상 비싼 것이 아니라는 것도 여기서 보입니다. 기다릴 것이 있을 때만 멈춥니다.
하면 안 되는 것 두 가지
하나. async void.
async void 처리() // 쓰지 않습니다
{
await 뭔가();
}
Task 를 안 돌려주니 부르는 쪽이 끝났는지 알 수도 없고 예외를 받을 수도 없습니다.
안에서 터진 예외는 아무도 못 잡고 프로세스를 죽입니다.
예외는 이벤트 핸들러뿐입니다. 그쪽은 반환값을 못 받는 자리라 어쩔 수 없습니다. 그것도 안에서 전부 try/catch 해야 합니다.
둘. .Result 와 .Wait().
var 값 = 뭔가Async().Result; // 쓰지 않습니다
기다리는 것을 기다리는 셈이라 스레드를 잡아 둡니다. async 를 쓴 이유가 사라집니다.
그리고 UI 나 옛날 ASP.NET 처럼 돌아올 스레드가 정해져 있는 환경에서는 데드락이 납니다.
그 스레드가 .Result 에서 서 있는데, await 뒤를 이어 가려면 바로 그 스레드가 필요하기 때문입니다.
앞에서 본 데드락과 모양이 같습니다. 서로가 가진 것을 기다립니다.
제 서버 코드에는 이 둘이 한 번도 안 나옵니다. 찾아보면 코드 생성기가 만들어 내는 클라이언트 스텁 문자열에만 있습니다.
async 로 시작했으면 위까지 async 로 올라가는 것이 원칙이고, 중간에 끊으면 그 지점이 병목이나 데드락이 됩니다.
정리
- 병행성은 CPU 를 여럿 쓰는 것, 비동기는 기다리는 동안 안 노는 것입니다. 다른 문제입니다
await는 메서드를 상태 기계로 쪼갭니다.yield return과 같은 기계입니다- 기다리는 동안 스레드는 반납됩니다. 그래서 세션 천 개가 스레드 천 개가 아닙니다
await앞뒤가 같은 스레드라는 보장은 없습니다- 스레드는
async가 아니라Task.Run을 부를 때 늘어납니다. 기다리는 일은 감싸지 않습니다 Task를 돌려주는 메서드가 전부async일 필요는 없습니다. 완료된Task를await하는 것은 공짜입니다async void와.Result는 쓰지 않습니다. 예외를 잃거나 데드락이 납니다