Chuyển tới nội dung chính

Async/Await

Định nghĩa (Definition)

async/await là một tính năng cấp ngôn ngữ trong C# giúp đơn giản hóa lập trình bất đồng bộ (Asynchronous Programming). Bổ từ async cho phép sử dụng await trong một phương thức, và await chờ đợi một Task hoặc Task<T> hoàn thành một cách bất đồng bộ mà không chặn luồng gọi. Trình biên dịch chuyển đổi phương thức thành state machine (máy trạng thái) trả lại quyền điều khiển cho người gọi tại mỗi điểm await và tiếp tục khi thao tác được await hoàn tất.

Tại sao Async/Await tồn tại

Vấn đề không có async/awaitGiải pháp với async/await
Lệnh gọi chặn luồng lãng phí tài nguyên thread poolawait giải phóng luồng trong khi chờ đợi
Chuỗi callback (continuation) lồng nhau sâu ("địa ngục callback" — Callback Hell)Luồng code tuyến tính, từ trên xuống dưới
Task.ContinueWith() thủ công khó đọc và kết hợpawait xử lý wiring continuation tự động
Lỗi SynchronizationContext với callback lồng nhauState machine do trình biên dịch tạo xử lý ngữ cảnh chính xác
// Không có async/await — chặn, lãng phí luồng
public string GetData()
{
var response = httpClient.GetAsync(url).Result; // chặn luồng
return response.Content.ReadAsStringAsync().Result;
}

// Với async/await — không chặn, hiệu quả
public async Task<string> GetDataAsync()
{
var response = await httpClient.GetAsync(url); // luồng được giải phóng
return await response.Content.ReadAsStringAsync(); // luồng được giải phóng lần nữa
}
Lợi ích chính
  • Không chặn (Non-blocking) — các luồng được giải phóng trong khi chờ I/O
  • Dễ đọc (Readable) — code bất đồng bộ đọc như code đồng bộ
  • Khả năng mở rộng (Scalable) — ít luồng phục vụ nhiều yêu cầu đồng thời hơn
  • Khả năng kết hợp (Composable) — phương thức async có thể gọi các phương thức async khác một cách tự nhiên

Khái niệm cốt lõi (Core Concepts)

Async hoạt động bên dưới như thế nào

Chuyển đổi State Machine (State Machine Transformation)

Khi trình biên dịch C# gặp một phương thức async, nó chuyển đổi thành một state machine struct (IAsyncStateMachine). Mỗi điểm await trở thành một chuyển đổi trạng thái. Phương thức được chia thành các đoạn ngăn cách bởi các ranh giới await.

// Những gì bạn viết
public async Task<string> FetchAsync()
{
var response = await httpClient.GetAsync(url); // Trạng thái 0 → Trạng thái 1
var content = await response.Content.ReadAsStringAsync(); // Trạng thái 1 → Trạng thái 2
return content.Trim();
}
// Phiên bản đơn giản hóa những gì trình biên dịch tạo ra
private struct StateMachine : IAsyncStateMachine
{
public int State;
public AsyncTaskMethodBuilder<string> Builder;
public HttpClient HttpClient;
private string url;
private HttpResponseMessage response;
private string content;
private TaskAwaiter<HttpResponseMessage> awaiter1;
private TaskAwaiter<string> awaiter2;

void MoveNext()
{
try
{
if (State == 0)
{
awaiter1 = HttpClient.GetAsync(url).GetAwaiter();
if (!awaiter1.IsCompleted)
{
State = 1;
Builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this);
return; // trả lại quyền điều khiển cho người gọi
}
response = awaiter1.GetResult();
}
// Trạng thái 1: tiếp tục sau await đầu tiên
if (State == 1)
{
response = awaiter1.GetResult();
awaiter2 = response.Content.ReadAsStringAsync().GetAwaiter();
if (!awaiter2.IsCompleted)
{
State = 2;
Builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
return; // trả lại quyền điều khiển lần nữa
}
content = awaiter2.GetResult();
}
// Trạng thái 2: tiếp tục sau await thứ hai
content = awaiter2.GetResult();
Builder.SetResult(content.Trim()); // hoàn thành Task
}
catch (Exception ex)
{
Builder.SetException(ex);
}
}
}

Điểm chính về state machine:

  • Nó là một struct — được cấp phát trên stack ban đầu, chỉ boxed lên heap nếu phương thức thực sự tạm dừng
  • Mỗi await là một điểm tạm dừng (Suspension Point) tiềm năng nơi phương thức trả lại quyền điều khiển
  • Phương thức MoveNext() được gọi mỗi khi awaited task hoàn thành
  • Không có luồng nào bị chặn trong khi chờ — continuation được lập lịch bởi awaiter

Async I/O: Không có luồng trong khi chờ

Một hiểu lầm phổ biến là async I/O "sử dụng một luồng từ thread pool để chờ." Thực tế, không có luồng nào tham gia trong thời gian chờ I/O.

  1. Ứng dụng gọi một phương thức I/O async (ví dụ: ReadAsync)
  2. Kernel của OS khởi tạo thao tác I/O và trả về ngay lập tức
  3. Luồng ứng dụng được giải phóng trở lại thread pool
  4. Khi phần cứng hoàn thành thao tác, nó kích hoạt hardware interrupt
  5. Kernel của OS báo hiệu I/O Completion Port (IOCP)
  6. Một luồng thread pool nhận thông báo hoàn thành và tiếp tục state machine

Đây là lý do tại sao async có khả năng mở rộng: 10,000 HTTP request đồng thời không cần 10,000 luồng. Tất cả đều phát hành I/O và giải phóng luồng của chúng, sau đó tiếp tục khi I/O hoàn tất.

Async CPU-bound vs I/O-bound

