CodeGym /Các khóa học /C# SELF /Tối ưu hiệu suất: ValueTas...

Tối ưu hiệu suất: ValueTask

C# SELF
Mức độ , Bài học
Có sẵn

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.CompletedTaskTask.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 TaskValueTask

Loại Số allocations Có thể hoàn thành đồng bộ Thường dùng
Task
Một (heap) Có/Không Gần như luôn luôn
ValueTask
Không/ một Có/Không Tối ưu
ValueTask<T>
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ề awaitValueTask

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
Task
ValueTask
Await nhiều lần Được phép Không được phép
Allocations khi trả lời nhanh 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

  1. 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).
  2. Không dùng ValueTask "chỉ cho vui" — điều đó làm mã phức tạp và dễ lỗi.
  3. Không await ValueTask nhiều lần. Nếu cần — chuyển nó sang Task.
  4. Tất cả logic tương thích với Task — qua .AsTask().
  5. Nhớ rằng ValueTaskstruct, 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.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION