1652 từ
8 phút
Async/Await trong C# — Những gì dev thực sự cần biết

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ế:#

  1. Method chạy đồng bộ cho đến khi gặp await đầu tiên
  2. Nếu Task đã complete (hot path) — method tiếp tục chạy, không có context switch
  3. Nếu Task chưa complete — method trả control về caller, thread được giải phóng về thread pool
  4. Khi Task complete, continuation được schedule lên SynchronizationContext hiện tại (hoặc thread pool nếu không có context)
NOTE

Trong 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 await nhiề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

ValueTaskhạn chế nghiêm ngặt: không được await nhiều lần, không được gọi .Result trước khi complete, không được dùng Task.WhenAll trự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ệ.

TIP

Nếu bạn bắt buộc phải gọi async từ sync context (ví dụ: Main method, 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);
    }
});
WARNING

Fire-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:#

ScenarioConfigureAwait(false)?
Library/SDK/NuGet packageLuôn dùng — bạn không biết consumer có SynchronizationContext hay không
ASP.NET Core app codeKhông cần — ASP.NET Core không có SynchronizationContext
WPF/WinForms/MAUIDùng nếu không cần update UI sau await
Blazor ServerCẩn thận — có SynchronizationContext riêng
TIP

Nế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);
}
TIP

Trong ASP.NET Core, HttpContext.RequestAborted tự động cancel khi client ngắt kết nối. Inject CancellationToken và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#

PracticeLý do
Async all the way — không mix sync/asyncTrá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ộ chainCho phép cancel sớm, tiết kiệm tài nguyên
ConfigureAwait(false) trong library codeTránh deadlock khi consumer có SynchronizationContext
ValueTask cho hot path sync-completeGiảm allocation, giảm GC pressure
IAsyncEnumerable cho streaming dataGiả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ộnTận dụng parallelism tự nhiên
Tạo scope mới cho fire-and-forgetTrá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 continuationblocking thread không đúng chỗ.

Ba nguyên tắc quan trọng nhất:

  1. Async all the way — đừng bao giờ block async code bằng .Result hay .Wait()
  2. Luôn truyền CancellationToken — đây là cách duy nhất để cancel gracefully
  3. 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#.

Async/Await trong C# — Những gì dev thực sự cần biết
https://www.devwithxuan.com/vi/posts/asyn-programing-csharp/
Tác giả
XuanPD
Ngày đăng
2024-05-01
Giấy phép
CC BY-NC-SA 4.0
Chia sẻ:

Đă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.

Bình luận