Khía cạnhI/O-BoundCPU-Bound
Ví dụHTTP request, đọc file, truy vấn cơ sở dữ liệuXử lý hình ảnh, nén, mã hóa
Cách hoạt độngKhông có luồng trong khi chờ (IOCP)Task.Run chuyển sang thread pool
Khả năng mở rộngTuyệt vời — luồng được giải phóng trong khi chờHạn chế — vẫn sử dụng một luồng
Sử dụng asyncLuôn luônChỉ để chuyển tải từ luồng UI
Patternawait networkStream.ReadAsync()await Task.Run(() => Compute())
// I/O-bound — không có luồng sử dụng trong khi chờ
public async Task<string> FetchAsync(string url)
{
var response = await httpClient.GetAsync(url); // luồng được giải phóng
return await response.Content.ReadAsStringAsync(); // luồng được giải phóng lần nữa
}

// CPU-bound — luồng thread pool thực hiện công việc
public async Task<byte[]> CompressAsync(byte[] data)
{
return await Task.Run(() => Compress(data)); // luồng khác thực hiện công việc CPU
}

SynchronizationContext

SynchronizationContext là một trừu tượng xác định nơi continuation chạy sau một await. Các mô hình ứng dụng khác nhau cung cấp các triển khai khác nhau:

Loại ứng dụngSynchronizationContextHành vi Continuation
ASP.NET CoreKhông có (null)Tiếp tục trên bất kỳ luồng thread pool nào
ASP.NET (legacy)AspNetSynchronizationContextTiếp tục trên luồng request gốc
WPF / WinFormsDispatcherSynchronizationContextTiếp tục trên luồng UI
Console appKhông có (null)Tiếp tục trên bất kỳ luồng thread pool nào
// Trong WPF — await capture SynchronizationContext UI theo mặc định
private async void Button_Click(object sender, EventArgs e)
{
var data = await FetchDataAsync(); // tiếp tục trên luồng UI
Label.Text = data; // an toàn — chúng ta đang trên luồng UI
}

// Với ConfigureAwait(false) — tiếp tục trên luồng thread pool
private async Task<string> LibraryMethodAsync()
{
var data = await FetchDataAsync().ConfigureAwait(false); // tiếp tục trên bất kỳ luồng nào
return data.Trim(); // KHÔNG trên luồng UI — không thể cập nhật UI ở đây
}
Tại sao ASP.NET Core không có SynchronizationContext

ASP.NET Core được thiết kế để không có trạng thái (Stateless) và không phụ thuộc luồng (Thread-agnostic). Trạng thái request được lưu trong HttpContext, không phải trong bộ nhớ cục bộ luồng (Thread-local Storage). Điều này loại bỏ nhu cầu chuyển đổi ngữ cảnh và tránh các vấn đề deadlock từng làm khó chịu legacy ASP.NET khi các nhà phát triển gọi .Result trên phương thức async.

Vòng đời và Trạng thái của Task

Một Task đi qua các trạng thái cụ thể trong vòng đời của nó:

Trạng tháiStatusIsCompletedIsFaultedIsCanceled
CreatedTaskStatus.Createdfalsefalsefalse
RunningTaskStatus.Runningfalsefalsefalse
SuccessTaskStatus.RanToCompletiontruefalsefalse
ExceptionTaskStatus.Faultedtruetruefalse
CancelledTaskStatus.Canceledtruefalsetrue
var task = FetchDataAsync();

if (task.IsCompleted) { /* xong (thành công, lỗi, hoặc hủy) */ }
if (task.IsCompletedSuccessfully) { /* chỉ thành công */ }
if (task.IsFaulted) { /* xảy ra ngoại lệ */ }
if (task.IsCanceled) { /* bị hủy */ }

// Truy cập kết quả (chỉ khi RanToCompletion)
if (task.IsCompletedSuccessfully)
{
string result = task.Result; // an toàn — không chặn
}

Sự tiến hóa của các mô hình lập trình Async

C# đã trải qua ba mô hình lập trình async chính:

Mô hìnhKỷ nguyênCách tiếp cậnKhả năng kết hợpHủy bỏXử lý lỗi
APM.NET 1.xBegin/End + IAsyncResultKhôngThủ côngEndXxx ném
EAP.NET 2.0Events + XxxCompletedKhôngHạn chếEvent args
TAP.NET 4.0+Task + async/awaitCancellationTokentry/catch

APM — pattern dựa trên callback gốc:

stream.BeginRead(buffer, 0, buffer.Length, ar =>
{
int bytesRead = stream.EndRead(ar);
// callback lồng nhau → "địa ngục callback"
}, null);

EAP — pattern dựa trên sự kiện:

webClient.DownloadDataCompleted += (sender, e) =>
{
byte[] data = e.Result; // khó kết hợp, chuỗi, hoặc hủy
};
webClient.DownloadDataAsync(url);

TAP — pattern hiện đại chúng ta sử dụng ngày nay:

public async Task<byte[]> DownloadDataAsync(Uri url)
{
var response = await httpClient.GetAsync(url);
return await response.Content.ReadAsByteArrayAsync();
}

Thread Pool và Task Scheduler

Các object Task thực thi trên thread pool theo mặc định. Thread pool quản lý một tập hợp các luồng worker và các luồng I/O completion port, điều chỉnh số lượng động dựa trên khối lượng công việc.

// Task.Run lập lịch công việc trên thread pool
var task = Task.Run(() => ExpensiveComputation());

// Task chạy lâu có luồng chuyên dụng
var task = Task.Run(() => LongWork(), TaskCreationOptions.LongRunning);

// TaskScheduler tùy chỉnh để kiểm soát thực thi
public class OrderedTaskScheduler : TaskScheduler
{
private readonly Queue<Task> _tasks = new();

protected override void QueueTask(Task task) => _tasks.Enqueue(task);
protected override bool TryExecuteTaskInline(Task task, bool wasQueued) => TryExecuteTask(task);
protected override IEnumerable<Task> GetScheduledTasks() => _tasks;
}
Thread Pool vs Luồng chuyên dụng
  • Thread pool — được tái sử dụng, quản lý bởi runtime. Lý tưởng cho các đơn vị công việc ngắn. Đừng chặn các luồng thread pool bằng các thao tác chạy lâu.
  • Task chạy lâu — sử dụng TaskCreationOptions.LongRunning để gợi ý scheduler tạo luồng chuyên dụng thay vì sử dụng pool.
  • Cạn kiệt thread pool (Thread Pool Starvation) — nếu quá nhiều luồng thread pool bị chặn (ví dụ: chờ đồng bộ trên code async), các task mới xếp hàng và toàn bộ ứng dụng trở nên không phản hồi.

