CodeGym /Các khóa học /C# SELF /Tương tác giữa mã bất đồng bộ và đồng bộ

Tương tác giữa mã bất đồng bộ và đồng bộ

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

1. Giới thiệu

Bất đồng bộ trong C# là một công cụ mạnh mẽ. Nhưng đôi khi bạn phải đối diện với tình huống mã bất đồng bộ cần được gọi từ mã đồng bộ (hoặc ngược lại). Trông thì mọi thứ có vẻ "phải chạy" một cách kỳ diệu, nhưng thực tế có thể xuất hiện treo ứng dụng (deadlock), giảm hiệu năng và thậm chí… giao diện người dùng bị phá hỏng. Thường thì bug chỉ xuất hiện trên dữ liệu thực: ở môi trường production hoặc với người dùng thực. Nguyên nhân thường nằm ở việc tương tác sai giữa mã đồng bộ và bất đồng bộ và những chi tiết không rõ ràng của bộ lập lịch tác vụ .NET.

Thêm nữa, các thư viện và framework hiện nay dùng bất đồng bộ rất nhiều, nên bạn cần hiểu cách "dán" mã bất đồng bộ vào chuỗi gọi đồng bộ hiện có hoặc ngược lại, gọi mã đồng bộ đúng cách từ phương thức bất đồng bộ.

Tóm lược: chuyện gì xảy ra khi dùng await

Khi bạn viết:

await SomeAsyncMethod();

Mã bị "phá" thành hai phần: trước await và sau. Phần đầu chạy đến khi gặp hành động bất đồng bộ cần chờ (ví dụ, gọi mạng), còn phần tiếp theo chạy sau khi hành động đó hoàn tất. Câu hỏi: "tiếp tục" sẽ chạy ở đâu? Trên cùng một thread? Trên thread khác? Nếu là ứng dụng desktop (ví dụ WPF hay WinForms) thì sao? Còn console thì sao? Câu trả lời — tùy. Và ở đây vào cuộc ví dụ như ConfigureAwait.

SynchronizationContext là gì?

SynchronizationContext — cơ chế đặc biệt của .NET, cho phép mã "ghi nhớ" nơi và cách thức cần gọi phần tiếp tục của một thao tác bất đồng bộ.

  • Trong ứng dụng WinForms/WPF truyền thống SynchronizationContext đảm bảo rằng sau await phần còn lại của phương thức sẽ tiếp tục chạy trên cùng một thread UI, để tránh lỗi "truy cập control từ thread khác".
  • Trong ASP.NET (phiên bản cũ, không phải Core) SynchronizationContext cho phép phục hồi HttpContext và tiếp tục xử lý request HTTP.
  • Trong ứng dụng console và ASP.NET Core thường thì SynchronizationContext không có (bằng null), và phần tiếp tục được chạy trên thread pool.

TaskScheduler là gì?

TaskScheduler — cơ chế cấp thấp hơn. Trong hầu hết trường hợp bạn làm việc với TaskScheduler.Default, dùng thread pool của .NET. Nó chịu trách nhiệm quyết định task nào được chạy khi nào và ở đâu.

2. Deadlock khi trộn awaitResult/Wait()

Một trong những bẫy nổi tiếng nhất trong C#:

// Ở đâu đó trong mã UI
var result = SomeAsyncMethod().Result;

hoặc

SomeAsyncMethod().Wait();

Mọi thứ: ứng dụng "treo". Tại sao?

Chuyện này xảy ra như thế nào?

  1. Bạn gọi một phương thức bất đồng bộ rồi ngay lập tức lấy .Result hoặc gọi .Wait() — tức là bạn "khóa" thread hiện tại và chờ task hoàn thành.
  2. SomeAsyncMethod bên trong dùng await, và lập lịch phần tiếp tục để chạy trên cùng thread thông qua SynchronizationContext, nhưng thread đó đang bị khoá vì chờ Result/.Wait().
  3. Vì thread đang chờ kết quả, nó không thể thực thi continuation (phần tiếp tục).
  4. Xong, deadlock: thread đang chờ chính nó.

Hiện tượng này dễ tái tạo trong ứng dụng UI, nơi mọi thứ chạy trên thread giao diện và mọi continuation đều chờ thread đó. Trong console và ASP.NET Core (không có synchronization context) thì deadlock kiểu này hiếm xảy ra.

Chuyện vui đời dev: Nếu bạn đã khiến ứng dụng deadlock bằng .Result — chúc mừng, bạn đã tiến gần hơn đến danh hiệu Senior :D

