C#LAB
C# 4.1 · 4부. 고급 문법

비동기 프로그래밍

async/await 와 Task 로 기다리는 동안 다른 일을 합니다.

예상 학습 시간 20분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 고급
개념 설명

기다리는 동안 다른 일을 합니다

3.8에서 파일을 읽었습니다. 그 사이 프로그램은 아무것도 하지 않고 서 있었습니다. 파일이든 네트워크든 데이터베이스든, 기다리는 시간의 대부분은 계산이 아니라 바깥의 응답을 기다리는 것입니다.

// 셋을 읽는 데 각각 0.3초가 걸린다면
var a = Load("A");   // 0.3초 서 있습니다
var b = Load("B");   // 또 0.3초
var c = Load("C");   // 또 0.3초  → 모두 0.9초

비동기는 기다리는 동안 그 스레드를 놓아 주는 것입니다. 놓아 준 사이에 다른 일이 진행되고, 응답이 오면 하던 자리로 돌아옵니다. 위의 셋을 함께 시작해 두면 0.9초가 아니라 0.3초에 끝납니다.

스레드를 더 만드는 것이 아닙니다. 기다리는 동안 사용하지 않는 스레드를 반납하는 것이라, 비동기와 병렬은 다릅니다. 그 차이는 이 단원 끝에서 다시 다룹니다.

최소 예제 · 편집 가능

차례대로와 함께

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
Console.WriteLine("차례대로");

// await 를 바로 붙이면 하나가 끝나야 다음이 시작됩니다
foreach (var name in new[] { "A", "B", "C" })
    Console.WriteLine("  " + await Load(name));

Console.WriteLine();
Console.WriteLine("함께");

// 여기서 셋이 모두 시작됩니다. 아직 await 하지 않았기 때문입니다.
var tasks = new[] { Load("A"), Load("B"), Load("C") };

foreach (var done in await Task.WhenAll(tasks))
    Console.WriteLine("  " + done);

static async Task<string> Load(string name) {
    Console.WriteLine($"    {name} 시작");
    await Task.Delay(300);
    return $"{name} 완료";
}
출력
차례대로 A 시작 A 완료 B 시작 B 완료 C 시작 C 완료 함께 A 시작 B 시작 C 시작 A 완료 B 완료 C 완료

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

두 묶음의 출력 순서를 비교해 보세요. 위쪽은 시작과 완료가 번갈아 나오고, 아래쪽은 셋이 먼저 다 시작한 뒤 완료가 몰려 나옵니다. 같은 Load 를 세 번 부르는데 결과가 이렇게 갈립니다.

상세 사용법

async 와 await

async 는 이 메서드 안에서 await 를 사용하겠다는 표시입니다. await 는 끝날 때까지 이 자리를 비워 두고, 끝나면 돌아와 이어서 실행하라는 뜻입니다.

돌려주는 것언제
Task돌려줄 값이 없을 때. 동기 메서드의 void 자리입니다
Task<T>값을 돌려줄 때. await 하면 T 가 나옵니다
ValueTask<T>대개 곧바로 끝나서 Task 를 만드는 것이 아까울 때
void이벤트 핸들러 말고는 사용하지 않습니다(아래 참고)

비동기 메서드의 이름은 Async 로 끝내는 것이 관례입니다(ReadAllTextAsync·SaveAsync). 부르는 쪽에서 await 를 붙여야 한다는 것이 이름만 보고 드러납니다.

시작하는 시점과 기다리는 시점

이 단원에서 가장 자주 어긋나는 곳입니다. await 는 일을 시작시키지 않습니다. 일은 메서드를 부른 그 자리에서 이미 시작되고, await 는 결과를 기다리는 자리일 뿐입니다.

부르자마자 기다리면
var a = await Load("A");
var b = await Load("B");

// A 가 끝나야 B 가 시작합니다
// 0.3 + 0.3 = 0.6초
먼저 시작해 두면
var ta = Load("A");   // 시작
var tb = Load("B");   // 시작

var a = await ta;
var b = await tb;
// 0.3초

오른쪽에서 Load("A") 가 돌려주는 것은 값이 아니라 진행 중인 일(Task)입니다. 그것을 변수에 담아 두었다가 나중에 await 하면, 담아 두는 사이에 일이 진행됩니다.

