Bài viết này được trình bày như một phần của C# Advent 2025. Theo dõi liên kết để xem toàn bộ nội dung C# và .NET tuyệt vời khác được đăng tải với 2 bài mỗi ngày từ ngày 1 đến ngày 25 tháng 12.
Bất đồng bộ (asynchrony) là một trong những khái niệm quan trọng nhất trong phát triển phần mềm hiện đại, được sử dụng rộng rãi cho mọi thứ từ các thao tác I/O-bound đến giữ cho giao diện người dùng luôn phản hồi nhanh.
Trong bài viết này, chúng ta sẽ cùng tìm hiểu cách mã bất đồng bộ hoạt động trong C#, cách Task và ValueTask tác động đến hiệu suất, điều gì xảy ra bên trong các state machine bất đồng bộ, và các mẹo thực tế để viết mã bất đồng bộ hiệu quả.
Mục lục
Giới thiệu về Tasks
.NET và C# có một lịch sử lâu dài với các mô hình lập trình bất đồng bộ. Nó bắt đầu với việc sử dụng các cấu trúc như Thread và BeginInvoke / EndInvoke. Sau đó, Thư viện Song song Tác vụ (TPL) được giới thiệu, cung cấp một tập hợp các kiểu hữu ích để làm việc với bất đồng bộ, bao gồm Task. Cuối cùng, C# 5 đã thêm hỗ trợ gốc cho các từ khóa async / await, biến chúng thành các tính năng hạng nhất cho lập trình bất đồng bộ và truyền cảm hứng cho các triển khai tương tự trong các ngôn ngữ khác.
Một Task về cơ bản đại diện cho một đơn vị công việc sẽ hoàn thành tại một thời điểm nào đó. Nó không xác định thời điểm hoàn thành hay kết quả sẽ là gì. Thông tin này chỉ có sẵn khi công việc được hoàn thành. Ví dụ, một Task có thể bao bọc một yêu cầu mạng, thao tác tệp tin, hoặc tính toán CPU-intensive.
Khi bạn nhận được một đối tượng Task, nó có thể đã hoàn thành đồng bộ (ví dụ: nếu kết quả được lưu trong bộ nhớ đệm), bất đồng bộ (ví dụ: nếu một thao tác I/O hoàn thành nhanh chóng), hoặc nó vẫn đang chạy. Trong trường hợp sau, có hai cách phổ biến để xử lý kết quả: chờ đồng bộ (blocking) hoặc cung cấp một callback thực thi khi tác vụ hoàn thành. Vẻ đẹp của async / await là tất cả các trường hợp này đều được xử lý nhất quán, mà không cần viết mã phức tạp hoặc lặp đi lặp lại khi làm việc với logic bất đồng bộ:
public string GetContent(string fileName)
{
string content = File.ReadAllText(fileName);
return content;
}
public async Task<string> GetContentAsync(string fileName)
{
string content = await File.ReadAllTextAsync(fileName);
return content;
}
Async/Await Hoạt động Như Thế Nào Dưới Nắp Máy
Vậy, Task đại diện cho một phần công việc có thể hoàn thành đồng bộ hoặc bất đồng bộ, và async / await cho phép chúng ta xử lý cả hai trường hợp một cách thống nhất. Nhưng điều này thực sự hoạt động như thế nào? Await một Task có nghĩa là gì? Và điều gì xảy ra nếu Task chưa hoàn thành khi nó được await?
Có một “phép thuật” thực sự xảy ra khi một phương thức được đánh dấu bằng từ khóa async. Dưới nắp máy, trình biên dịch C# biến đổi một phương thức như vậy thành thứ gọi là async state machine. Ví dụ, hãy xem xét phương thức này:
public async Task<string> MergeAsync(string file1Name, string file2Name)
{
Console.WriteLine("Starting Merge");
string file1Content = await File.ReadAllTextAsync(file1Name);
Console.WriteLine("Finished Reading File 1");
string file2Content = await File.ReadAllTextAsync(file2Name);
Console.WriteLine("Finished Reading File 2");
return file1Content + file2Content;
}
Sau khi decompile assembly .NET được tạo ra, chúng ta có thể kiểm tra mã do trình biên dịch tạo ra (với tên được đơn giản hóa cho dễ đọc). Nó bao gồm hai phần chính. Phần đầu tiên là chính phương thức, trông như thế này:
[AsyncStateMachine(typeof (Test.MergeAsyncStateMachine))]
public Task<string> MergeAsync(string file1Name, string file2Name)
{
Test.MergeAsyncStateMachine stateMachine;
stateMachine.methodBuilder = AsyncTaskMethodBuilder<string>.Create();
stateMachine.file1Name = file1Name;
stateMachine.file2Name = file2Name;
stateMachine.state = -1;
stateMachine.methodBuilder.Start<Test.MergeAsyncStateMachine>(ref stateMachine);
return stateMachine.methodBuilder.Task;
}
Và phần thứ hai, là state machine:
[CompilerGenerated]
private struct MergeAsyncStateMachine : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder<string> methodBuilder;
public string file1Name;
public string file2Name;
private string _file1Content;
private TaskAwaiter<string> _awaiter;
void IAsyncStateMachine.MoveNext()
{
string result;
try
{
TaskAwaiter<string> awaiter;
if (this.state != 0)
{
if (this.state != 1)
{
Console.WriteLine("Starting Merge");
awaiter = File.ReadAllTextAsync(this.file1Name, new CancellationToken()).GetAwaiter();
if (!awaiter.IsCompleted)
{
this.state = 0;
this._awaiter = awaiter;
this.methodBuilder.AwaitUnsafeOnCompleted<TaskAwaiter<string>, Test.MergeAsyncStateMachine>(ref awaiter, ref this);
return;
}
}
else
{
awaiter = this._awaiter;
this._awaiter = new TaskAwaiter<string>();
this.state = -1;
goto label_9;
}
}
else
{
awaiter = this._awaiter;
this._awaiter = new TaskAwaiter<string>();
this.state = -1;
}
this._file1Content = awaiter.GetResult();
Console.WriteLine("Finished Reading File 1");
awaiter = File.ReadAllTextAsync(this.file2Name, new CancellationToken()).GetAwaiter();
if (!awaiter.IsCompleted)
{
this.state = 1;
this._awaiter = awaiter;
this.methodBuilder.AwaitUnsafeOnCompleted<TaskAwaiter<string>, Test.MergeAsyncStateMachine>(ref awaiter, ref this);
return;
}
label_9:
string file2Content = awaiter.GetResult();
Console.WriteLine("Finished Reading File 2");
result = string.Concat(this._file1Content, file2Content);
}
catch (Exception ex)
{
this.state = -2;
this._file1Content = (string) null;
this.methodBuilder.SetException(ex);
return;
}
this.state = -2;
this._file1Content = (string) null;
this.methodBuilder.SetResult(result);
}
}
Nhìn qua, điều này có vẻ đáng sợ, nhưng thực ra nó khá đơn giản. Phương thức async gốc bây giờ tạo và khởi tạo state machine được tạo ra, sau đó bắt đầu thực thi nó. State machine định nghĩa một phương thức MoveNext, chứa logic của phương thức async gốc. Dựa vào các log console, chúng ta có thể thấy logic được chia thành các nhánh khác nhau bên trong các câu lệnh if. Nhưng điều đó có nghĩa là gì? Tại sao chúng ta cần những nhánh đó, và state đang được kiểm tra là gì?
Cấu trúc này là kết quả của việc sử dụng await bên trong phương thức. Phương thức gốc có hai biểu thức await, và đây là những điểm mà Task đang được await (được trả về từ ReadAllTextAsync) có thể vẫn đang chạy. Nhìn vào state machine, chúng ta có thể thấy rằng state ban đầu được đặt thành -1. Lần đầu tiên MoveNext thực thi, nó chạy phần logic trước await đầu tiên. Khi đến await đầu tiên, state machine gọi AwaitUnsafeOnCompleted, điều này nói với runtime: “khi tác vụ này hoàn thành, hãy gọi MoveNext một lần nữa.” State sau đó được cập nhật thành 0, để trong lần gọi tiếp theo, việc thực thi tiếp tục từ nhánh tiếp theo (giữa await thứ nhất và thứ hai). Cuối cùng, cả hai tệp được đọc bất đồng bộ và kết quả được hợp nhất thành một chuỗi.
Hãy chú ý đến kiểu của state machine được tạo ra: trong bản Debug, nó là một class, và trong bản Release, nó là một struct. Sự khác biệt này quan trọng vì trong chế độ Debug, nó có thể gây ra thêm các cấp phát heap. Ngoài ra, vì mỗi phương thức async tạo ra state machine riêng của nó, điều này giải thích tại sao một số cấu trúc nhất định (như tham số out, biến cục bộ ref, hoặc ref struct) không được phép bên trong các phương thức async.
Đây rõ ràng là một ví dụ đã được đơn giản hóa. State machine trở nên thú vị hơn nhiều (và phức tạp hơn) khi các phương thức async chứa vòng lặp, điều kiện hoặc câu lệnh switch.
Benchmark: Các Phương thức Async
Bây giờ chúng ta đã hiểu rõ hơn về những gì xảy ra bên trong với các phương thức async, hãy chạy một benchmark và so sánh các phiên bản khác nhau của một phương thức async đơn giản bằng BenchmarkDotNet:
public static Task<int> TestFromResult()
{
return Task.FromResult(42);
}
public static async Task<int> TestSync()
{
return 42;
}
public static async Task<int> TestAsyncOnce()
{
await Task.Yield();
return 42;
}
public static async Task<int> TestAsyncTwice()
{
await Task.Yield();
await Task.Yield();
return 42;
}
public static async Task<int> TestAsyncThrice()
{
await Task.Yield();
await Task.Yield();
await Task.Yield();
return 42;
}
Sự khác biệt giữa các phương thức này nằm ở cách chúng hoàn thành:
TestFromResulttrả về một task đã hoàn thành trực tiếp, bỏ qua hoàn toàn state machine.TestSynctạo ra một state machine (vì nó được đánh dấuasync) nhưng không sử dụng bất kỳ tính năng bất đồng bộ nào—nó vẫn trả về đồng bộ.- Các phương thức
TestAsyncOnce,TestAsyncTwice,TestAsyncThricethực sự await một task khác, được trả về bởiTask.Yield, một phương thức trợ giúp trả về bất đồng bộ cho ngữ cảnh hiện tại khi được await.
(Chạy trên hệ thống Linux 64 bit)
Sự khác biệt về hiệu suất giữa TestFromResult và TestSync là không đáng kể, nhưng đáng chú ý là TestSync liên quan đến việc tạo một state machine được tạo ra và chi phí cấp phát nó (trên stack) và gọi phương thức MoveNext một lần. 72 byte cấp phát được báo cáo chủ yếu đến từ chính thể hiện Task, đây là một kiểu tham chiếu (reference type).
Các phương thức TestAsync* thú vị hơn. Chúng cho thấy chi phí hiệu suất đáng kể và thêm 24 byte cấp phát. Điều này xảy ra vì phương thức bất đồng bộ thực sự yêu cầu state machine (một struct trong bản release) phải được đóng hộp (boxed). Việc đóng hộp này chỉ xảy ra một lần và các lần gọi await tiếp theo không gây ra thêm cấp phát. Tuy nhiên, hiệu suất tiếp tục giảm nhẹ vì mỗi await giới thiệu thêm logic state machine và các lần gọi MoveNext.
Ở cuối, có ba benchmark nữa, những cái có tên chứa ValueTask. Chúng cho thấy không có cấp phát nào trong các trường hợp không có công việc bất đồng bộ thực sự. Làm thế nào điều này có thể xảy ra, và chính xác thì ValueTask là gì?
Task vs ValueTask
Nhìn vào các benchmark, chúng ta có thể thấy rằng Task, là một kiểu tham chiếu, phải được cấp phát trước khi được trả về từ một phương thức, dù nó hoàn thành đồng bộ hay bất đồng bộ. Đây là một sự đánh đổi cần thiết khiến Task trở thành một abstraction rất linh hoạt. Ví dụ, một thể hiện Task được trả về từ một phương thức có thể được await nhiều lần, được chia sẻ giữa nhiều consumer đồng thời, hoặc được lưu vào bộ nhớ đệm để sử dụng lại sau này.
Tuy nhiên, trong nhiều kịch bản phổ biến, sự linh hoạt này không cần thiết. Thông thường, một Task được tạo, trả về và ngay lập tức được await. Trong những trường hợp như vậy, chi phí cấp phát vẫn tồn tại, nhưng chức năng bổ sung không được sử dụng. Đây chính xác là lý do tại sao ValueTask được giới thiệu trong .NET.
Lợi ích chính của ValueTask là nó là một struct. Điều này có nghĩa là nếu phương thức hoàn thành đồng bộ, không cần cấp phát heap nào, state machine và chính ValueTask vẫn là các kiểu giá trị. Khi thao tác thực sự bất đồng bộ, việc cấp phát vẫn xảy ra, nhưng các đối tượng đại diện cho trạng thái bất đồng bộ có thể được gộp và tái sử dụng. Việc tái sử dụng này được kích hoạt thông qua giao diện IValueTaskSource, đây là cơ chế nền tảng cho ValueTask.
Cuối cùng, ValueTask không phải là sự thay thế trực tiếp cho Task. Nó đi kèm với những sự đánh đổi riêng và quyết định giữa hai loại này phụ thuộc vào trường hợp sử dụng cụ thể. Hãy ghi nhớ những cân nhắc này khi làm việc với ValueTask:
- Nó không thể được await nhiều lần.
- Nó không thể được lưu vào bộ nhớ đệm hoặc lưu trữ để truy cập kết quả sau này.
- Nó không hỗ trợ truy cập đồng thời.
- Nó không thể được sử dụng để chặn chờ kết quả (
.GetAwaiter().GetResult()).
Một số ví dụ từ Thư viện Cơ sở (BCL) sử dụng ValueTask bao gồm: Stream, IAsyncDisposable, PipeReader, PipeWriter, IAsyncEnumerator<T>, Socket.
Lưu Trữ Kết Quả Task
.NET runtime duy trì một bộ nhớ đệm nội bộ nhỏ của các thể hiện Task<T> cho một số kiểu nhất định, cụ thể là bool và int.
Đối với bool, việc lưu trữ đệm là hoàn toàn hợp lý: chỉ có hai giá trị có thể có (true và false), vì vậy việc cấp phát một Task<bool> mới mỗi lần sẽ rất lãng phí. Đối với int, việc lưu trữ đệm tất cả các giá trị có thể là điều không thực tế, nhưng một tập hợp nhỏ các số nguyên thường được sử dụng vẫn được lưu trữ đệm vì Task<int> được sử dụng thường xuyên. Sự đánh đổi này mang lại lợi ích về hiệu suất trong các kịch bản điển hình.
Bộ nhớ đệm này được quản lý nội bộ bởi runtime thông qua lớp TaskCache và được cố tình giữ nhỏ. Trong các kịch bản thông lượng cao, các nhà phát triển cũng có thể triển khai các chiến lược lưu trữ đệm tương tự trực tiếp trong mã của riêng họ để giảm cấp phát.
Custom Task-like Types
Cơ chế async/await trong C# rất linh hoạt và không giới hạn ở Task hay ValueTask. Nó dựa trên một hình thức duck typing—một tập hợp các quy tắc được định nghĩa sẵn—để xác định liệu một kiểu có thể được await hay không.
Để làm cho một kiểu tùy chỉnh có thể await được với từ khóa await, nó phải định nghĩa một phương thức GetAwaiter() công khai. Kiểu được trả về bởi GetAwaiter() phải:
- Triển khai giao diện
INotifyCompletion - Định nghĩa một thuộc tính thể hiện công khai
IsCompletedkiểubool - Định nghĩa một phương thức thể hiện công khai
GetResult()và trả về kết quả được await (hoặcvoid)
Các kiểu tích hợp sẵn như Task và ValueTask đã tuân theo mẫu này, đó là lý do tại sao chúng có thể được await. Ngoài ra, C# cho phép các kiểu tùy chỉnh được sử dụng làm kiểu trả về cho các phương thức async, miễn là chúng tuân thủ các quy tắc này.
Tính mở rộng này cho phép các nhà phát triển tạo ra các kiểu và thư viện bất đồng bộ hoàn toàn tùy chỉnh và tích hợp liền mạch với cú pháp async/await. Các hệ thống tùy chỉnh như vậy đặc biệt hữu ích trong các kịch bản hiệu suất cao hoặc theo lĩnh vực cụ thể, nơi các kiểu tích hợp sẵn có thể không đáp ứng đầy đủ các yêu cầu.
Các Kịch Bản Tối Ưu Hóa Phổ Biến
Với tất cả những điều này, đây là một số cách phổ biến để làm cho mã bất đồng bộ hiệu quả hơn:
- Sử dụng các phương thức trợ giúp như
Task.WhenAll,Task.WhenAny,Task.WhenEachkhi xử lý nhiều tác vụ chạy đồng thời - Tránh các lời gọi blocking (
.Result,.GetResult(),.GetAwaiter().GetResult()) trên các task khi có thể, thay vào đó hãy cân nhắc sửa đổi caller để sử dụngawait - Lưu trữ đệm các thể hiện đã hoàn thành của
Task<T>khi kết quả dự kiến thường đồng bộ - Gộp nhóm công việc trên các hot path thay vì await các task nhỏ
- Cân nhắc sử dụng
ValueTaskở những nơi phù hợp (dựa trên các hướng dẫn ở trên) - Cân nhắc sử dụng
IAsyncEnumerable<T>thay vìIEnumerable<T>khi xử lý streaming hoặc các trường hợp dữ liệu đến dần dần, không phải cùng một lúc - Truyền và sử dụng
CancellationToken, đặc biệt trong các phương thức có thể mất nhiều thời gian để hoàn thành
Tổng Kết
Mô hình lập trình bất đồng bộ trong C# vừa mạnh mẽ vừa linh hoạt. Từ các kiểu cốt lõi Task và ValueTask cho đến các kiểu tùy chỉnh hoàn toàn có thể await, ngôn ngữ này cung cấp cho các nhà phát triển các công cụ để viết mã hiệu quả và phản hồi nhanh. Hiểu được các sắc thái như lưu trữ đệm kết quả task, khi nào sử dụng ValueTask và các quy tắc cho các kiểu tùy chỉnh giống task có thể giúp tránh cấp phát không cần thiết và cải thiện hiệu suất trong các kịch bản thông lượng cao.
Bằng cách áp dụng các chiến lược tối ưu hóa phổ biến như gộp nhóm công việc, lưu trữ đệm các kết quả thường xuyên, tận dụng Task.WhenAll và các trợ giúp liên quan, sử dụng IAsyncEnumerable<T> cho dữ liệu streaming và xử lý CancellationToken đúng cách, chúng ta có thể viết mã bất đồng bộ vừa sạch sẽ vừa có hiệu suất cao.
Cuối cùng, async/await trong C# không chỉ đơn thuần là làm cho mã trở nên bất đồng bộ. Đó là viết mã có khả năng mở rộng hiệu quả, tích hợp liền mạch và vẫn dễ hiểu, ngay cả trong các hệ thống phức tạp, hiệu suất cao.
Mã của bạn đã được tinh chỉnh async chưa? Liên hệ Trailhead để chúng tôi có thể giúp bạn làm cho mã của bạn hoạt động tốt hơn bằng cách tận dụng tối đa mã bất đồng bộ.
Liên Kết Tham Khảo
Để đọc thêm, dưới đây là một số tài nguyên tuyệt vời:
- https://devblogs.microsoft.com/dotnet/how-async-await-really-works/
- https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types
- https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/
Bài viết được dịch và biên tập từ bài gốc: “.NET Performance: Efficient Async Code” trên Trailhead Technology Partners.