3. Cách nhúng đúng mã bất đồng bộ vào mã đồng bộ?

Khuyến nghị số một: bất đồng bộ từ trên xuống

Nếu bạn có một phương thức bất đồng bộ, kéo async/await lên trên stack gọi tới tận UI hoặc điểm vào. Đừng giữ "nửa đường" bất đồng bộ.

Sai (khóa thread):

// Phương thức đồng bộ gọi async qua .Result
public void DoStuff()
{
    var data = GetDataAsync().Result;
    // ...
}

Đúng (bất đồng bộ xuyên suốt):

public async Task DoStuffAsync()
{
    var data = await GetDataAsync();
    // ...
}

Nếu được — luôn dùng await, đừng dùng .Result / .Wait().

4. Nhưng có những tình huống: cần gọi async từ sync

Viết lại toàn bộ cây gọi thành async (tùy chọn tốt nhất nếu bạn có thể).

Dùng các pattern đặc biệt: ví dụ chạy task trên một thread riêng bằng Task.Run, rồi bên trong đó gọi phương thức async.

public void DoStuff()
{
    var result = Task.Run(() => SomeAsyncMethod()).Result;
}

Tuy nhiên: vẫn có những nuance với đồng bộ hóa trong ứng dụng UI, nên đừng làm thế nếu không cần thiết lớn.

5. ConfigureAwait(false) làm gì?

Có lúc bạn không cần phần tiếp tục sau await chạy trên cùng thread (ví dụ trong server-side code hoặc thư viện, nơi SynchronizationContext không quan trọng). Ngược lại, tốt hơn là .NET không bị ràng buộc vào một thread cụ thể — giúp chạy nhanh hơn!

Cú pháp và nguyên lý

await SomeAsyncMethod().ConfigureAwait(false);
  • ConfigureAwait(false) nói: "tôi không cần SynchronizationContext gốc, tiếp tục ở bất kỳ đâu, thậm chí trên thread khác".
  • ConfigureAwait(true) (mặc định) — "tiếp tục ở nơi gọi await, tốt nhất là cùng SynchronizationContext".

Sơ đồ trực quan


        ┌─────────────────────────────────────────────────────┐
        │                     SynchronizationContext          │
        └─────────────────────────────────────────────────────┘
                      ↑                             ↑
       (UI-puối)   await SomeAsyncMethod()        Continuation (sau await)
                  ──────────────────────────────>  (cùng luồng — nếu ConfigureAwait(true))
                      ↓
                    (bất kỳ luồng — nếu ConfigureAwait(false))

Ví dụ dùng ConfigureAwait(false) — mã thư viện

Giả sử bạn viết một thư viện mà ai cũng có thể dùng: WinForms, WPF, ASP.NET, console…

Bạn không nên phụ thuộc vào mô hình luồng của họ. Do đó luôn dùng ConfigureAwait(false) trong các phương thức bất đồng bộ của thư viện:

public async Task<string> LoadDataFromUrlAsync(string url)
{
    using var client = new HttpClient();
    string content = await client.GetStringAsync(url).ConfigureAwait(false);
    return content;
}

Bây giờ phương thức của bạn sẽ không "yêu cầu" chạy trên một SynchronizationContext cụ thể. An toàn hơn và hiệu năng tốt hơn (ít chuyển đổi luồng hơn).

Ví dụ: chuyện gì xảy ra với await khi không dùng ConfigureAwait

Xem ví dụ ứng dụng WPF:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    Button1.Content = "Đang tải...";
    await Task.Delay(2000);  // Mô phỏng thao tác lâu
    Button1.Content = "Hoàn tất!";
}

Task.Delay bên trong có một "await". Mặc định sau await sẽ quay lại thread UI để tiếp tục cập nhật control.

Nếu trong thao tác lâu bạn dùng ConfigureAwait(false):

await Task.Delay(2000).ConfigureAwait(false);
Button1.Content = "Hoàn tất!";  // Lỗi!

Sẽ ném ngoại lệ: InvalidOperationException: "The calling thread cannot access this object because a different thread owns it."
Vì phần "tiếp tục" giờ chạy trên thread khác, không được phép truy cập UI.

Kết luận: chỉ dùng ConfigureAwait(false) ở chỗ không cần truy cập UI/Context.

6. Những nuance hữu ích

Khi nào nên dùng ConfigureAwait

