1. Giới thiệu
Trước khi nhảy vào code, hãy làm rõ bằng lời đơn giản. Hãy nhớ rằng việc tạo một object trong .NET (chứ đừng nói đến một task!) tốn bộ nhớ và một ít thời gian CPU. Bây giờ tưởng tượng một API bất đồng bộ mà trong một nửa trường hợp trả về kết quả ngay lập tức (ví dụ lấy từ cache), còn nửa kia thì truy cập database, nên thao tác thực sự trở thành bất đồng bộ và cần Task. Tình huống này gặp rất thường — ví dụ ở cache file, API mạng, pool đối tượng và những kịch bản "bất đồng bộ nhưng đôi khi ngay lập tức".
Nếu chúng ta luôn trả Task, ngay cả với kết quả ngay lập tức thì phải tạo các object thừa. Còn nếu có thể trả kết quả mà không cần task khi nó đã sẵn sàng? Đó là lý do ValueTask ra đời.
Sự thật: các Task chuẩn như Task.CompletedTask và Task.FromResult(…) thực sự tiết kiệm thời gian thực thi, nhưng tạo ra object chung mà có thể không tối ưu cho các kịch bản tải cao.
ValueTask là gì
ValueTask là một struct wrapper đặc biệt, có thể đại diện cho hoặc một kết quả đã sẵn sàng, hoặc (nếu thao tác thực sự bất đồng bộ) chính Task. Nói gọn: nó là "gói" có thể chứa hoặc giá trị đơn thuần, hoặc tham chiếu đến Task.
Có hai dạng chính:
- ValueTask — "vô giá trị" (không trả về giá trị, giống Task)
- ValueTask<TResult> — wrapper cho giá trị (tương tự Task<TResult>)
So sánh Task và ValueTask
| Loại | Số allocations | Có thể hoàn thành đồng bộ | Thường dùng |
|---|---|---|---|
|
Một (heap) | Có/Không | Gần như luôn luôn |
|
Không/ một | Có/Không | Tối ưu |
|
Không/ một | Có/Không | Tối ưu |
Khi nào áp dụng ValueTask
Có một quy tắc vàng: nếu bạn viết method async bình thường, luôn trả kết quả chỉ sau thao tác async, thì dùng Task. Đơn giản, an toàn và mọi người hiểu.
Dùng ValueTask khi:
- Kết quả có thể lấy được đồng bộ (ví dụ từ cache, pool, bộ nhớ), và bạn muốn tiết kiệm allocations thừa.
- Thao tác bất đồng bộ xảy ra không quá thường xuyên (nếu không lợi ích bị tiêu tan bởi độ phức tạp mã và copy struct).
Chú ý! Nếu bạn luôn trả kết quả bất đồng bộ — dùng Task. Nếu muốn API cực kỳ tối ưu cho các kết quả tức thời thường gặp — dùng ValueTask.
2. Kết quả đồng bộ hay bất đồng bộ
Xem xét hàm tìm user theo tên. Nếu user trong cache — trả ngay; nếu không — tải bất đồng bộ từ "database":
// Mô phỏng user của chúng ta
public class User
{
public string Name { get; set; }
}
// Cache của chúng ta (rất đơn giản)
private readonly Dictionary<string, User> _localCache = new();
public async ValueTask<User> FindUserAsync(string name)
{
// Kiểm tra cache cục bộ
if (_localCache.TryGetValue(name, out var user))
{
// Kết quả ngay lập tức — không có allocation của task!
return user;
}
// Ở đây là thao tác bất đồng bộ dài (ví dụ từ database)
user = await LoadUserFromDbAsync(name);
// Lưu vào cache cho tương lai
_localCache[name] = user;
return user;
}
private async Task<User> LoadUserFromDbAsync(string name)
{
// Giả lập độ trễ
await Task.Delay(500);
return new User { Name = name };
}
Quan trọng: Trong trường hợp cache-hit chúng ta trả kết quả bình thường — không có allocation task! Chỉ khi phải "lấy" kết quả mới thực sự tạo task bất đồng bộ.
Cấu trúc bên trong của ValueTask
ValueTask bên trong có thể chứa hoặc giá trị đã sẵn sàng, hoặc tham chiếu tới Task:
ValueTask result = ValueTask.CompletedTask;
ValueTask<int> valueResult = new ValueTask<int>(42);
ValueTask<int> valueResult2 = new ValueTask<int>(Task.Run(() => 42));
Khi dùng await compiler sẽ tự xử lý: nếu kết quả ngay lập tức, sẽ không tạo các object thừa.
Quan trọng về await và ValueTask
Các method async hoạt động tốt với await cho ValueTask:
public async ValueTask PingAsync()
{
// ...
await Task.Delay(10);
}
Nhưng nếu bạn lưu instance của ValueTask và định đợi nó sau này, nhớ rằng: không được await cùng một instance nhiều lần. Task có thể await nhiều lần, còn ValueTask thì không.
Lỗi: Await lại ValueTask
ValueTask<int> task = ComputeAsync();
// Đây OK
int a = await task;
// Đây là lỗi! Await thứ hai trên cùng một instance ValueTask không được phép:
// int b = await task; // KHÔNG ĐƯỢC!
3. Viết lại phần ứng dụng console của chúng ta
Giả sử bây giờ ta phát triển một mini-reader sách: một phần văn bản đã được load vào cache, phần còn lại tải bất đồng bộ. Hiện thực method tối ưu lấy dòng đầu của sách bằng ValueTask:
private readonly Dictionary<string, string> _bookCache = new();
public async ValueTask<string> GetFirstLineOfBookAsync(string title)
{
if (_bookCache.TryGetValue(title, out var bookText))
{
var firstLine = bookText.Split('\n')[0];
return firstLine;
}
// Giả sử tải sách bất đồng bộ
var downloadedBook = await DownloadBookTextAsync(title);
_bookCache[title] = downloadedBook;
return downloadedBook.Split('\n')[0];
}
private async Task<string> DownloadBookTextAsync(string title)
{
// Giả lập độ trễ (ví dụ tải từ internet)
await Task.Delay(1000);
return $"Book: {title}\nThis is the first line.\nSecond line...";
}
Đó là cách ValueTask tiết kiệm việc tạo task khi sách đã có trong cache.
4. Những điểm cần lưu ý
Dấu hiệu: khi không nên dùng ValueTask
Nếu API của bạn luôn bất đồng bộ (ví dụ luôn gọi mạng) — dùng Task, đơn giản và an toàn hơn.
Cách chuyển ValueTask thành Task và ngược lại
Có khi ValueTask "không vừa" với API cần Task. Dùng .AsTask():
ValueTask<int> valueTask = ComputeAsync();
Task<int> task = valueTask.AsTask();
Nếu cần từ Task tạo ValueTask — tạo ValueTask từ task:
Task<int> task = ComputeAsyncTask();
ValueTask<int> valueTask = new ValueTask<int>(task);
So sánh: ValueTask vs Task
| Đặc tính | |
|
|---|---|---|
| Await nhiều lần | Được phép | Không được phép |
| Allocations khi trả lời nhanh | Có | Không (nếu kết quả ngay tức thì) |
| Tính tương thích giao diện | Được hỗ trợ rộng rãi | Cần các wrapper bổ sung |
| Phạm vi áp dụng | Mọi nơi | Chỉ để tối ưu |
| Pooling (Pool) | Dùng pool chung | Không, là struct |
| Độ đơn giản | Đơn giản | Phức tạp hơn |
Sử dụng ValueTask trong thực tế
public async ValueTask<string> GetMessageAsync(int id)
{
if (_messageCache.TryGetValue(id, out var value))
return value;
var result = await LoadMessageFromDbAsync(id);
_messageCache[id] = result;
return result;
}
Ứng dụng trong phỏng vấn: Nếu người phỏng vấn hỏi làm thế nào tối ưu hơn nữa hiệu năng của API bất đồng bộ với caching — hãy nói tới ValueTask.
Ví dụ tương thích với LINQ và IAsyncEnumerable<T>
Nếu muốn dùng ValueTask với LINQ bất đồng bộ (IAsyncEnumerable<T>), .NET hỗ trợ (ví dụ method ToListAsync):
public async ValueTask<List<int>> GetPrimeNumbersAsync()
{
// Giả lập: một phần số đã tính, phần khác là thao tác bất đồng bộ
// ... (ví dụ bỏ qua cho ngắn)
return new List<int> { 2, 3, 5, 7, 11 };
}
5. Kết luận và đặc điểm triển khai
- Dùng ValueTask để tối ưu khi phần lớn thời gian kết quả của bạn đã sẵn sàng ngay (ví dụ từ cache).
- Không dùng ValueTask "chỉ cho vui" — điều đó làm mã phức tạp và dễ lỗi.
- Không await ValueTask nhiều lần. Nếu cần — chuyển nó sang Task.
- Tất cả logic tương thích với Task — qua .AsTask().
- Nhớ rằng ValueTask là struct, và có thể hành xử khác khi bị copy.
6. Lỗi phổ biến khi làm việc với ValueTask
Lỗi #1: await lặp lại trên cùng một instance ValueTask. ValueTask là struct, không thể await hai lần. Await thứ hai sẽ gây exception hoặc hoạt động sai.
Lỗi #2: dùng ValueTask mà không kiểm tra tương thích. Một số API bên thứ ba yêu cầu Task, và truyền trực tiếp ValueTask có thể gây vấn đề.
Lỗi #3: dùng ValueTask không cần thiết. Nếu kết quả luôn bất đồng bộ, dùng ValueTask làm mã phức tạp mà không có lợi.
Lỗi #4: copy ValueTask như struct. Struct bị copy có thể hành xử bất ngờ khi await, nên quan trọng là làm việc với bản gốc.
GO TO FULL VERSION