Gọi .Result khi nào thì deadlock, khi nào thì không?
.Result và .Wait() block thread hiện tại. Khi await tạm dừng, nó bắt lại SynchronizationContext đang hiện hành để chạy phần code phía sau đúng trên context đó. Nếu context chỉ cho phép một thread tại một thời điểm — UI thread của WPF, request context của ASP.NET Framework — mà thread đó lại đang bị .Result block, thì continuation không bao giờ vào được: deadlock. Console và ASP.NET Core không có SynchronizationContext, nên continuation chạy trên thread pool và không kẹt. Cách sửa thật sự là async all the way.
Đoạn code dưới đây chạy hoàn hảo trong một console app, và treo cứng khi bạn copy nguyên xi vào một controller của ASP.NET Framework hoặc một nút bấm trong WPF:
public string GetCustomerName(int id)
{
return GetCustomerAsync(id).Result; // treo ở đây
}
private async Task<string> GetCustomerAsync(int id)
{
var response = await _http.GetStringAsync($"/customers/{id}");
return Parse(response).Name;
}
Không có exception, không có stack trace, không có timeout. Thread đứng im vĩnh viễn. Chuyện này không phải do HttpClient, cũng không phải do async "bị lỗi" — nó là hệ quả trực tiếp của một quyết định thiết kế trong cách await hoạt động.
await bắt lại cái gì khi nó tạm dừng
Compiler biến mỗi method async thành một state machine: mỗi await là một checkpoint, code phía sau checkpoint được đóng gói thành một continuation để chạy lại sau khi Task hoàn thành (chi tiết state machine ở đây).
Câu hỏi quan trọng là: continuation đó sẽ chạy trên thread nào?
Mặc định, tại thời điểm await tạm dừng, awaiter đọc SynchronizationContext.Current. Nếu khác null, nó ghi nhớ context này và sau đó Post continuation vào đúng context ấy. Nếu SynchronizationContext.Current là null, nó lùi sang TaskScheduler.Current; và nếu cái đó cũng là scheduler mặc định, continuation chạy thẳng trên thread pool.
Đây là hành vi mong muốn chứ không phải bug. Trong WPF, nó cho phép bạn viết await rồi gán thẳng vào control ở dòng sau mà không cần Dispatcher.Invoke, vì continuation được đảm bảo quay về UI thread.
Vì sao "quay về context" cộng với "block" thành deadlock
Điểm mấu chốt: một số SynchronizationContext chỉ cho phép một thread chạy tại một thời điểm.
DispatcherSynchronizationContext(WPF) vàWindowsFormsSynchronizationContextgắn chặt vào đúng một UI thread.Postnghĩa là xếp công việc vào message queue của thread đó.AspNetSynchronizationContextcủa ASP.NET Framework (System.Web) không gắn vào một thread cố định, nhưng nó tuần tự hoá: trong một request, tại một thời điểm chỉ một thread được phép chạy trong context của request đó.
Ghép hai mảnh lại, trình tự deadlock như sau:
- UI thread (hoặc request thread) gọi
GetCustomerAsync(id). - Method chạy tới
await, bắt lại context hiện tại, trả về mộtTaskchưa hoàn thành. .Resultblock chính thread đó để chờTask.- HTTP call xong trên một thread pool thread. Awaiter
Postcontinuation vào context đã bắt. - Context cần thread đang bị block ở bước 3 — hoặc chờ nó rời khỏi context. Nhưng thread đó chỉ rời đi khi
Taskhoàn thành, màTaskchỉ hoàn thành khi continuation chạy xong.
Hai bên chờ nhau. Không ai nhường. Đây là deadlock theo đúng nghĩa đen, không phải "chậm".
Chú ý là nó không phụ thuộc vào việc I/O có nhanh hay không. Nếu Task tình cờ đã hoàn thành trước khi tới await — cache hit chẳng hạn — await chạy đồng bộ, không tạm dừng, không có continuation nào cần post, và code chạy trót lọt. Chính điều này làm bug trở nên ác: nó xuất hiện không đều, phụ thuộc vào timing, và thường chỉ nổ trên môi trường thật.
Console và ASP.NET Core khác ở chỗ nào
Đây là đoạn hay bị nói sai nhất, nên cần tách bạch hai chuyện khác nhau.
Chuyện thứ nhất — vì sao không deadlock. Trong một console app, SynchronizationContext.Current trên thread Main là null. ASP.NET Core thì cố ý không cài SynchronizationContext nào cả; toàn bộ pipeline request được thiết kế async từ đầu tới cuối (kiến trúc pipeline). Không có context để bắt, nên continuation rơi thẳng xuống thread pool, không cần xin phép thread nào đang bị block. Vòng chờ bị phá, .Result trả về bình thường.
Chuyện thứ hai — vì sao vẫn không nên block. Việc không deadlock không có nghĩa là .Result an toàn trong ASP.NET Core. Mỗi lần bạn block, bạn giữ một thread pool thread đứng im để chờ I/O. Thread pool phát triển rất dè dặt sau khi chạm mức tối thiểu — thứ tự vài trăm mili-giây cho mỗi thread được thêm vào. Dưới tải, số request đến nhanh hơn tốc độ thread pool nở ra, và bạn rơi vào thread pool starvation: latency tăng vọt, request xếp hàng, health check trượt, hệ thống trông như "treo".
Triệu chứng giống deadlock nên rất hay bị gọi nhầm là deadlock. Nhưng nguyên nhân khác hẳn, và cách sửa cũng khác: deadlock cần phá vòng chờ context, còn starvation cần ngừng block để thread quay lại phục vụ request khác (vì sao async nâng throughput).
Tóm lại:
| Môi trường | Có SynchronizationContext? | .Result gây deadlock? | .Result có hại? |
|---|---|---|---|
| Console / worker | Không | Không | Có — block thread vô ích |
| ASP.NET Core | Không | Không | Có — thread pool starvation dưới tải |
| ASP.NET Framework | Có, tuần tự hoá theo request | Có | Rất |
| WPF / WinForms | Có, gắn vào UI thread | Có | Rất |
Cách sửa thật sự: async all the way
Sửa deadlock không phải là tìm một cái flag. Nó là đổi chữ ký của cả chuỗi gọi để không còn chỗ nào block:
// Controller / handler
public async Task<IActionResult> Detail(int id, CancellationToken ct)
{
var name = await GetCustomerNameAsync(id, ct);
return View(name);
}
public async Task<string> GetCustomerNameAsync(int id, CancellationToken ct)
{
var customer = await GetCustomerAsync(id, ct);
return customer?.Name ?? string.Empty;
}
Truyền luôn CancellationToken khi bạn đã sửa chữ ký — sau này thêm vào từng tầng một sẽ tốn công hơn nhiều.
Khi đụng một biên giới thật sự không cho phép async Task, có đường thoát riêng cho từng loại, chứ không phải .Result:
- Entry point:
async Task Mainđược hỗ trợ từ C# 7.1. - Constructor: constructor không thể
async. Dùng factory methodstatic async Task<T> CreateAsync(...). - Việc chạy nền: đừng gọi
.ResulttrongMainhay trong startup. DùngBackgroundService/IHostedService(hosted service). - Interface bạn không sửa được: bọc lại bằng adapter async, hoặc chấp nhận block ở đúng một chỗ duy nhất và ghi rõ lý do.
Nếu nhiều việc độc lập nhau, Task.WhenAll cho bạn song song thật sự mà vẫn không block thread nào (Task.WhenAll và WhenAny).
