2024. 12. 4. 21:22ㆍSTUDY/C#
이글은 서적 '이것이 C#이다'를 기반하여 각종 블로그에서 추가적인 정보를 토대로 작성된 글입니다.
같이 보면 좋은글
[async와 await의 개념]
C#의 async와 await 키워드는 비동기 프로그래밍을 쉽게 구현할 수 있도록 도와주는 핵심적인 도구이다. 이를 통해 코드가 실행되는 동안 작업을블록하지 않으면서도 동기식 코드처럼 간결하고 이해하기 쉬운 형태로 작성할 수 있다.
async
- 메서드가 비동기적으로 실행된다는 것을 나타낸다.
- async를 붙인 메서드는 반드시 반환값으로 Task, Task<TResult>, 또는 void를 사용해야 한다.
- void를 사용하게 되면 비동기 메서드를 호출하는 쪽에서 비동기 제어할 수 없다. 종종 이벤트 핸들러로 사용할 때 void를 사용하곤 하는데 UI버튼을 클릭하면 일어나는 작업들을 비동기로 처리할때 void를 사용하는 것이 대표적인 예시다.
await 키워드
- 비동기 작업의 흐름을 제어하는 키워드다.
- 코드실행을 블록(block)하지 않고 다음 작업으로 제어를 넘긴다.
- await는반드시 async 메서드 안에서만 사용할 수 있다.

위 그림은 async 한정자와 await 연산자가 어떻게 비동기 코드를 형성하는지에 대한 이해를 돕기 위한 그림이다.
Caller()의 실행이 시작되면 (1)의 흐름을 따라 문장1이 실행되고 이어서 (2)를 따라 MyMethodAsync() 메서드의 실행으로 제어가 이동된다. MyMethodAsync()에서는 (3)을 따라 문장2가 실행되면 async 람다문을 피연산자로 하는 await 연산자를 만난다. 바로 여기서 CLR은 (4)를 따라 제어를 호출자인 Caller()에게로 이동시키고 위 그림에서 점선으로 표시된 (a)와 (b)의 흐름을 동시에 실행한다.
[void를 반환하는 async 메서드]

위 코드를 보면 우리가 예상할 수 있는 결과화면은 'End Main' 이 출력되고 'End MyAsyncFunc'가 출력될 것이라고 예상했을 것이다. 하지만 실제로는 'End MyAsyncFunc' 가 출력되지 않은 것을 확인할 수 있다. void를 반환하는 비동기 메서드를 대기할 수 없기 때문에 Task를 대신 반환해야 한다. 해당메서드의 호출자는 호출된 비동기 메서드가 미치는 것을 기다리지 않고 완료될 때까지 계속 진행되어야 한다.
async void를 피해야 하는 이유
- 완료를 추적할 수 없음 : 호출자가 비동기 작업의 완료를 확인하거나 대기할 수 없다.
- 예외 처리 불가능 : async void 메서드에서 발생한 예외는 호출자에게 전달되지 않으며, 프로그램이 예상치 못한 방식으로 종료될 수 있다.
async void는 UI버튼을 클릭하면 일어나는 작업들을 비동기로 처리하는 이벤트 핸들러에서 사용하거나 file-and-forget 작업을 담고 있는 메서드에서 사용한다. 일반적인 비동기 프로그래밍에서는 작업 상태를 추적할 수 있는 Task를 반환하는 async 메서드를 권장한다.