Task và Task<T>

Task biểu diễn một thao tác bất đồng bộ có thể chưa hoàn thành — hãy nghĩ nó như một "lời hứa" (Promise) rằng công việc sẽ hoàn tất vào một thời điểm nào đó trong tương lai. Bạn không nhận được giá trị kết quả, chỉ có tín hiệu rằng thao tác đã hoàn thành (hoặc thất bại).

Task<T> là phiên bản generic tạo ra kết quả kiểu T khi hoàn thành. Sau khi await nó, bạn nhận trực tiếp giá trị T.

Task là nền tảng cơ bản của pattern TAP. Chúng không phải là luồng — một Task là cấu trúc dữ liệu theo dõi trạng thái của một thao tác, trong khi Thread là đơn vị thực thi cấp OS. Một Task có thể sử dụng một luồng (cho công việc CPU-bound qua Task.Run) hoặc không sử dụng luồng nào (cho công việc I/O-bound).

// Task — không có giá trị trả về (giống void)
public async Task DoWorkAsync()
{
await Task.Delay(1000); // mô phỏng công việc async
Console.WriteLine("Done");
}

// Task<T> — trả về một giá trị
public async Task<string> GetNameAsync()
{
await Task.Delay(500);
return "Alice";
}

// Tạo task đã hoàn thành
Task.CompletedTask // Task đã hoàn thành không có kết quả
Task.FromResult(42) // Task<int> đã hoàn thành với giá trị 42
Task.FromException(ex) // task bị lỗi

Từ khóa Async/Await

public async Task<int> CalculateAsync()
{
// 'await' tạm dừng phương thức và trả về cho người gọi
int a = await FetchValueAsync(); // I/O async
int b = await ComputeAsync(a); // tính toán async
return a + b; // kết quả được bao trong Task<int>
}

// Nhiều await thực thi tuần tự
public async Task ChainAsync()
{
await Step1Async(); // chờ bước 1
await Step2Async(); // rồi bước 2
await Step3Async(); // rồi bước 3
}
Chữ ký phương thức Async
  • Sử dụng async Task cho phương thức async không có giá trị trả về. Không bao giờ dùng async void trừ event handler.
  • Kiểu trả về là Task<T>, không phải T — trình biên dịch tự động bao giá trị trả về.
  • Thêm async vào phương thức không sử dụng await sẽ tạo cảnh báo trình biên dịch (CS1998).

Thực thi đồng thời (Concurrent Execution)

Khi bạn có nhiều thao tác async độc lập, bạn có thể chạy chúng tuần tự (lần lượt) hoặc đồng thời (tất cả cùng lúc). Thực thi tuần tự là mặc định với nhiều câu lệnh await — mỗi cái chờ cái trước hoàn thành trước khi bắt đầu cái tiếp theo. Thực thi đồng thời bắt đầu tất cả các thao tác ngay lập tức và chờ tất cả hoàn thành, nhanh hơn nhiều khi các thao tác độc lập.

Sử dụng Task.WhenAll khi bạn cần tất cả kết quả (ví dụ: tải dữ liệu từ 3 API đồng thời). Sử dụng Task.WhenAny khi bạn chỉ cần kết quả đầu tiên (ví dụ: truy vấn nhiều mirror và lấy phản hồi nhanh nhất).

// Tuần tự — tổng thời gian = tổng tất cả các thao tác
public async Task SequentialAsync()
{
await Task.Delay(1000); // 1s
await Task.Delay(1000); // 1s
await Task.Delay(1000); // 1s → tổng: 3s
}

// Đồng thời — tất cả task chạy đồng thời
public async Task ConcurrentAsync()
{
var t1 = Task.Delay(1000);
var t2 = Task.Delay(1000);
var t3 = Task.Delay(1000);
await Task.WhenAll(t1, t2, t3); // tổng: ~1s
}

// WhenAll — chờ tất cả, lấy tất cả kết quả
public async Task<int[]> GetAllResultsAsync()
{
var tasks = new[]
{
GetScoreAsync("Alice"),
GetScoreAsync("Bob"),
GetScoreAsync("Charlie"),
};
return await Task.WhenAll(tasks); // int[] với tất cả kết quả
}

// WhenAny — chờ cái đầu tiên hoàn thành
public async Task<string> GetFastestAsync()
{
var tasks = urls.Select(url => FetchAsync(url)).ToArray();
Task<string> first = await Task.WhenAny(tasks);
return await first;
}

Cancellation Token

CancellationToken là một struct cho phép hủy bỏ hợp tác (Cooperative Cancellation) — người gọi báo hiệu "xin dừng lại," và thao tác async kiểm tra tín hiệu này và quyết định cách phản hồi. Nó là hợp tác vì không bên nào bị ép buộc: người gọi yêu cầu hủy, và thao tác quyết định khi nào và cách thực hiện.

Pattern điển hình: tạo CancellationTokenSource (người gửi), truyền thuộc tính Token của nó cho các phương thức async (người nhận), và gọi .Cancel() khi bạn muốn dừng. Các phương thức BCL async (HTTP, file I/O, EF Core) đã chấp nhận và kiểm tra token — bạn chỉ cần truyền chúng qua. Đối với vòng lặp của riêng bạn, gọi ct.ThrowIfCancellationRequested() để kiểm tra giữa các lần lặp.

// Định nghĩa một phương thức async có thể hủy
public async Task<Data> FetchDataAsync(CancellationToken cancellationToken)
{
var response = await httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Data>(cancellationToken);
}

// Hủy từ người gọi
public async Task ExampleAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // timeout

try
{
var data = await FetchDataAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Request was cancelled or timed out");
}
}