Tình huống Có nên dùng .ConfigureAwait(false)? Tại sao?
Mã thư viện Được gọi trong bất kỳ context nào, không cần UI
Bên trong mã ASP.NET Core Có (gần như không có context) Tăng hiệu năng
Trong WinForms/WPF, khi thao tác với UI Không Cần trả điều khiển về thread UI
Phương thức đồng bộ Vô nghĩa Không có synchronization context
Trong ứng dụng console Có thể, nhưng không thấy tác dụng Không có context

Làm sao biết có SynchronizationContext không?

Có thể kiểm tra ngay trong code:

Console.WriteLine(SynchronizationContext.Current == null
    ? "Không có context"
    : "Context tồn tại");
  • Trong ứng dụng console và ASP.NET Core sẽ là "Không có context".
  • Trong WinForms/WPF — "Context tồn tại".

Minh họa chuyển luồng

sequenceDiagram
    participant MainThread as Luồng chính (UI/Console)
    participant ThreadPool as Luồng từ pool

    MainThread->>SomeAsyncMethod: Gọi phương thức
    SomeAsyncMethod->>MainThread: await không có ConfigureAwait
    Note right of MainThread: Sau await quay lại cùng luồng
    SomeAsyncMethod->>ThreadPool: await với ConfigureAwait(false)
    Note right of ThreadPool: Sau await có thể chạy trên bất kỳ luồng nào

Quy tắc ngắn gọn khi dùng

  • Đừng dùng .Result.Wait() trong mã UI với phương thức bất đồng bộ.
  • Trong mã thư viện, luôn dùng ConfigureAwait(false) cho mọi await.
  • Trong ứng dụng UI, dùng ConfigureAwait(false) chỉ trong các phương thức không truy cập UI.
  • Không nên trộn lẫn mã đồng bộ và bất đồng bộ nếu không thực sự cần.
  • Nếu cần gọi async từ sync — suy nghĩ kỹ: có thể biến toàn bộ chuỗi thành async không?

7. Lỗi thường gặp của người mới với bất đồng bộ

Lỗi #1: dùng .Result hoặc .Wait() khắp nơi.
Rất nhiều dev, nhất là sau vài bài đọc về bất đồng bộ, bắt đầu thêm .Result hoặc .Wait() ở mọi chỗ để "đồng bộ hóa" các lời gọi async. Ban đầu trông tiện, nhưng thực tế đây là con đường chắc chắn dẫn đến deadlock trong ứng dụng UI. Đặc biệt nguy hiểm nếu trong các phương thức bất đồng bộ không có ConfigureAwait(false) — thread UI sẽ bị khóa và không thể thực thi phần tiếp tục của task.

Lỗi #2: dùng ConfigureAwait(false) không đúng chỗ hoặc quá tay.
Một số newb dùng ConfigureAwait(false) đại trà, kể cả trong code cần thao tác UI. Trong ứng dụng UI điều này dẫn đến lỗi truy cập control (InvalidOperationException), vì phần tiếp tục giờ chạy trên thread khác không phải UI-thread.

Lỗi #3: quên là ConfigureAwait chỉ hoạt động với các đối tượng awaitable.
Nhiều người nghĩ có thể áp ConfigureAwait(false) cho mọi method, nhưng thực tế nó chỉ áp dụng cho các object trả về Task, Task<T>, ValueTask và các kiểu awaitable khác. Với phương thức đồng bộ, ConfigureAwait không có tác dụng, và việc chờ thực thi những phương thức đó vẫn đồng bộ.

Lỗi #4: trộn mã đồng bộ và bất đồng bộ không cần thiết.
Cố gắng gọi async từ sync mà không có chiến lược (ví dụ qua .Result, .Wait() hay Task.Run) thường gây ra bug khó nhận biết, giảm hiệu năng và deadlock khó debug. Dù method có vẻ ngắn và an toàn, những cuộc gọi như vậy trong chuỗi có thể làm hỏng toàn bộ app.

Lỗi #5: đánh giá thấp ảnh hưởng của SynchronizationContext.
Người mới hay quên rằng phần tiếp tục sau await có thể chạy trên cùng thread (UI) nếu không dùng ConfigureAwait(false). Điều này dẫn đến hành vi không lường khi kết hợp code UI và thư viện, nơi các chờ và phần tiếp tục đan xen nhau.

1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Luồng dữ liệu bất đồng bộ
Đào sâu vào bất đồng bộ
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION