Async nâng cao

1. ValueTask - tối ưu performance

  • Thông thường async trả về Task, Task<T>
  • Vấn đề là Task là object, mỗi lần gọi async sẽ tạo object mới nên GC phải dọn, nếu API được gọi hàng triệu lần thì GC sẽ tốn tài nguyên
  • Giải pháp là ValueTask, ValueTask là struct, không cần allocation trong nhiều trường hợp
  • Khi nào dùng ValueTask: Khi method thường trả kết quả ngay lập tức
    public ValueTask<User> GetUser()
    {
        if (cacheHit)
            return new ValueTask<User>(cachedUser);

        return new ValueTask<User>(LoadFromDb());
    }
  • Khi nào không nên dùng: Nếu method luôn async
    await httpClient.GetAsync()

2. IAsyncEnumerable - stream async

  • Trước đây: List<User> users = await GetUsers();, nhược điểm phải load toàn bộ data trước

  • Async stream:

    public async IAsyncEnumerable<int> GenerateNumbers()
    {
        for (int i = 0; i < 5; i++)
        {
            await Task.Delay(1000);
            yield return i;
        }
    }

    await foreach (var n in GenerateNumbers())
    {
        Console.WriteLine(n);
    }

    # 0
    # 1
    # 2
    # 3
    # 4
    # Mỗi lần xuất hiện sau 1 giây
  • Dùng rất nhiều trong: đọc database lớn, đọc file lớn, streaming API

3. Parallel.ForEachAsync

  • .NET có API rất mạnh: Parallel.ForEachAsync
  • Ví dụ:
await Parallel.ForEachAsync(urls, async (url, ct) =>
{
    var html = await httpClient.GetStringAsync(url);
    Console.WriteLine(html.Length);
});

# Tự động chạy song song
# Giới hạn concurrency
  • Ví dụ thực tế
# Download 100 URL
await Parallel.ForEachAsync(urls, async (url, ct) =>
{
    await Download(url);
});
# Nhanh hơn nhiều so với:
foreach (var url in urls)
{
    await Download(url);
}

4. Tóm tắt async nâng cao

Kỹ thuật Dùng khi
ValueTask tối ưu allocation
IAsyncEnumerable stream dữ liệu
Parallel.ForEachAsync xử lý song song

5. Một số lỗi async

5.1. Fire-and-forget async

  • Ví dụ:
    public async void SendEmail()
    {
        await smtp.SendAsync(...);
    }
  • hoặc:
    # không await
    SendEmailAsync(); 
  • Vấn đề:

    • exception sẽ bị mất
    • task có thể chết giữa chừng
    • không ai biết
  • Ví dụ:

    public async Task SendEmailAsync()
    {
        throw new Exception("fail");
    }

    SendEmailAsync();
    # Exception không được catch
  • Cách đúng:
    await SendEmailAsync();
    # hoặc lưu task:
    var task = SendEmailAsync();

5.2. Tạo quá nhiều Task

    var tasks = new List<Task>();

    foreach (var item in items)
    {
        tasks.Add(Process(item));
    }

    await Task.WhenAll(tasks);

    # Nếu items = 1,000,000 thì sẽ tạo 1 triệu task, dẫn đến:
    # Ram tăng, scheduler quá tải, performance giảm mạnh
  • Cách đúng, giới hạn concurrency: SemaphoreSlim / Parallel.ForEachAsync
  • Ví dụ:
await Parallel.ForEachAsync(items, async (item, ct) =>
{
    await Process(item);
});

5.3. Async method nhưng không async thật

public async Task<User> GetUser()
{
    return db.Users.First();
}
  • Vấn đề: method async nhưng code bên trong sync, thread vẫn bị block
  • Đúng:
public async Task<User> GetUser()
{
    return await db.Users.FirstAsync();
}

5.4. Await trong loop (làm code chậm 10x)

foreach (var id in ids)
{
    await GetUser(id);
}
# 100 request, mỗi request 200ms, tổng 20s
var tasks = ids.Select(id => GetUser(id));

await Task.WhenAll(tasks);
# thời gian khoảng 200ms

5.5. Blocking async code

Task.Delay(1000).Wait();
Thread.Sleep(1000);
# trong async code điều này block thread, phá lợi ích async
public async Task Test()
{
    Thread.Sleep(2000);
}
# method async nhưng vẫn block
  • Đúng await Task.Delay(2000);

5.6 Tóm tắt 5 lỗi nguy hiểm

Lỗi Hậu quả
fire-and-forget mất exception
quá nhiều task memory + scheduler overload
async giả thread bị block
await trong loop code chậm
blocking async mất lợi ích async

6. Phân biệt Task vs Thread vs async