// Hủy thủ công
public async Task ProcessAsync()
{
using var cts = new CancellationTokenSource();

// Hủy từ luồng khác hoặc sự kiện
_ = Task.Run(async () =>
{
await Task.Delay(5000);
cts.Cancel(); // kích hoạt hủy sau 5 giây
});

await LongRunningOperationAsync(cts.Token);
}

// Kiểm tra hủy trong code của riêng bạn
public async Task ProcessItemsAsync(IEnumerable<Item> items, CancellationToken ct)
{
foreach (var item in items)
{
ct.ThrowIfCancellationRequested(); // ném OperationCanceledException
await ProcessItemAsync(item, ct);
}
}
Thực tiễn tốt nhất cho Hủy bỏ
  • Chấp nhận CancellationToken làm tham số cuối cùng trong phương thức async.
  • Truyền token đến tất cả các lệnh gọi async downstream.
  • Sử dụng CancellationTokenSource(TimeSpan) cho timeout.
  • Sử dụng ct.ThrowIfCancellationRequested() trong vòng lặp CPU-bound.

Xử lý ngoại lệ (Exception Handling)

Ngoại lệ từ các thao tác async hoạt động giống như ngoại lệ đồng bộ — bạn bắt chúng với try/catch quanh await. Sự khác biệt chính là với Task.WhenAll: khi nhiều task thất bại, chỉ ngoại lệ đầu tiên được ném bởi await. Các ngoại lệ còn lại được bắt trong thuộc tính Exception của task trả về dưới dạng AggregateException. Nếu bạn cần quan sát tất cả các thất bại (ví dụ: ghi log tất cả lỗi), bạn phải truy cập task.Exception.InnerExceptions.

// Ngoại lệ được bắt trên await
public async Task SafeFetchAsync()
{
try
{
var data = await FetchDataAsync();
}
catch (HttpRequestException ex)
{
Console.WriteLine($"HTTP error: {ex.Message}");
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was cancelled");
}
finally
{
Cleanup();
}
}

