EngineeringSenior DeveloperTraining Sessions

Session 01 - Async/Await, Threading & Task Parallelism trong .NET

Đây là chủ đề bị hiểu sai nhiều nhất ở level senior. Nhiều người "dùng async hàng ngày", nhưng thực ra chỉ đang viết code chạy được, chưa hẳn hiểu cách runtime vận hành.


Mục tiêu

  • Hiểu bản chất async/await
  • Phân biệt async với multithreading
  • Biết khi nào nên và không nên dùng Task.Run
  • Tránh deadlock, thread starvation, fake async
  • Review async code như một master

1) Bản chất thật sự của async/await

Điều quan trọng số 1: async/await không phải là "chạy trên thread khác". Nó là cơ chế giúp bạn viết asynchronous non-blocking flow dưới dạng code tuần tự dễ đọc.

Khi bạn viết:

csharp
var data = await httpClient.GetStringAsync("https://api.com/users");
Console.WriteLine(data);

Chuyện gì thực sự xảy ra:

  1. GetStringAsync gửi HTTP request → không block thread
  2. Thread hiện tại được trả về thread pool (hoặc trả lại UI thread)
  3. Khi response về, runtime lấy một thread bất kỳ từ pool (hoặc quay lại UI thread nếu có SynchronizationContext) để chạy tiếp phần sau await
Thread A: ──[gửi request]──→ trả thread về pool ──→ (rảnh, làm việc khác)
                                                              ⬇
                                    (response về)
                                                              ⬇
Thread B: ──────────────────────────→ [chạy Console.WriteLine]

Kết luận: async giúp giải phóng thread trong lúc chờ I/O, KHÔNG phải tạo thread mới.


2) Async ≠ Multi-threading

Đây là nhầm lẫn phổ biến nhất:

Async/AwaitMulti-threading
Bản chấtGiải phóng thread khi chờ I/OChạy code song song trên nhiều threads
Mục đíchTránh block thread (scalability)Tận dụng nhiều CPU cores (parallelism)
Khi nào dùngI/O: HTTP, database, file, networkCPU-bound: tính toán nặng, image processing
Thread mới?Không (trả thread về pool)Có (dùng thread pool hoặc tạo mới)

Ví dụ minh họa rõ nhất

csharp
// Async I/O - KHÔNG dùng thread mới
async Task<string> GetUserAsync()
{
    // Thread được trả về pool trong lúc chờ DB
    var user = await db.Users.FirstOrDefaultAsync(u => u.Id == 1);
    return user.Name;
}

// Multi-threading - DÙNG thread từ pool
Task<int> CalculateHeavyAsync()
{
    // Đẩy computation sang thread pool
    return Task.Run(() =>
    {
        // Chạy trên thread khác, CPU goes brrrr
        return Fibonacci(1_000_000);
    });
}

Câu hỏi tự kiểm tra

Bạn đang gọi API bên thứ 3, chờ response mất 2 giây. Trong lúc chờ, thread đang làm gì?

Nếu dùng async đúng: Thread rảnh, được trả về pool, phục vụ request khác.
Nếu dùng sync (.Result): Thread bị block, nằm chờ, không làm gì → thread starvation nếu load cao.


3) SynchronizationContext - "Thủ phạm" của deadlock

Vấn đề kinh điển

csharp
// ❌ DEADLOCK trong ASP.NET Framework (pre-.NET Core) hoặc WPF/WinForms
public string GetUserSync()
{
    // .Result block thread hiện tại (UI thread hoặc request thread)
    var user = GetUserAsync().Result;  // 💀 DEADLOCK
    return user.Name;
}

async Task<User> GetUserAsync()
{
    var user = await db.Users.FindAsync(1);
    // → await muốn quay lại thread gốc (SynchronizationContext)
    // → nhưng thread gốc đang bị block bởi .Result
    // → DEADLOCK!
    return user;
}

Tại sao deadlock?

1. GetUserSync() chạy trên Thread A
2. Gọi GetUserAsync().Result → Block Thread A, chờ Task hoàn thành
3. Trong GetUserAsync(), await db.Users.FindAsync(1) hoàn thành
4. SynchronizationContext nói: "phải quay lại Thread A để chạy tiếp"
5. Nhưng Thread A đang bị block bởi .Result → 💀 DEADLOCK

Giải pháp

csharp
// ✅ Giải pháp 1: Dùng async all the way (BẮT BUỘC)
public async Task<string> GetUserAsync()
{
    var user = await GetUserFromDbAsync();
    return user.Name;
}

