async 는 스레드를 만들지 않는다
Esc

    async 는 스레드를 만들지 않는다

    –C#동시성

    앞에서 본 코드에 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 는 쓰지 않습니다. 예외를 잃거나 데드락이 납니다