// Quan sát tất cả ngoại lệ từ WhenAll
public async Task ObserveAllErrorsAsync()
{
var tasks = urls.Select(url => FetchAsync(url));
var allTasks = Task.WhenAll(tasks);

try
{
await allTasks;
}
catch
{
// allTasks.Exception chứa tất cả ngoại lệ dưới dạng AggregateException
foreach (var ex in allTasks.Exception!.InnerExceptions)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
}

ConfigureAwait

Theo mặc định, khi bạn await một Task, code sau await (continuation) cố gắng tiếp tục trên cùng ngữ cảnh nó đang ở trước — ví dụ: luồng UI trong WPF, hoặc luồng request trong legacy ASP.NET. Điều này được gọi là capture SynchronizationContext.

ConfigureAwait(false) nói với awaiter: "Không capture ngữ cảnh. Tiếp tục trên bất kỳ luồng thread pool nào." Điều này quan trọng vì:

  • Trong code thư viện, bạn không cần bất kỳ ngữ cảnh cụ thể nào, nên tránh capture nhanh hơn và ngăn deadlock.
  • Trong code UI, bạn cần luồng UI để cập nhật control, nên KHÔNG nên sử dụng nó.
  • Trong ASP.NET Core, không có ngữ cảnh để capture, nên không tạo khác biệt — nhưng bao gồm cũng không gây hại.
// Code thư viện — luôn sử dụng ConfigureAwait(false)
public async Task<string> LibraryMethodAsync()
{
var data = await httpClient.GetStringAsync(url).ConfigureAwait(false);
var result = await ParseAsync(data).ConfigureAwait(false);
return result;
}

// Code ứng dụng UI/ASP.NET — sử dụng mặc định (tiếp tục trên ngữ cảnh đã capture)
public async Task<IActionResult> ControllerActionAsync()
{
var data = await _service.GetDataAsync(); // ConfigureAwait(true) là mặc định
return Ok(data);
}
Khi nào sử dụng ConfigureAwait(false)
  • Code thư viện: Luôn sử dụng .ConfigureAwait(false) — bạn không biết ngữ cảnh của người gọi, và tránh capture ngữ cảnh cải thiện hiệu suất và ngăn deadlock.
  • Code ứng dụng (ASP.NET Core, console app): Không cần — ASP.NET Core không có SynchronizationContext, nên không tạo khác biệt. Bao gồm cũng không gây hại.
  • Ứng dụng UI (WPF, WinForms, MAUI): KHÔNG sử dụng nếu bạn cần cập nhật UI sau await, vì bạn sẽ mất ngữ cảnh luồng UI.

ValueTask

Task<T> là một class (kiểu tham chiếu), nên mỗi lần bạn tạo một, nó cấp phát trên heap. Đối với hầu hết code điều này ổn, nhưng trong đường dẫn nóng (Hot Paths) nơi kết quả thường có sẵn ngay lập tức (ví dụ: cache hit), việc cấp phát là lãng phí.

ValueTask<T> là một struct (kiểu giá trị) bao hoặc trực tiếp kết quả T (không cấp phát) hoặc một Task<T> (chỉ cấp phát khi đường dẫn async cần thiết). Điều này tránh cấp phát heap trên đường dẫn nhanh đồng bộ.

Khi nào sử dụng ValueTask<T>: khi bạn có một phương thức async nơi kết quả có sẵn ngay lập tức hầu hết thời gian — cache, đọc stream đã buffer, hoặc các phương thức short-circuit. Đối với mọi thứ khác, tiếp tục dùng Task<T>.

// Sử dụng ValueTask khi kết quả thường có sẵn ngay lập tức
public ValueTask<string> GetCachedValueAsync(string key)
{
if (_cache.TryGetValue(key, out string? value))
{
return new ValueTask<string>(value); // đồng bộ — không cấp phát
}

return new ValueTask<string>(FetchFromDbAsync(key)); // async — cấp phát
}
Hướng dẫn ValueTask
  • Sử dụng ValueTask<T> chỉ khi đường dẫn đồng bộ là trường hợp phổ biến (ví dụ: cache, đọc đã buffer).
  • Không await một ValueTask hai lần — nó không thể tái sử dụng như Task.
  • Không lưu ValueTask trong trường hoặc trả về từ property — chuyển sang Task trước bằng .AsTask().
  • Khi không chắc chắn, sử dụng Task<T> — chi phí cấp phát là tối thiểu cho hầu hết các kịch bản.

Async Streams (IAsyncEnumerable)

IEnumerable<T> cho phép bạn lặp một collection với foreach, kéo từng phần tử một lần. Nhưng mỗi phần tử được tạo đồng bộ — nếu lấy một phần tử yêu cầu I/O (ví dụ: đọc trang tiếp theo từ cơ sở dữ liệu), toàn bộ luồng bị chặn.

IAsyncEnumerable<T> giải quyết điều này bằng cách cho phép mỗi phần tử được tạo bất đồng bộ. Với await foreach, người tiêu dùng await mỗi phần tử — người sản xuất có thể thực hiện I/O async (đọc cơ sở dữ liệu, gọi API, đọc file) để tạo mỗi mục mà không chặn bất kỳ luồng nào.

Điều này hữu ích cho streaming tập dữ liệu lớn từ cơ sở dữ liệu, tiêu thụ feed sự kiện thời gian thực, hoặc phản hồi API phân trang nơi bạn không muốn tải tất cả dữ liệu vào bộ nhớ cùng lúc.

// Producer — tạo các phần tử bất đồng bộ
public async IAsyncEnumerable<int> GenerateNumbersAsync(
int count,
[EnumeratorCancellation] CancellationToken ct = default)
{
for (int i = 0; i < count; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(100, ct);
yield return i;
}
}

// Consumer — await foreach
public async Task ConsumeAsync()
{
await foreach (var number in GenerateNumbersAsync(10))
{
Console.WriteLine(number);
}
}

// Với cancellation
public async Task ConsumeWithCancellationAsync(CancellationToken ct)
{
await foreach (var number in GenerateNumbersAsync(10).WithCancellation(ct))
{
Console.WriteLine(number);
}
}

Channels

System.Threading.Channels cung cấp hàng đợi producer/consumer an toàn theo luồng, tương thích async. Hãy nghĩ Channel như một ốngpipe an toàn theo luồng: producer ghi message vào, consumer đọc message ra. Nhiều producer và consumer có thể hoạt động đồng thời mà không cần lock tường minh.

Có hai loại: bounded channel có dung lượng cố định và áp dụng áp lực ngược (Backpressure — producer chờ khi đầy), trong khi unbounded channel chấp nhận số lượng item không giới hạn. Channel nhẹ hơn BlockingCollection<T> và hoàn toàn async — chúng sử dụng ValueTask cho đọc/ghi, nên không có luồng nào bị chặn trong khi chờ.

Channel lý tưởng khi bạn cần tách biệt producer nhanh khỏi consumer chậm, xây dựng pipeline xử lý với nhiều giai đoạn, hoặc triển khai hàng đợi công việc nền trong bộ nhớ (In-memory Background Job Queues) trong ASP.NET Core.

using System.Threading.Channels;

// Tạo bounded channel (giới hạn dung lượng, áp lực ngược)
var channel = Channel.Bounded<string>(capacity: 100);

// Tạo unbounded channel (không giới hạn)
var channel = Channel.CreateUnbounded<string>();

// Producer
async Task ProduceAsync(ChannelWriter<string> writer, CancellationToken ct)
{
for (int i = 0; i < 1000; i++)
{
ct.ThrowIfCancellationRequested();
await writer.WriteAsync($"Item {i}", ct);
}
writer.Complete(); // báo hiệu không còn item
}

// Consumer
async Task ConsumeAsync(ChannelReader<string> reader, CancellationToken ct)
{
await foreach (var item in reader.ReadAllAsync(ct))
{
Console.WriteLine($"Consumed: {item}");
}
}

// Cách sử dụng
var channel = Channel.Bounded<string>(100);
var cts = new CancellationTokenSource();

var producer = ProduceAsync(channel.Writer, cts.Token);
var consumer = ConsumeAsync(channel.Reader, cts.Token);

await Task.WhenAll(producer, consumer);
Khi nào sử dụng Channels
  • Pattern Producer/consumer — tách biệt sản xuất dữ liệu khỏi tiêu thụ
  • Xử lý pipeline — chuỗi nhiều giai đoạn, mỗi giai đoạn đọc từ một channel và ghi sang channel khác
  • Giới hạn tốc độ (Rate Limiting) — bounded channel áp dụng áp lực ngược (Backpressure) cho producer nhanh
  • Hàng đợi công việc nền (Background Job Queues) — xếp hàng các mục công việc và xử lý chúng trên task riêng

TaskCompletionSource

TaskCompletionSource<T> là cách thủ công để tạo và kiểm soát một Task<T>. Bình thường, task được tạo bởi trình biên dịch (qua phương thức async) hoặc bởi Task.Run. Nhưng đôi khi bạn cần tạo Task từ một sự kiện, callback, timer, hoặc bất kỳ tín hiệu bên ngoài nào — đó là lúc TCS phát huy tác dụng.

Bạn tạo một TaskCompletionSource<T>, phơi bày thuộc tính Task của nó cho người gọi, và sau đó gọi TrySetResult, TrySetException, hoặc TrySetCanceled khi thao tác bên ngoài hoàn thành. Điều này kết nối các pattern async cũ (APM, EAP) hoặc bất kỳ API dựa trên callback nào vào thế giới async/await hiện đại.

// Bao bọc API dựa trên sự kiện thành Task
public Task<string> WaitForUserInputAsync()
{
var tcs = new TaskCompletionSource<string>();

_inputReceived += (sender, args) =>
{
tcs.TrySetResult(args.Text);
};

_inputCanceled += (sender, args) =>
{
tcs.TrySetCanceled();
};

return tcs.Task;
}

// Bao bọc EAP (Event-based Async Pattern) thành TAP
public Task<byte[]> DownloadDataAsync(Uri url)
{
var tcs = new TaskCompletionSource<byte[]>();
var webClient = new WebClient();

webClient.DownloadDataCompleted += (sender, e) =>
{
if (e.Error is not null)
tcs.TrySetException(e.Error);
else if (e.Cancelled)
tcs.TrySetCanceled();
else
tcs.TrySetResult(e.Result);
};

webClient.DownloadDataAsync(url);
return tcs.Task;
}
Sử dụng TrySetResult, TrySetException, TrySetCanceled (các biến thể Try) để tránh InvalidOperationException nếu task đã được hoàn thành bởi một nguồn khác.

Parallel.ForEachAsync

Parallel.ForEachAsync (.NET 6+) kết hợp lặp song song với hỗ trợ async và giới hạn đồng thời — thay thế pattern SemaphoreSlim + Task.WhenAll thủ công.

public async Task ProcessUrlsAsync(IEnumerable<string> urls)
{
await Parallel.ForEachAsync(
urls,
new ParallelOptions
{
MaxDegreeOfParallelism = 5,
CancellationToken = cancellationToken
},
async (url, ct) =>
{
var data = await httpClient.GetStringAsync(url, ct);
await ProcessDataAsync(data, ct);
});
}

Nguyên thủy Điều phối Async (Async Coordination Primitives)

SemaphoreSlim — Giới hạn tốc độ (Rate Limiting)

public class RateLimitedService
{
private readonly SemaphoreSlim _semaphore = new(3); // tối đa 3 đồng thời

public async Task<string> FetchAsync(string url)
{
await _semaphore.WaitAsync();
try
{
return await httpClient.GetStringAsync(url);
}
finally
{
_semaphore.Release();
}
}
}

AsyncLock (Tùy chỉnh)

// C# không có async lock tích hợp — triển khai với SemaphoreSlim(1, 1)
public class AsyncLock
{
private readonly SemaphoreSlim _semaphore = new(1, 1);

public async Task<IDisposable> LockAsync(CancellationToken ct = default)
{
await _semaphore.WaitAsync(ct);
return new Releaser(_semaphore);
}

private sealed class Releaser(SemaphoreSlim semaphore) : IDisposable
{
public void Dispose() => semaphore.Release();
}
}

// Cách sử dụng
public class Cache
{
private readonly AsyncLock _lock = new();
private readonly Dictionary<string, string> _data = new();

public async Task<string> GetOrAddAsync(string key, Func<Task<string>> factory)
{
using (await _lock.LockAsync())
{
if (_data.TryGetValue(key, out var value))
return value;

value = await factory();
_data[key] = value;
return value;
}
}
}

Task nền (Background Tasks)

BackgroundService

BackgroundService là cách được khuyến nghị để chạy công việc async chạy lâu trong ASP.NET Core.

public class EmailQueueProcessor : BackgroundService
{
private readonly Channel<EmailMessage> _channel;
private readonly ILogger<EmailQueueProcessor> _logger;

public EmailQueueProcessor(Channel<EmailMessage> channel, ILogger<EmailQueueProcessor> logger)
{
_channel = channel;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var email in _channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await SendEmailAsync(email, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {Recipient}", email.To);
}
}
}
}