[ValueTask<TResult>]
ValueTask는 이름에서 의미하는 바와 같이 '값형식'이다. 반면 일반적인 비동기 과정을 거치기 위해 반환하는 Task의 경우 참조 형식이다. 굳이 비동기 과정을 거치지 않는다면 GC Heap을 어지럽힐 이유가 없으므로 그런 경우를 위해 ValueTask 를 반환한다.
MS Docs에서 예제 코드를 하나 가져왔다.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
public static class DownloadCache
{
private static readonly ConcurrentDictionary<string, string> s_cachedDownloads = new();
private static readonly HttpClient s_httpClient = new();
public static Task<string> DownloadStringAsync(string address)
{
if (s_cachedDownloads.TryGetValue(address, out string? content))
{
return Task.FromResult(content);
}
return Task.Run(async () =>
{
content = await s_httpClient.GetStringAsync(address);
s_cachedDownloads.TryAdd(address, content);
return content;
});
}
public static async Task Main()
{
string[] urls = new[]
{
"https://learn.microsoft.com/aspnet/core",
"https://learn.microsoft.com/dotnet",
"https://learn.microsoft.com/dotnet/architecture/dapr-for-net-developers",
"https://learn.microsoft.com/dotnet/azure",
"https://learn.microsoft.com/dotnet/desktop/wpf",
"https://learn.microsoft.com/dotnet/devops/create-dotnet-github-action",
"https://learn.microsoft.com/dotnet/machine-learning",
"https://learn.microsoft.com/xamarin",
"https://dotnet.microsoft.com/",
"https://www.microsoft.com"
};
Stopwatch stopwatch = Stopwatch.StartNew();
IEnumerable<Task<string>> downloads = urls.Select(DownloadStringAsync);
static void StopAndLogElapsedTime(
int attemptNumber, Stopwatch stopwatch, Task<string[]> downloadTasks)
{
stopwatch.Stop();
int charCount = downloadTasks.Result.Sum(result => result.Length);
long elapsedMs = stopwatch.ElapsedMilliseconds;
Console.WriteLine(
$"Attempt number: {attemptNumber}\n" +
$"Retrieved characters: {charCount:#,0}\n" +
$"Elapsed retrieval time: {elapsedMs:#,0} milliseconds.\n");
}
await Task.WhenAll(downloads).ContinueWith(
downloadTasks => StopAndLogElapsedTime(1, stopwatch, downloadTasks));
// Perform the same operation a second time. The time required
// should be shorter because the results are held in the cache.
stopwatch.Restart();
downloads = urls.Select(DownloadStringAsync);
await Task.WhenAll(downloads).ContinueWith(
downloadTasks => StopAndLogElapsedTime(2, stopwatch, downloadTasks));
}
// Sample output:
// Attempt number: 1
// Retrieved characters: 754,585
// Elapsed retrieval time: 2,857 milliseconds.
// Attempt number: 2
// Retrieved characters: 754,585
// Elapsed retrieval time: 1 milliseconds.
}
위 코드를 보면 cached된 결과값을 반환하는 경우에도 Task.FromResult를이용해 Task 타입을 반환해야 한다.
여기서 문제는 Task가 바로 참조 타입이라는 점이다.
cache를 구현하기 전의 DownloadStringAsync 메서드라면 비동기 동작을 수행하므로 Task 인스턴스를 반환하는 것이 당연할 수 있지만, 비동기 동작 없이 곧바로 동기 반환을 할 수 있는 경우에도 Task.FromResult를 이용해 굳이 Task를 생성한 다음 반환하는 것은 GC Heap에 필요없는 할당이 발생하게 되는 것이다.
바로 이럴 때 값 형식의 ValueTask를 사용하면 위의 문제를 수정할 수 있다.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
public static class DownloadCache
{
private static readonly ConcurrentDictionary<string, string> s_cachedDownloads = new();
private static readonly HttpClient s_httpClient = new();
public static ValueTask<string> DownloadStringAsync(string address)
{
// 캐시에 값이 존재하는 경우
if (s_cachedDownloads.TryGetValue(address, out string? content))
{
return new ValueTask<string>(content); // 이미 결과가 존재하므로 ValueTask로 반환
}
var task = Task.Run(async () =>
{
content = await s_httpClient.GetStringAsync(address);
s_cachedDownloads.TryAdd(address, content);
return content;
});
// 새로 다운로드해야 하는 경우
return new ValueTask<string>(task);
}
private static async Task<string> DownloadAndCacheStringAsync(string address)
{
string content = await s_httpClient.GetStringAsync(address);
s_cachedDownloads.TryAdd(address, content);
return content;
}
public static async Task Main()
{
string[] urls = new[]
{
"https://learn.microsoft.com/aspnet/core",
"https://learn.microsoft.com/dotnet",
"https://learn.microsoft.com/dotnet/architecture/dapr-for-net-developers",
"https://learn.microsoft.com/dotnet/azure",
"https://learn.microsoft.com/dotnet/desktop/wpf",
"https://learn.microsoft.com/dotnet/devops/create-dotnet-github-action",
"https://learn.microsoft.com/dotnet/machine-learning",
"https://learn.microsoft.com/xamarin",
"https://dotnet.microsoft.com/",
"https://www.microsoft.com"
};
Stopwatch stopwatch = Stopwatch.StartNew();
IEnumerable<Task<string>> downloads = urls.Select(url => DownloadStringAsync(url).AsTask());
static void StopAndLogElapsedTime(
int attemptNumber, Stopwatch stopwatch, Task<string[]> downloadTasks)
{
stopwatch.Stop();
int charCount = downloadTasks.Result.Sum(result => result.Length);
long elapsedMs = stopwatch.ElapsedMilliseconds;
Console.WriteLine(
$"Attempt number: {attemptNumber}\n" +
$"Retrieved characters: {charCount:#,0}\n" +
$"Elapsed retrieval time: {elapsedMs:#,0} milliseconds.\n");
}
await Task.WhenAll(downloads).ContinueWith(
downloadTasks => StopAndLogElapsedTime(1, stopwatch, downloadTasks));
// Perform the same operation a second time. The time required
// should be shorter because the results are held in the cache.
stopwatch.Restart();
downloads = urls.Select(url => DownloadStringAsync(url).AsTask());
await Task.WhenAll(downloads).ContinueWith(
downloadTasks => StopAndLogElapsedTime(2, stopwatch, downloadTasks));
}
}
수정 포인트
- ValueTask로 캐시된 값 반환
- AsTask 사용
결과를 곧바로 반환, 즉 동기적으로 동작할 때는 GC Heap을 사용하지 않으므로 성능이 개선되는 반면, 기존엔 Task를 ValueTask로 변환하는 과정, Task.WhenAll() 메서드를 호출하기 위해 AsTask() 호출 등 추가적인 오버헤드가 발생한다.
ValueTask 사용을 피해야 하는 경우
1. 작업이 항상 비동기적으로 완료되는 경우
ValueTask는 주로 작업이 동기적으로 완료될 가능성이있는 경우에 적합하다. ValueTask가 내부적으로 동기적 완료와 비동기적 작업 두 가지를 모두 처리하기 위해 내부적으로 동기적 결과가 이미 존재하는지 확인등 추가적인 매커니즘을 가지고 있다. 그렇기 때문에 작업이 항상 비동기적이라면 ValueTask는 추가 상태를 관리하므로 항상 비동기적인 경우 메모리와 cpu 부담이 증가할 수 있다.
2. 여러번 await가 불리는 경우
ValueTask는 한번만 await 될 것을 기대한다. 여러번 await되거나 사용되면 비정상적인 동작이나 성능 문제가 발생할 수 있다. 왜냐하면 한번 await 된 후 내부 상태가 변경된 다음, 다음 await에서 잘못된 결과가 반환될 가능성이 있다.
위와 같은 이유로 해당 문서에서는 일반적인 상황에서는 Task 혹은 Task<TResult>를 사용하는것이 더 나은 선택이 될 것이고 확실한 성능 향상이 있다고 생각하는 경우에서 ValueTask<TResult> 를 사용하라고 되어 있다.
todo : 비동기 프로그래밍 패턴 (TAP, EAP, APM), unitask
- https://learn.microsoft.com/ko-kr/dotnet/csharp/asynchronous-programming/
- https://learn.microsoft.com/ko-kr/dotnet/csharp/asynchronous-programming/async-scenarios
- https://learn.microsoft.com/ko-kr/dotnet/api/system.threading.tasks.valuetask-1?view=net-9.0
- https://kangworld.tistory.com/25
- https://m.blog.naver.com/oidoman/222051140706
- https://www.sysnet.pe.kr/2/0/13114
- https://blog.stephencleary.com/2020/03/valuetask.html
'STUDY > C#' 카테고리의 다른 글
공용 언어 런타임(CLR)의 실행 모델 (1) | 2025.02.02 |
---|---|
[C#] 동기 비동기 개념 이해하기 (0) | 2024.11.28 |
[C#] Array vs List vs ArrayList (0) | 2024.11.12 |
[C#] Class vs Struct (0) | 2024.11.06 |
interface는 인스턴스는 못 만들지만 참조는 만들 수 있다? (1) | 2024.10.03 |