// ✅ Giải pháp 2: ConfigureAwait(false) trong library code
async Task<User> GetUserFromDbAsync()
{
    var user = await db.Users.FindAsync(1).ConfigureAwait(false);
    // → Nói với runtime: KHÔNG CẦN quay lại thread/context gốc
    // → Chạy tiếp trên bất kỳ thread nào từ pool
    return user;
}

ConfigureAwait - Khi nào dùng?

Code typeConfigureAwait(false)Lý do
Library/NuGet package✅ LUÔN dùngKhông biết consumer có SynchronizationContext không
ASP.NET Core app🔄 Không bắt buộcASP.NET Core KHÔNG có SynchronizationContext
WPF/WinForms/MAUI⚠️ Cẩn thậnNếu dùng → code sau await KHÔNG chạy trên UI thread
Blazor Server✅ Nên dùngGiống library code

ASP.NET Core không có SynchronizationContext, nên deadlock kiểu trên KHÔNG xảy ra. Nhưng .Result vẫn block thread → thread starvation vẫn là vấn đề!


4) Thread Starvation - Khi Nào Xảy Ra?

csharp
// Thread pool mặc định: ~(số CPU cores) threads ban đầu
// Ví dụ: server 4 cores → bắt đầu với ~4 threads

// ❌ Block 100 threads cùng lúc
app.MapGet("/users/{id}", (int id) =>
{
    // .Result block thread, thread pool phải tạo thêm threads
    // Thread injection rate: ~1-2 threads/giây (RẤT CHẬM!)
    var user = httpClient.GetStringAsync($"/api/users/{id}").Result;
    return user;
});
// 100 requests đồng thời → 96 requests phải chờ thread → timeout!

// ✅ Async: 100 requests, vẫn chỉ cần vài threads
app.MapGet("/users/{id}", async (int id) =>
{
    var user = await httpClient.GetStringAsync($"/api/users/{id}");
    return user;
});
// 100 requests → mỗi request gửi HTTP rồi trả thread → 4 threads đủ phục vụ!

Dấu hiệu nhận biết Thread Starvation

1. Response time tăng đột ngột dưới load vừa phải
2. dotnet-counters: "ThreadPool Thread Count" tăng liên tục
3. Nhiều thread ở trạng thái "waiting" trong dump
4. App "lag" nhưng CPU thấp (threads bị block, không CPU-bound)

5) Task.Run - Khi Nào Dùng, Khi Nào KHÔNG

❌ Sai: Wrap async method trong Task.Run

csharp
// ❌ KHÔNG wrap I/O async trong Task.Run
// Task.Run lấy thread từ pool → async method trả thread về → vô nghĩa!
var data = await Task.Run(() => httpClient.GetStringAsync(url));
// → Dùng 1 thread để... giải phóng 1 thread? Tốn overhead, không gain gì.

// ✅ Gọi async method trực tiếp
var data = await httpClient.GetStringAsync(url);

✅ Đúng: CPU-bound work

csharp
// ✅ Task.Run cho CPU-bound work
// Offload computation nặng khỏi request thread
app.MapGet("/report", async () =>
{
    // Computation mất 5 giây → block request thread nếu không dùng Task.Run
    var report = await Task.Run(() => GenerateComplexReport());
    return report;
});

int Fibonacci(int n)
{
    // Pure CPU work, không có I/O
    if (n <= 1) return n;
    return Fibonacci(n - 1) + Fibonacci(n - 2);
}

⚠️ Cẩn thận: Task.Run trong ASP.NET

csharp
// ⚠️ Trong ASP.NET, Task.Run có thể gây HẠI
// Vì: request thread đã ở trong thread pool rồi!
// Task.Run = lấy THÊM 1 thread từ pool → giảm throughput

// Chỉ dùng Task.Run trong ASP.NET khi:
// 1. CPU-bound work sẽ block request thread quá lâu (>50ms)
// 2. Bạn muốn nó chạy parallel với work khác

// Trong Desktop app (WPF/WinForms): Task.Run HỢP LÝ hơn
// Vì: offload khỏi UI thread để UI không freeze
await Task.Run(() => HeavyComputation()); // UI thread rảnh → app responsive

6) Fake Async - Anti-Pattern Nguy Hiểm

csharp
// ❌ FAKE ASYNC: method async nhưng bên trong sync
// Cho caller ẢO TƯỞNG là async, thực tế block thread!

async Task<User> GetUserAsync(int id)
{
    // DbContext không có sync API nên dev... wrap sync code
    return await Task.Run(() =>
    {
        using var conn = new SqlConnection(connStr);
        conn.Open();                  // 💀 Block thread!
        var cmd = new SqlCommand("SELECT * FROM Users WHERE Id = @id", conn);
        cmd.Parameters.AddWithValue("@id", id);
        // ...đọc data synchronously...
    });
}

// ✅ REAL ASYNC: dùng native async APIs
async Task<User> GetUserAsync(int id)
{
    await using var conn = new SqlConnection(connStr);
    await conn.OpenAsync();           // ✅ Non-blocking
    var cmd = new SqlCommand("SELECT * FROM Users WHERE Id = @id", conn);
    cmd.Parameters.AddWithValue("@id", id);
    await using var reader = await cmd.ExecuteReaderAsync();  // ✅ Non-blocking
    // ...
}

Quy tắc vàng: Nếu thư viện có phương thức async (ví dụ OpenAsync, ReadAsync, SaveChangesAsync), luôn dùng bản async. Nếu không có → đó có thể là thư viện cũ, cân nhắc thay thế.


7) Parallel vs Concurrent vs Async

Concurrent (đồng thời):
  Nhiều task TIẾN TRIỂN cùng lúc, nhưng có thể KHÔNG chạy song song.
  Ví dụ: 1 thread xử lý xen kẽ 3 requests (async I/O)
  → Thread switch giữa các tasks khi 1 task đang chờ I/O

Parallel (song song):
  Nhiều tasks chạy CÙ LÚC trên nhiều threads/cores.
  Ví dụ: 4 threads tính Fibonacci trên 4 cores
  → Đúng nghĩa song song, cần nhiều CPU cores

Async:
  Cơ chế VIẾT CODE cho concurrent flow.
  Không tự nó song song, không tự tạo thread.

Task.WhenAll - Concurrent, không nhất thiết Parallel

csharp
// 3 HTTP calls chạy CONCURRENT (đồng thời, cùng tiến triển)
// Nhưng có thể chỉ dùng 1-2 threads!
var userTask = httpClient.GetAsync("/users/1");
var orderTask = httpClient.GetAsync("/orders/latest");
var productTask = httpClient.GetAsync("/products/top");

await Task.WhenAll(userTask, orderTask, productTask);
// → 3 requests gửi đi cùng lúc
// → Thread được trả về pool trong lúc chờ
// → Tổng thời gian ≈ max(3 requests), không phải sum(3 requests)

Parallel.ForEachAsync - True Parallel

csharp
// .NET 6+: xử lý song song N items
var urls = GetUrlsToProcess();  // 1000 URLs

await Parallel.ForEachAsync(urls, new ParallelOptions
{
    MaxDegreeOfParallelism = 10  // Tối đa 10 concurrent
}, async (url, ct) =>
{
    var response = await httpClient.GetAsync(url, ct);
    await ProcessResponseAsync(response);
});

8) ValueTask - Khi Nào Dùng?

csharp
// Task: allocate object trên heap MỖI LẦN gọi
// ValueTask: là struct, KHÔNG allocate nếu result đã sẵn

// ✅ Dùng ValueTask khi: result thường xuyên có sẵn (cached)
async ValueTask<User?> GetUserAsync(int id)
{
    // Cache hit → trả về ngay, KHÔNG allocate Task object
    if (_cache.TryGetValue(id, out var user))
        return user;

    // Cache miss → thực sự async
    user = await _db.Users.FindAsync(id);
    _cache.Set(id, user);
    return user;
}

// ❌ KHÔNG dùng ValueTask khi:
// - Method luôn async (luôn await I/O)
// - Caller cần await nhiều lần (ValueTask chỉ await 1 lần!)
// - Interface public rộng (Task dễ compose hơn)

Master Checklist ✅

Khi review async code, hãy kiểm tra:

□ Async all the way? (không .Result, .Wait(), .GetAwaiter().GetResult())
□ Task.Run chỉ dùng cho CPU-bound? (không wrap I/O async)
□ Không fake async? (dùng native async APIs: OpenAsync, ReadAsync...)
□ ConfigureAwait(false) trong library code?
□ Task.WhenAll cho independent async operations?
□ CancellationToken được forward ở mọi tầng?
□ Không async void? (trừ event handlers)
□ Không try/catch nuốt exception trong async?

Common Mistakes Recap

MistakeHậu quảFix
.Result / .Wait()Deadlock hoặc thread starvationawait
Task.Run(() => AsyncMethod())Waste thread, no gainawait AsyncMethod()
async voidException không bắt được, crash appasync Task
Quên CancellationTokenRequest bị cancel nhưng server vẫn xử lýForward token
await trong loop tuần tựChậm (sequential)Task.WhenAll nếu independent

Tư duy master: Mỗi khi viết await, hãy nghĩ: "Thread này đi đâu? Khi quay lại, nó ở thread nào?" - Nếu trả lời được, bạn đã hiểu async.