Async nâng cao

ValueTask - tối ưu performance

  • Thông thường async trả về Task, Task<T>
  • Vấn đề là Taskobject, 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()

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

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);
}

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

Một số lỗi async

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();

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);
});

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();
}

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. 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);

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

Phân biệt Task vs Thread vs async