여럿을 기다릴 때는 Task.WhenAll 이 읽기 좋습니다. 먼저 끝난 하나만 필요하면 Task.WhenAny 입니다.

// 모두 끝날 때까지. Task<T> 를 넘기면 결과 배열이 나옵니다.
var all = await Task.WhenAll(ta, tb, tc);

// 먼저 끝난 하나. 끝난 Task 가 나오므로 한 번 더 await 합니다.
var first = await Task.WhenAny(Slow("느림", 500), Slow("빠름", 100));
Console.WriteLine(await first);   // 빠름

예외는 await 하는 자리에서

비동기 메서드 안에서 발생한 예외는 그 자리에서 튀어나오지 않고 Task 안에 담깁니다. await 할 때 다시 던져지므로, 1.10처럼 감싸면 됩니다.

try { await Fail(); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }

WhenAll 은 조심할 점이 있습니다. 여럿이 실패해도 await 는 그중 하나만 던집니다. 전부 보려면 Task 를 변수에 담아 두고 Exception 을 들여다봅니다.

var t = Task.WhenAll(Fail(), Fail());

try { await t; }
catch {
    Console.WriteLine(t.Exception!.InnerExceptions.Count);   // 2
}

그만두게 하기

오래 걸리는 일에는 그만둘 방법을 함께 넘깁니다. CancellationToken 을 마지막 매개 변수로 받는 것이 관례입니다.

static async Task<string> Slow(string name, int ms, CancellationToken ct = default) {
    await Task.Delay(ms, ct);
    return name;
}

// 0.1초 뒤에 그만두라고 알립니다
using var cts = new CancellationTokenSource(100);

try { await Slow("오래", 5000, cts.Token); }
catch (OperationCanceledException) { Console.WriteLine("취소되었습니다"); }

토큰을 받기만 하고 넘기지 않으면 아무 일도 하지 않습니다. 안에서 부르는 비동기 메서드에 그대로 이어 넘겨야 취소가 전달됩니다.

하지 말아야 할 것

.Result 와 .Wait() 로 기다리지 마세요. 비동기로 만든 뜻을 없앨 뿐 아니라, 환경에 따라 결과가 다릅니다.

어디서무슨 일이
콘솔 앱막고 기다립니다. 되기는 하지만 그동안 스레드가 묶입니다
화면이 있는 앱서로 기다리다 멈춥니다. 돌아올 자리를 그 스레드가 쥐고 있습니다
브라우저(WASM)PlatformNotSupportedException

async void 도 사용하지 마세요. 돌려주는 Task 가 없으니 예외를 담을 곳이 없습니다. 감싸도 잡히지 않고 프로그램이 그대로 죽습니다.

static async void Bad() {
    await Task.Delay(10);
    throw new InvalidOperationException("async void 예외");
}

try { Bad(); await Task.Delay(200); }
catch (Exception) { Console.WriteLine("여기서 잡힐 것 같지만"); }

// Unhandled exception. System.InvalidOperationException: async void 예외
//    at Program.Bad()
//    ... 프로그램이 죽습니다

void 를 허용하는 자리는 이벤트 핸들러 하나뿐입니다(3.4). 그 밖에는 돌려줄 값이 없어도 Task 를 돌려주세요.

마지막으로, await 를 빠뜨리면 부르기만 하고 결과도 예외도 받지 못한 채 지나갑니다. 컴파일러가 알려 줍니다.

Save(order);
// warning CS4014: 이 호출이 대기되지 않으므로 호출이 완료되기 전에
// 현재 메서드가 계속 실행됩니다.

비동기와 병렬은 다릅니다

await 는 기다리는 동안 스레드를 놓아 주는 것이고, 병렬은 여러 스레드가 실제로 함께 계산하는 것입니다. 기다리는 것이 바깥의 응답이면 비동기가 맞고, 무거운 계산을 나누어 하려는 것이면 Task.Run 과 스레드 쪽입니다.

// 바깥을 기다리는 일 — 비동기가 맞습니다
await File.ReadAllTextAsync(path);

// 무거운 계산 — 이것은 병렬 쪽입니다
await Task.Run(() => 아주큰계산());