// Đăng ký trong Program.cs
builder.Services.AddSingleton(Channel.CreateUnbounded<EmailMessage>());
builder.Services.AddHostedService<EmailQueueProcessor>();

IHostedService

Đối với logic async khởi tạo/tắt máy một lần, sử dụng IHostedService trực tiếp.

public class DatabaseMigrationService : IHostedService
{
private readonly IServiceProvider _services;

public DatabaseMigrationService(IServiceProvider services) => _services = services;

public async Task StartAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync(ct);
}

public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

Task nền định kỳ (Periodic Background Task)

public class CacheRefreshService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly TimeSpan _interval = TimeSpan.FromMinutes(5);

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);

await RefreshCacheAsync(stoppingToken); // chạy ngay lập tức một lần

while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RefreshCacheAsync(stoppingToken);
}
}
}

Async trong ASP.NET Core

Middleware Async

app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
await next(context);
stopwatch.Stop();
logger.LogInformation("Request took {Ms}ms", stopwatch.ElapsedMilliseconds);
});

Minimal API Endpoint Async

app.MapGet("/api/users", async (IUserService userService, CancellationToken ct) =>
{
var users = await userService.GetAllAsync(ct);
return Results.Ok(users);
});

app.MapPost("/api/users", async (CreateUserRequest request, IUserService userService, CancellationToken ct) =>
{
var user = await userService.CreateAsync(request, ct);
return Results.Created($"/api/users/{user.Id}", user);
});

Filter Async

public class ValidationFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (!context.ModelState.IsValid)
{
context.Result = new BadRequestObjectResult(context.ModelState);
return;
}

await next();
}
}

Async trong EF Core

// Truy vấn async
var users = await dbContext.Users
.Where(u => u.IsActive)
.OrderBy(u => u.Name)
.ToListAsync(cancellationToken);

// Lưu async
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync(cancellationToken);

// Transaction async
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
await dbContext.Products.AddAsync(product, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}

// Thực thi raw SQL async
var affected = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE Products SET Price = Price * 1.1 WHERE CategoryId = {0}", categoryId);

Thử lại và Khả năng phục hồi (Retry and Resilience)

// Thử lại thủ công với exponential backoff
public async Task<string> FetchWithRetryAsync(string url, int maxRetries = 3)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
return await httpClient.GetStringAsync(url);
}
catch (HttpRequestException) when (attempt < maxRetries)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 1s, 2s, 4s
await Task.Delay(delay);
}
}

throw new HttpRequestException($"Failed after {maxRetries} retries");
}

// Sử dụng Polly
var retryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TaskCanceledException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));

var result = await retryPolicy.ExecuteAsync(async () =>
{
var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
});

AsyncLocal<T>

AsyncLocal<T> lưu trữ một giá trị tự động truyền qua ranh giới async. Khi bạn đặt một giá trị trong phương thức async, tất cả các lệnh gọi await downstream và task con có thể đọc cùng giá trị đó — mặc dù chúng có thể chạy trên các luồng khác nhau.

