Mở đầu — Tại sao bạn nên đọc bài này
Một ngày đẹp trời, API của bạn bắt đầu trả response chậm dần, rồi timeout hàng loạt. Monitoring cho thấy CPU chỉ 5%, RAM bình thường, nhưng request queue cứ dài ra. Nguyên nhân? Thread pool starvation — vì ai đó gọi .Result trong một endpoint async, block hết thread, và CLR không kịp tạo thread mới.
Hoặc tệ hơn: app crash không log, không trace, không ai hiểu vì sao — cho đến khi phát hiện một async void method ném exception mà không ai catch được.
Đây không phải lý thuyết. Đây là những bug thực tế mà rất nhiều team C# từng gặp. Bài viết này tập trung vào những gì bạn thực sự cần biết để tránh chúng.
1. async/await hoạt động thế nào bên trong
Khi bạn viết:
public async Task<string> GetDataAsync()
{
var response = await httpClient.GetAsync("https://api.example.com/data");
var content = await response.Content.ReadAsStringAsync();
return content;
}
Compiler không chạy code này “như bạn thấy”. Nó biến đổi method thành một state machine — một struct implement IAsyncStateMachine với các state tương ứng mỗi await.
Flow thực tế:
- Method chạy đồng bộ cho đến khi gặp
awaitđầu tiên - Nếu Task đã complete (hot path) — method tiếp tục chạy, không có context switch
- Nếu Task chưa complete — method trả control về caller, thread được giải phóng về thread pool
- Khi Task complete, continuation được schedule lên
SynchronizationContexthiện tại (hoặc thread pool nếu không có context)
NOTETrong ASP.NET Core, không có
SynchronizationContext(đã bị loại bỏ từ ASP.NET Core 1.0). Continuation luôn chạy trên thread pool. Điều này giúp tránh deadlock nhưng cũng có nghĩa bạn không nên giả định continuation chạy trên thread nào.
Tại sao điều này quan trọng?
Vì nó giải thích tại sao blocking (.Result, .Wait()) gây deadlock trong môi trường có SynchronizationContext (WPF, WinForms, Blazor Server). Thread đang chờ Task complete, nhưng continuation cần chính thread đó để chạy — deadlock.
2. Task vs ValueTask — Khi nào dùng cái nào
// Task — allocate object trên heap mỗi lần gọi
public async Task<int> GetCountAsync()
{
var count = await _cache.GetAsync<int>("count");
return count;
}
// ValueTask — struct, zero allocation khi kết quả đã có sẵn
public ValueTask<int> GetCountAsync()
{
if (_cache.TryGetValue("count", out int cached))
return ValueTask.FromResult(cached); // Không allocate
return new ValueTask<int>(GetCountFromDbAsync());
}
Khi nào dùng ValueTask:
- Method thường xuyên trả về kết quả đồng bộ (cache hit, dữ liệu đã buffer)
- Hot path cần giảm GC pressure
- Interface/abstract method mà implementation có thể sync hoặc async
Khi nào dùng Task:
- Method luôn luôn async (HTTP call, DB query)
- Bạn cần
awaitnhiều lần hoặc lưu Task để dùng sau - Không chắc — cứ dùng Task, nó an toàn hơn
WARNING
ValueTaskcó hạn chế nghiêm ngặt: không được await nhiều lần, không được gọi.Resulttrước khi complete, không được dùngTask.WhenAlltrực tiếp. Vi phạm các rule này gây ra bug rất khó debug. Nếu cần những thao tác đó, gọi.AsTask()trước.
3. Những lỗi phổ biến — và cách chúng giết app của bạn
3.1. async void — Kẻ giết người thầm lặng
// WRONG — Exception sẽ crash toàn bộ process
public async void ProcessOrder(Order order)
{
await _orderService.ValidateAsync(order);
await _paymentService.ChargeAsync(order); // Throw -> CRASH
}
// CORRECT — Exception được propagate qua Task
public async Task ProcessOrderAsync(Order order)
{
await _orderService.ValidateAsync(order);
await _paymentService.ChargeAsync(order);
}
async void ném exception trực tiếp lên SynchronizationContext — không ai catch được. Trong ASP.NET Core, điều này crash cả process.
Chỉ dùng async void cho event handler trong UI (WPF/WinForms/MAUI), vì đó là design bắt buộc. Mọi trường hợp khác — trả về Task.
Cẩn thận với async void ngầm định qua delegate. Action accept async void lambda mà không warning:
// Đây là async void! forEach không await
items.ForEach(async item => await ProcessAsync(item));
// Thay bằng:
foreach (var item in items)
await ProcessAsync(item);
3.2. .Result và .Wait() — Deadlock factory
// WRONG — Deadlock trong WPF/Blazor Server, thread starvation trong ASP.NET Core
public string GetData()
{
var result = GetDataAsync().Result; // Block thread, chờ Task complete
return result;
}
// CORRECT — async all the way
public async Task<string> GetDataAsync()
{
var result = await FetchFromApiAsync();
return result;
}
Nguyên tắc vàng: async all the way. Nếu bạn gọi một async method, toàn bộ call stack phía trên cũng phải async. Không có ngoại lệ.
TIPNếu bạn bắt buộc phải gọi async từ sync context (ví dụ:
Mainmethod, legacy code), pattern an toàn nhất là:// Tạo thread riêng để tránh block thread pool var result = Task.Run(() => GetDataAsync()).GetAwaiter().GetResult();Đây vẫn là workaround, không phải giải pháp. Hãy refactor sang async khi có thể.
3.3. Fire-and-forget — Mất exception, mất scope
// WRONG — Exception bị nuốt, không log, DI scope có thể đã dispose
_ = SendEmailAsync(order.Email);
// BETTER — Log error, tạo scope riêng
_ = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
try
{
await emailService.SendAsync(order.Email);
}
catch (Exception ex)
{
var logger = scope.ServiceProvider.GetRequiredService<ILogger<OrderController>>();
logger.LogError(ex, "Failed to send email to {Email}", order.Email);
}
});
WARNINGFire-and-forget trong ASP.NET Core cực kỳ nguy hiểm vì request scope (HttpContext, DbContext, scoped services) có thể bị dispose trước khi Task complete. Luôn tạo scope mới nếu cần dùng pattern này. Hoặc tốt hơn — dùng background service (
IHostedService, channel, hoặc message queue).
4. ConfigureAwait(false) — Khi nào và tại sao
// Trong library/SDK code
public async Task<byte[]> DownloadAsync(string url)
{
var response = await _httpClient.GetAsync(url).ConfigureAwait(false);
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
return bytes;
}
ConfigureAwait(false) nói với runtime: “Tôi không cần quay lại SynchronizationContext ban đầu.” Continuation có thể chạy trên bất kỳ thread pool thread nào.
Khi nào dùng:
| Scenario | ConfigureAwait(false)? |
|---|---|
| Library/SDK/NuGet package | Luôn dùng — bạn không biết consumer có SynchronizationContext hay không |
| ASP.NET Core app code | Không cần — ASP.NET Core không có SynchronizationContext |
| WPF/WinForms/MAUI | Dùng nếu không cần update UI sau await |
| Blazor Server | Cẩn thận — có SynchronizationContext riêng |
TIPNếu toàn bộ project là ASP.NET Core, bạn có thể bỏ qua
ConfigureAwait(false)trong application code. Nhưng nếu viết shared library, luôn thêm nó — đây là best practice được chính Microsoft khuyến nghị.
5. CancellationToken — Pattern thực tế
CancellationToken cho phép bạn dừng operation sớm khi user cancel request, hoặc khi timeout.
Pattern 1: Propagate token qua toàn bộ call chain
public async Task<OrderDto> GetOrderAsync(int id, CancellationToken ct = default)
{
var order = await _dbContext.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
if (order is null)
throw new NotFoundException($"Order {id} not found");
var enriched = await _pricingService.EnrichAsync(order, ct);
return MapToDto(enriched);
}
Pattern 2: Timeout với linked token
public async Task<ReportDto> GenerateReportAsync(ReportRequest request, CancellationToken ct)
{
// Timeout 30s, nhưng cũng cancel nếu user cancel
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
try
{
return await _reportEngine.GenerateAsync(request, linkedCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
throw new TimeoutException("Report generation timed out after 30 seconds");
}
}
Pattern 3: Check cancellation trong loop
public async Task ImportProductsAsync(IEnumerable<ProductCsv> rows, CancellationToken ct)
{
var batch = new List<Product>(100);
foreach (var row in rows)
{
ct.ThrowIfCancellationRequested(); // Check mỗi iteration
batch.Add(MapToEntity(row));
if (batch.Count >= 100)
{
await _dbContext.BulkInsertAsync(batch, ct);
batch.Clear();
}
}
if (batch.Count > 0)
await _dbContext.BulkInsertAsync(batch, ct);
}
TIPTrong ASP.NET Core,
HttpContext.RequestAbortedtự động cancel khi client ngắt kết nối. InjectCancellationTokenvào controller action parameter và framework sẽ tự bind nó.
6. IAsyncEnumerable — Streaming data thay vì load hết vào memory
Thay vì load toàn bộ 1 triệu record vào List<T> rồi mới trả về, IAsyncEnumerable cho phép bạn yield từng item khi có sẵn.
Ví dụ: Stream data từ database
public async IAsyncEnumerable<ProductDto> GetAllProductsAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
var products = _dbContext.Products
.AsNoTracking()
.AsAsyncEnumerable();
await foreach (var product in products.WithCancellation(ct))
{
yield return new ProductDto
{
Id = product.Id,
Name = product.Name,
Price = product.Price
};
}
}
Ví dụ: Stream response từ API (kiểu ChatGPT streaming)
// Minimal API endpoint trả về streaming JSON
app.MapGet("/api/products/stream", async (
ProductService service,
CancellationToken ct) =>
{
var stream = service.GetAllProductsAsync(ct);
return Results.Ok(stream); // ASP.NET Core tự serialize từng item
});
// Consumer side
await foreach (var product in client.GetProductStreamAsync())
{
Console.WriteLine($"Received: {product.Name}");
}
NOTE
IAsyncEnumerableđặc biệt hữu ích khi kết hợp với gRPC server streaming, SignalR, hoặc SSE (Server-Sent Events) — nơi bạn cần push data liên tục mà không buffer toàn bộ.
7. Một số pattern hữu ích khác
Chạy song song nhiều task
public async Task<DashboardDto> GetDashboardAsync(int userId, CancellationToken ct)
{
// Start tất cả cùng lúc — không await từng cái
var ordersTask = _orderService.GetRecentAsync(userId, ct);
var statsTask = _statsService.GetSummaryAsync(userId, ct);
var notifsTask = _notifService.GetUnreadAsync(userId, ct);
// Chờ tất cả hoàn thành
await Task.WhenAll(ordersTask, statsTask, notifsTask);
return new DashboardDto
{
RecentOrders = ordersTask.Result, // Safe vì Task đã complete
Stats = statsTask.Result,
Notifications = notifsTask.Result
};
}
await using cho async dispose
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
// ... execute commands
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
// connection và transaction được dispose async khi ra khỏi scope
8. Bảng tổng hợp Best Practices
| Practice | Lý do |
|---|---|
| Async all the way — không mix sync/async | Tránh deadlock và thread starvation |
Không bao giờ dùng async void (trừ event handler UI) | Exception không catch được, crash process |
Không dùng .Result, .Wait() | Block thread, gây deadlock hoặc starvation |
Truyền CancellationToken qua toàn bộ chain | Cho phép cancel sớm, tiết kiệm tài nguyên |
ConfigureAwait(false) trong library code | Tránh deadlock khi consumer có SynchronizationContext |
ValueTask cho hot path sync-complete | Giảm allocation, giảm GC pressure |
IAsyncEnumerable cho streaming data | Giảm memory footprint, cải thiện TTFB |
await using cho async disposable | Đảm bảo flush buffer trước khi dispose |
| Start task sớm, await muộn | Tận dụng parallelism tự nhiên |
| Tạo scope mới cho fire-and-forget | Tránh dùng disposed services |
Kết luận
Async/await trong C# mạnh mẽ nhưng đầy cạm bẫy. Phần lớn bug liên quan đến async đều đến từ không hiểu thread nào chạy continuation và blocking thread không đúng chỗ.
Ba nguyên tắc quan trọng nhất:
- Async all the way — đừng bao giờ block async code bằng
.Resulthay.Wait() - Luôn truyền CancellationToken — đây là cách duy nhất để cancel gracefully
- Hiểu SynchronizationContext — nó quyết định continuation chạy ở đâu, và là nguyên nhân gốc rễ của deadlock
Nắm vững ba điều này, bạn sẽ tránh được 90% các bug async trong C#.
Đăng ký nhận bản tin
Nhận thông báo khi có bài viết mới. Không spam, hủy bất cứ lúc nào.