스레드가 하나뿐인 환경에서는 뒤엣것이 소용없습니다. 브라우저에서 Task.Run 을 불러 보면 메인과 같은 스레드에서 실행됩니다. 비동기는 그대로 동작하지만 병렬은 그렇지 않은 것이, 둘이 다른 것이라는 증거이기도 합니다. 스레드 이야기는 4.2에서 이어집니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
3.0
5.0
6.0
7.0
8.0
9.0
11.0
14.0
  • 5.0async · await
  • 7.0ValueTask · async Main(7.1)
  • 8.0비동기 스트림 — await foreach · await using
C# 5.0+

C# 5.0은 이 하나를 위해 나온 버전이라고 해도 될 만큼 async·await 말고는 추가된 것이 거의 없습니다. 그전에도 비동기로 적을 수는 있었지만 콜백을 겹겹이 쌓아야 했고, 그 코드는 try·catch 나 foreach 와 잘 어울리지 않았습니다. await 는 같은 일을 평범한 순서대로 적게 해 준 것이고, C# 8.0의 await foreach 는 그것을 하나씩 흘러오는 자료에까지 넓힌 것입니다.

실습 문제

직접 해보기

1. 차례대로 도는 코드 고치기 난이도 하

아래 코드는 비동기로 적혀 있는데도 0.9초가 걸립니다. 까닭을 설명하고 0.3초에 끝나도록 고쳐보세요.

static async Task<int> TotalAsync() {
    var a = await LoadAsync("A");
    var b = await LoadAsync("B");
    var c = await LoadAsync("C");

    return a + b + c;
}
일은 부르는 자리에서 시작합니다. await 를 바로 붙이면 그 자리에서 결과를 기다리므로 다음 줄이 실행되지 않습니다.
// 셋이 서로를 기다릴 까닭이 없는데도 await 를 바로 붙여 // A 가 끝나야 B 가 시작되고 있습니다. static async Task<int> TotalAsync() { var ta = LoadAsync("A"); // 여기서 셋이 모두 시작됩니다 var tb = LoadAsync("B"); var tc = LoadAsync("C"); var values = await Task.WhenAll(ta, tb, tc); return values.Sum(); } // 셋을 배열로 만들어 넘겨도 같습니다 static async Task<int> TotalAsync2() { Task<int>[] tasks = [LoadAsync("A"), LoadAsync("B"), LoadAsync("C")]; return (await Task.WhenAll(tasks)).Sum(); } // 앞의 결과가 뒤에 필요하다면 차례대로 두는 것이 맞습니다. // 고칠 수 있는 것은 서로 기댈 것이 없을 때뿐입니다.
2. 시간 제한을 두고 부르기 난이도 중

FetchAsync 가 2초 안에 끝나지 않으면 그만두게 만들어보세요. 그만두었을 때는 예외를 밖으로 내보내지 말고 기본값을 돌려줍니다. 3.7의 예외 처리와 함께 사용합니다.

CancellationTokenSource 는 생성자에 시간을 넣으면 그 시간이 지날 때 저절로 알립니다. 받은 토큰은 안쪽 비동기 메서드에 그대로 이어 넘겨야 합니다.
static async Task<string> FetchOrDefaultAsync() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); try { return await FetchAsync(cts.Token); } catch (OperationCanceledException) { return "(시간 안에 받지 못했습니다)"; } } static async Task<string> FetchAsync(CancellationToken ct) { await Task.Delay(TimeSpan.FromSeconds(5), ct); // 토큰을 이어 넘깁니다 return "값"; } // 2초가 지나면 Task.Delay 가 OperationCanceledException 을 던지고, // 그것을 여기서 받아 기본값을 돌려줍니다. // ct 를 Task.Delay 에 넘기지 않으면 2초 뒤에도 그대로 5초를 기다립니다. // 토큰은 받는 것이 아니라 이어 넘기는 것이 일입니다.
요약
  • 비동기는 기다리는 동안 스레드를 놓아 주는 것입니다. 스레드를 더 만드는 것이 아닙니다.
  • 일은 메서드를 부른 자리에서 시작하고, await 는 결과를 기다리는 자리입니다.
  • 서로 기댈 것이 없으면 먼저 다 시작해 두고 Task.WhenAll 로 기다립니다.
  • 예외는 Task 에 담겼다가 await 할 때 던져집니다. WhenAll 은 하나만 던집니다.
  • CancellationToken 은 이어 넘겨야 동작합니다. 받기만 하면 아무 일도 하지 않습니다.
  • .Result·.Wait()·async void 는 사용하지 않습니다. 마지막 것은 예외를 담을 곳이 없습니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.