Điều này khác với ThreadLocal<T>, được gắn với một luồng OS cụ thể. Vì continuation async có thể nhảy giữa các luồng, ThreadLocal không hoạt động qua ranh giới await. AsyncLocal giải quyết điều này bằng cách lưu giá trị trong ExecutionContext, truyền theo thao tác async bất kể luồng nào chạy nó.

Các trường hợp sử dụng phổ biến: truyền correlation ID qua pipeline request, truyền ngữ cảnh tenant mà không cần tham số phương thức, hoặc lan truyền activity/tracing ID cho quan sát (Observability).

// Lưu correlation ID truyền qua các lệnh gọi async
public static class CorrelationContext
{
private static readonly AsyncLocal<string> _correlationId = new();

public static string Current
{
get => _correlationId.Value ?? string.Empty;
set => _correlationId.Value = value;
}
}

// Middleware đặt nó
app.Use(async (context, next) =>
{
CorrelationContext.Current = context.TraceIdentifier;
await next(context);
});

// Bất kỳ lệnh gọi async downstream nào cũng có thể đọc nó
public async Task ProcessOrderAsync(Order order)
{
var correlationId = CorrelationContext.Current; // truyền tự động
logger.LogInformation("Processing order {Id} with correlation {CorrelationId}",
order.Id, correlationId);
}

Khi nào sử dụng

  • Thao tác I/O-bound — lệnh gọi mạng, I/O file, truy vấn cơ sở dữ liệu — luôn sử dụng async
  • Ứng dụng UI — giữ luồng UI phản hồi bằng cách chuyển tải công việc với async
  • Web API — controller/service async mở rộng tốt hơn dưới tải
  • Thao tác đồng thời — khi bạn cần chạy nhiều thao tác độc lập đồng thời với Task.WhenAll
  • Xử lý nềnBackgroundService cho công việc async chạy lâu trong ASP.NET Core
  • Producer/consumerChannels cho pipeline dữ liệu async tách biệt
  • Tránh cho tính toán CPU-bound trên server — Task.Run chuyển tải sang thread pool nhưng không giảm tổng công việc; chỉ sử dụng ở phía client để giữ UI phản hồi

Lỗi thường gặp (Common Pitfalls)

Async Void

// Sai — async void: ngoại lệ làm crash process, không thể await
public async void SaveAsync()
{
await _db.SaveChangesAsync(); // ngoại lệ → không được quan sát, crash ứng dụng
}

// Đúng — async Task: ngoại lệ lan truyền đến người gọi
public async Task SaveAsync()
{
await _db.SaveChangesAsync();
}

// Ngoại lệ: event handler là trường hợp sử dụng HỢP LỆ DUY NHẤT của async void
button.Click += async (sender, e) =>
{
await LoadDataAsync(); // async void là không thể tránh ở đây
};

Chặn trên code Async (Blocking on Async Code)

// Sai — gây deadlock trong ASP.NET (non-Core) và ứng dụng UI
public string GetData()
{
return GetDataAsync().Result; // chặn luồng
}

public string GetData()
{
return GetDataAsync().GetAwaiter().GetResult(); // cũng chặn
}

// Đúng — lan truyền async lên toàn bộ
public async Task<string> GetDataAsync()
{
return await GetDataAsyncCore();
}
Kịch bản Deadlock

Trong các ứng dụng có SynchronizationContext (ASP.NET non-Core, WPF, WinForms), gọi .Result hoặc .Wait() trên phương thức async gây deadlock: phương thức async await và cố gắng tiếp tục trên ngữ cảnh đã capture, nhưng luồng ngữ cảnh đang bị chặn chờ kết quả. ASP.NET Core tránh điều này vì nó không có SynchronizationContext.

Thiếu Await (Missing Await)

// Sai — fire-and-forget, ngoại lệ bị nuốt âm thầm
public Task ProcessAsync()
{
_ = SendEmailAsync(); // không được await, lỗi bị mất
return SaveAsync();
}

// Đúng — await tất cả task trừ khi bạn cố ý muốn fire-and-forget
public async Task ProcessAsync()
{
await SendEmailAsync();
await SaveAsync();
}

// Fire-and-forget có chủ đích (công nhận bằng comment)
public async Task ProcessAsync()
{
_ = SendEmailAsync(); // fire-and-forget: lỗi email không ảnh hưởng luồng chính
await SaveAsync();
}

Truy cập Result trước khi hoàn thành

// Sai — truy cập .Result trên Task chưa hoàn thành sẽ chặn
public async Task<int> GetValueAsync()
{
var task = FetchAsync();
return task.Result; // chặn cho đến khi task hoàn thành (phá vỡ mục đích)
}

// Đúng — await task
public async Task<int> GetValueAsync()
{
return await FetchAsync();
}

Trộn Sync và Async

// Sai — phương thức đồng bộ gọi async với .Result
public List<User> GetUsers()
{
return _dbContext.Users.ToListAsync().Result; // chặn
}

// Đúng — làm cho phương thức async
public async Task<List<User>> GetUsersAsync()
{
return await _dbContext.Users.ToListAsync();
}

Điểm chính (Key Takeaways)

  • async/await chuyển đổi phương thức thành state machine giải phóng luồng trong khi chờ I/O.
  • Async I/O-bound không sử dụng luồng trong khi chờ — OS xử lý I/O qua completion port, và luồng thread pool nhận kết quả khi xong.
  • Async CPU-bound (Task.Run) vẫn sử dụng một luồng — chỉ sử dụng để chuyển tải từ luồng UI, không phải để "làm mọi thứ nhanh hơn" trên server.
  • SynchronizationContext xác định nơi continuation chạy — ASP.NET Core không có, ứng dụng UI có một. Đây là lý do tại sao ConfigureAwait(false) quan trọng trong thư viện.
  • Luôn trả về Task hoặc Task<T> từ phương thức async — không bao giờ async void (trừ event handler).
  • Sử dụng CancellationToken cho hủy bỏ hợp tác và timeout.
  • Sử dụng ConfigureAwait(false) trong code thư viện để tránh capture ngữ cảnh không cần thiết.
  • Sử dụng Task.WhenAll cho thực thi đồng thời và Task.WhenAny cho đua (Racing).
  • Không bao giờ chặn trên code async với .Result hoặc .Wait() — lan truyền async lên toàn bộ.
  • Sử dụng ValueTask<T> khi đường dẫn đồng bộ là trường hợp phổ biến.
  • Sử dụng IAsyncEnumerable<T> cho streaming dữ liệu và Channels cho pattern producer/consumer.
  • Sử dụng TaskCompletionSource<T> để kết nối API callback/sự kiện vào thế giới async.
  • Sử dụng Parallel.ForEachAsync cho xử lý đồng thời với độ song song có giới hạn.
  • Sử dụng BackgroundService cho task nền async chạy lâu trong ASP.NET Core.
  • Áp dụng thử lại với exponential backoff (Polly hoặc thủ công) cho lỗi tạm thời (Transient Failures).

