---
title: "async 는 스레드를 만들지 않는다"
description: "스레드가 여럿일 때 생기는 문제와 async 는 다른 이야기입니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/concurrency"
tags: ["C#", "동시성"]
canonical: https://blog.unialgames.com/posts/csharp-async-not-thread/
---
앞에서 본 코드에 `async` 와 `await` 가 계속 나왔습니다. 송신 루프도, 수신 루프도 그렇습니다.

```csharp
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` 는 기다림을 시작하고 멈췄다가 이어서 갑니다.

그래서 중요한 사실이 나옵니다. **기다리는 동안 스레드는 반납됩니다.**

```csharp
int sent = await Socket.SendAsync(...);
```

이 줄에서 스레드가 서 있는 게 아닙니다. 메서드가 멈추고 스레드는 다른 일 하러 갑니다.
네트워크가 응답하면 그때 아무 스레드나 잡아서 다음 줄을 이어 갑니다.

**`await` 앞뒤가 같은 스레드라는 보장이 없습니다.** 이건 나중에 문제가 되는 자리가 있어서 기억해 둘 만합니다.

## 그래서 세션 천 개가 스레드 천 개가 아니다

<x-compare>
<script type="application/json">
{
  "tabs": [
    {
      "label": "스레드로 하면",
      "code": "// 세션마다 스레드 하나\nforeach (var session in sessions)\n{\n    new Thread(() => {\n        while (true)\n            session.수신();   // 여기서 스레드가 서 있다\n    }).Start();\n}",
      "stats": [["세션 1000개", "스레드 1000개"], ["스레드 하나당 스택", "약 1MB"], ["대기 중에도", "메모리를 잡고 있음"]],
      "tone": "bad"
    },
    {
      "label": "async 로 하면",
      "code": "// 세션마다 Task 하나\nforeach (var session in sessions)\n{\n    _ = session.RunAsync(ct);   // 대기 중에는 스레드를 안 잡는다\n}",
      "stats": [["세션 1000개", "대기 중인 상태 기계 1000개"], ["실제 스레드", "코어 수 정도"], ["대기 중에는", "작은 객체 하나뿐"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

스레드는 만드는 것도 비싸고 바꿔 끼우는 것도 비쌉니다. 그리고 스택으로 메모리를 먹습니다.
대부분의 시간을 **기다리는 데** 쓰는 서버에서 세션마다 스레드를 두는 것은 자리만 차지하는 셈입니다.

`async` 로 짜면 기다리는 동안 스레드를 안 잡으니 스레드 몇 개로 세션 수천 개를 돌 수 있습니다.

## 그럼 언제 스레드가 늘어나나

`async` 를 붙였다고 늘지 않습니다. **`Task.Run` 을 부를 때** 늘어납니다.

```csharp
await Task.Run(() => 무거운계산());   // 스레드풀에서 하나 빌린다
```

이건 "CPU 를 쓰는 일을 다른 스레드에서 하라" 는 뜻입니다. 병행성 쪽입니다.

그래서 기준이 하나 나옵니다.

- **기다리는 일**(파일, 네트워크, DB) → 그냥 `await`. `Task.Run` 으로 감싸지 않습니다
- **CPU 를 쓰는 일**(무거운 계산) → `Task.Run`

기다리는 일을 `Task.Run` 으로 감싸면 스레드 하나를 빌려서 거기서 또 기다립니다. **손해만 봅니다.**

## async 를 안 붙여도 Task 를 돌려줄 수 있다

여기가 이 글에서 제일 실용적인 부분입니다.

`Task` 를 돌려주는 메서드가 전부 `async` 일 필요는 없습니다. 제 서버에 그런 자리가 있습니다.

```csharp
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` 를 붙이면 상태 기계가 만들어집니다. 기다릴 것도 없는데 만들면 그만큼 낭비입니다.

이름은 그대로 뒀는데, 이유를 주석에 적어 뒀습니다.

```csharp
/// 이름을 SendPacketAsync 로 유지한 이유: 호출부 ~20곳을 건드리지 않기 위해서다
/// (연결 계층 변경의 위험 표면을 줄인다). await 는 무해하다 — 완료된 Task 다.
```

**완료된 `Task` 를 `await` 하면 아무 일도 안 일어납니다.** 멈추지도 않고 스레드를 놓지도 않고 그냥 다음 줄로 갑니다.
그래서 부르는 쪽 스무 곳을 안 고쳐도 됐습니다.

`await` 가 항상 비싼 것이 아니라는 것도 여기서 보입니다. **기다릴 것이 있을 때만** 멈춥니다.

## 하면 안 되는 것 두 가지

**하나. `async void`.**

```csharp
async void 처리()   // 쓰지 않습니다
{
    await 뭔가();
}
```

`Task` 를 안 돌려주니 부르는 쪽이 **끝났는지 알 수도 없고 예외를 받을 수도 없습니다.**
안에서 터진 예외는 아무도 못 잡고 프로세스를 죽입니다.

예외는 이벤트 핸들러뿐입니다. 그쪽은 반환값을 못 받는 자리라 어쩔 수 없습니다. 그것도 안에서 전부 `try/catch` 해야 합니다.

**둘. `.Result` 와 `.Wait()`.**

```csharp
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` 는 쓰지 않습니다. 예외를 잃거나 데드락이 납니다