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·catchforeach 와 잘 어울리지 않았습니다. 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. 시간 제한을 두고 부르기 난이도 중

FetchAsync2초 안에 끝나지 않으면 그만두게 만들어보세요. 그만두었을 때는 예외를 밖으로 내보내지 말고 기본값을 돌려줍니다. 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# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.