비동기 프로그래밍
async/await 와 Task 로 기다리는 동안 다른 일을 합니다.
기다리는 동안 다른 일을 합니다
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초에 끝납니다.
스레드를 더 만드는 것이 아닙니다. 기다리는 동안 사용하지 않는 스레드를 반납하는 것이라, 비동기와 병렬은 다릅니다. 그 차이는 이 단원 끝에서 다시 다룹니다.
차례대로와 함께
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
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} 완료"; }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
두 묶음의 출력 순서를 비교해 보세요. 위쪽은 시작과 완료가 번갈아
나오고, 아래쪽은 셋이 먼저 다 시작한 뒤 완료가 몰려 나옵니다.
같은 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에서 이어집니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 5.0async · await
- 7.0ValueTask · async Main(7.1)
- 8.0비동기 스트림 — await foreach · await using
C# 5.0은 이 하나를 위해 나온 버전이라고 해도 될 만큼
async·await 말고는
추가된 것이 거의 없습니다. 그전에도 비동기로 적을 수는 있었지만 콜백을 겹겹이
쌓아야 했고, 그 코드는 try·catch 나
foreach 와 잘 어울리지 않았습니다.
await 는 같은 일을 평범한 순서대로 적게
해 준 것이고, C# 8.0의 await foreach 는
그것을 하나씩 흘러오는 자료에까지 넓힌 것입니다.
직접 해보기
아래 코드는 비동기로 적혀 있는데도 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; }
FetchAsync 가 2초 안에 끝나지 않으면
그만두게 만들어보세요. 그만두었을 때는 예외를 밖으로 내보내지 말고
기본값을 돌려줍니다. 3.7의
예외 처리와 함께 사용합니다.
- 비동기는 기다리는 동안 스레드를 놓아 주는 것입니다. 스레드를 더 만드는 것이 아닙니다.
- 일은 메서드를 부른 자리에서 시작하고,
await는 결과를 기다리는 자리입니다. - 서로 기댈 것이 없으면 먼저 다 시작해 두고
Task.WhenAll로 기다립니다. - 예외는
Task에 담겼다가await할 때 던져집니다.WhenAll은 하나만 던집니다. CancellationToken은 이어 넘겨야 동작합니다. 받기만 하면 아무 일도 하지 않습니다..Result·.Wait()·async void는 사용하지 않습니다. 마지막 것은 예외를 담을 곳이 없습니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.