Câu hỏi phỏng vấn (Interview Questions)

Q: Sự khác biệt giữa TaskThread là gì? Thread là cấu trúc cấp OS biểu diễn một luồng thực thi (Thread of Execution). Task biểu diễn một thao tác bất đồng bộ có thể hoặc không sử dụng luồng. Đối với công việc I/O-bound, một Task hoàn thành mà không giữ bất kỳ luồng nào. async/await hoạt động với Task, không phải Thread.

Q: Điều gì xảy ra khi bạn await một Task? Thực thi phương thức bị tạm dừng, và quyền điều khiển trả về cho người gọi. Luồng tự do thực hiện công việc khác. Khi Task được await hoàn thành, state machine tiếp tục thực thi sau điểm await.

Q: Tại sao nên tránh async void? Phương thức async void không thể await, và ngoại lệ không xử lý crash process thay vì lan truyền đến người gọi. Chúng cũng làm cho kiểm thử và kết hợp không thể thực hiện. Trường hợp sử dụng hợp lệ duy nhất là event handler.

Q: ConfigureAwait(false) là gì và khi nào nên sử dụng? Nó nói với awaiter không capture SynchronizationContext hiện tại và cho phép continuation chạy trên bất kỳ luồng thread pool nào. Sử dụng trong code thư viện để tránh deadlock và cải thiện hiệu suất. Không sử dụng trong code UI khi bạn cần cập nhật phần tử UI sau await.

Q: Cách xử lý ngoại lệ từ Task.WhenAll? Task.WhenAll ném ngoại lệ đầu tiên và phần còn lại được bắt trong thuộc tính Exception của task trả về dưới dạng AggregateException. Để quan sát tất cả ngoại lệ, truy cập task.Exception.InnerExceptions sau khi catch.

Q: Sự khác biệt giữa Task.DelayThread.Sleep là gì? Thread.Sleep chặn luồng hiện tại trong khoảng thời gian chỉ định. Task.Delay trả về một Task hoàn thành sau khoảng thời gian mà không chặn bất kỳ luồng nào — nó sử dụng timer bên trong. Luôn ưu tiên Task.Delay trong code async.

Q: Async I/O hoạt động mà không cần luồng như thế nào? Khi bạn gọi phương thức I/O async, kernel của OS khởi tạo thao tác và trả về ngay lập tức. Luồng gọi được giải phóng. Khi phần cứng hoàn thành I/O, nó báo hiệu cho OS qua hardware interrupt. OS thông báo cho thread pool qua I/O Completion Port (IOCP), và luồng thread pool tiếp tục state machine. Không có luồng nào bị chặn trong thời gian chờ.

Q: SynchronizationContext là gì và tại sao nó quan trọng? Đó là một trừu tượng xác định nơi continuation async chạy sau await. Framework UI (WPF, WinForms) sử dụng nó để đảm bảo continuation chạy trên luồng UI. Legacy ASP.NET sử dụng nó để đảm bảo continuation chạy trên luồng request. ASP.NET Core không có, nên continuation chạy trên bất kỳ luồng thread pool nào. Hiểu điều này là chìa khóa để tránh deadlock.

Q: TaskCompletionSource<T> là gì và khi nào sử dụng? Đó là producer thủ công cho Task<T>. Bạn sử dụng nó để kết nối các pattern async không dựa trên Task (sự kiện, callback, API cũ) vào thế giới async/await. Bạn gọi TrySetResult, TrySetException, hoặc TrySetCanceled để hoàn thành task khi thao tác nền tảng kết thúc.

Q: Parallel.ForEachAsync khác gì với Task.WhenAll? Task.WhenAll chạy tất cả task đồng thời mà không có giới hạn đồng thời. Parallel.ForEachAsync xử lý các mục với MaxDegreeOfParallelism có thể cấu hình, phù hợp hơn cho các thao tác I/O bị điều tiết (Throttled) nơi bạn muốn giới hạn request đồng thời.

Q: BackgroundService là gì và khi nào sử dụng? Đó là một base class trong .NET để triển khai IHostedService với vòng lặp async chạy lâu. Override ExecuteAsync(CancellationToken) để chạy công việc nền liên tục như xử lý hàng đợi, làm mới cache, hoặc lắng nghe sự kiện. Đăng ký với services.AddHostedService<T>().

Q: AsyncLocal<T> là gì và khác gì với ThreadLocal<T>? AsyncLocal<T> lưu dữ liệu truyền qua ngữ cảnh lệnh gọi async — task con kế thừa giá trị và thay đổi không truyền ngược lại cho parent. ThreadLocal<T> là theo từng luồng và không truyền qua ranh giới async vì continuation async có thể chạy trên các luồng khác nhau.

Q: Ba mô hình lập trình async trong .NET là gì? APM (.NET 1.x) sử dụng BeginXxx/EndXxx với callback — dễ dẫn đến địa ngục callback. EAP (.NET 2.0) sử dụng sự kiện — khó kết hợp. TAP (.NET 4.0+) sử dụng Task + async/await — luồng code tuyến tính, xử lý lỗi tự nhiên, hủy bỏ tích hợp. TAP là pattern duy nhất được sử dụng trong .NET hiện đại.

Tài liệu tham khảo (References)