6.11 - Sự cố thực tế: một dòng .Result làm treo cả API
Tóm tắt: Một dòng
.Resulttrong endpoint được gọi nhiều nhất làm toàn bộ API chậm dưới tải, trong khi CPU gần như rảnh. Đây là loại sự cố gây nhầm lẫn nhiều nhất với người mới: mọi biểu đồ hạ tầng đều xanh, database nhàn, nhưng người dùng chờ hàng giây cho mỗi thao tác. Nguyên nhân là thread pool starvation (bài 6.1), và điều khiến nó khó chẩn đoán là triệu chứng xuất hiện ở cả những endpoint không có lỗi. Đo trên .NET 10 ở bài tập 1: trong lúc 100 request.Resultđang chạy, một endpoint chỉ trả về chuỗi"nhanh"mất tới 2,3–4,8 giây. Bài này đi qua chẩn đoán, cơ chế, cách sửa và cách ngăn tái diễn.
Mục tiêu bài học
Sau bài này bạn có thể:
- Nhận ra thread pool starvation từ số liệu giám sát.
- Giải thích vì sao một endpoint chặn thread làm chậm cả những endpoint khác.
- Tìm mọi dạng sync-over-async trong dự án bằng lệnh và bằng analyzer.
- Ngăn nó tái diễn bằng phân tích tĩnh và cảnh báo.
Cần biết trước: bài 6.1 (thread pool, starvation, định luật Little) và bài 6.7 (sync-over-async, analyzer). Bài này áp dụng chúng vào một sự cố; khái niệm không được dạy lại.
Nội dung bài học
6.11.1 — Triệu chứng
Tình huống minh hoạ (số liệu giám sát dưới đây là ví dụ để luyện đọc, không phải số đo):
Giờ cao điểm, CRM nhận khoảng 200 request/giây:
p50 của MỌI endpoint: 4.200ms (bình thường: 80ms)
p99: 14.000ms
Tỷ lệ lỗi: 18% (timeout)
CPU: 8% <- RẤT THẤP
Bộ nhớ: 340 MB <- bình thường
CPU database: 12% <- nhàn rỗi
Độ trễ database: 3ms <- nhanhMọi thành phần đều khoẻ, nhưng hệ thống không dùng được. Đây là chữ ký của đang chờ, không phải của thiếu tài nguyên tính toán.
6.11.2 — Chẩn đoán
dotnet-counters monitor -p <pid> --counters System.RuntimeHai chỉ số cần nhìn là độ dài hàng đợi và số thread của thread pool (trên .NET 9 trở lên tên là dotnet.thread_pool.queue.length và dotnet.thread_pool.thread.count). Trong sự cố này, hàng đợi dài hàng trăm tới hàng nghìn việc trong khi CPU dưới 10%: có rất nhiều việc chờ thread, mà thread thì không bận tính toán. Chúng đang bị chiếm trong lúc chờ thứ gì đó. Cách đọc chi tiết hơn ở bài 19.3.
Tìm thủ phạm:
grep -rn "\.Result\|\.Wait()\|GetAwaiter().GetResult()" --include=*.cs src/src/Crm.Api/Controllers/CustomersController.cs:47:
var points = _loyaltyClient.GetPointsAsync(customerId).Result;Một dòng, trong endpoint GET /api/customers/{id}/summary. Màn hình danh sách khách hàng gọi endpoint này cho từng dòng đang hiển thị, nên giờ cao điểm nó nhận khoảng 50 request mỗi giây. Mỗi lần nó gọi API điểm thưởng nội bộ; bình thường mất 100 ms, nhưng giờ cao điểm API đó chậm lên khoảng 2 giây.
6.11.3 — Cơ chế
Áp dụng định luật Little của bài 6.1: 50 request/giây × 2 giây chặn = khoảng 100 thread bị giữ cùng lúc chỉ riêng cho endpoint này. Lúc bình thường (100 ms) chỉ cần 5, nên lỗi nằm im hàng tháng.
Thread pool có N thread (mặc định = số nhân CPU), tạo thêm thread từ từ
1. Request summary gọi .Result
2. Thread bị CHIẾM, không trả về pool, chờ API điểm thưởng 2 giây
3. Request summary mới đến liên tục -> chiếm thêm thread
4. Phần code sau await bên trong GetPointsAsync CŨNG CẦN một thread để chạy,
nhưng nó phải xếp hàng sau các request đang chờ thread
5. Pool tạo thêm thread, nhưng không kịp với tốc độ request đến
6. Hàng đợi dài ra -> MỌI endpoint chậm, kể cả endpoint không có lỗi
7. CPU vẫn thấp vì mọi thread đều đang CHỜ, không tính toánBước 4 là lý do .Result còn tệ hơn một lời gọi đồng bộ thông thường: Task mà thread đang chờ chỉ xong được khi có thread rảnh để chạy phần tiếp theo của nó.
Bước 6 là điều khiến sự cố khó chẩn đoán: endpoint gây lỗi không phải endpoint duy nhất biểu hiện triệu chứng. Người báo lỗi nói "màn hình đăng nhập cũng chậm", còn thủ phạm nằm ở một endpoint khác hoàn toàn.
Tốc độ thread pool tạo thêm thread. Pool cố ý không tạo thread ồ ạt (mỗi thread tốn bộ nhớ stack, và quá nhiều thread làm hệ điều hành mất thời gian chuyển qua lại). Từ .NET 6, runtime phát hiện thread bị chặn bởi .Result/.Wait() và bù thread nhanh hơn hẳn so với khi thread bị chặn bởi Thread.Sleep hay I/O đồng bộ (tài liệu Microsoft). Số đo trên .NET 10 ở bài tập 1 và bài 6.1 cho thấy rõ:
| Kiểu chặn | Thread pool trong vài giây đầu |
|---|---|
Thread.Sleep (giống I/O đồng bộ) | 14 lên 17 thread sau 13 giây |
.Result trên một Task | 14 lên khoảng 48 thread sau 3 giây |
Bù nhanh hơn không có nghĩa là hết starvation: trong khoảng thời gian pool đang nở ra, request vẫn xếp hàng, và mỗi thread thêm vào tốn bộ nhớ. Starvation chỉ không còn kéo dài vô tận như các phiên bản .NET cũ.
6.11.4 — Sửa
// TRƯỚC
[HttpGet("{customerId}/summary")]
public IActionResult GetSummary(int customerId)
{
var points = _loyaltyClient.GetPointsAsync(customerId).Result; // chiếm thread
return Ok(new { customerId, points });
}
// SAU — async suốt chuỗi
[HttpGet("{customerId}/summary")]
public async Task<IActionResult> GetSummary(int customerId, CancellationToken ct)
{
var points = await _loyaltyClient.GetPointsAsync(customerId, ct);
return Ok(new { customerId, points });
}Bài tập 1 đo trước/sau: 100 request đồng thời từ khoảng 3–5,5 giây xuống 0,53 giây, endpoint "vô can" từ vài giây xuống 1 mili-giây.
Nguyên tắc: async phải đi suốt chuỗi. Một mắt xích đồng bộ ở giữa phá hỏng cả chuỗi:
// SAI — vẫn chặn, dù hàm ngoài là async
public async Task<int> GetPointsAsync(int customerId, CancellationToken ct)
{
var response = _http.GetAsync($"points/{customerId}", ct).Result; // <- vẫn chặn ở đây
return await response.Content.ReadFromJsonAsync<int>(ct);
}6.11.5 — Giảm nhẹ tạm thời
Khi chưa sửa kịp và hệ thống đang cháy:
// KHÔNG phải cách sửa — chỉ mua thêm thời gian
ThreadPool.SetMinThreads(workerThreads: 200, completionPortThreads: 200);Nó cho pool tạo ngay tới 200 thread khi cần, không phải nở dần, nên hệ thống thở được. Nhưng:
- Mỗi thread giữ một vùng nhớ stack riêng (cỡ megabyte, tuỳ hệ điều hành).
- Nó che triệu chứng, nên vấn đề sẽ quay lại khi tải tăng thêm.
- Nó không sửa nguyên nhân: mỗi request vẫn chiếm một thread trong suốt thời gian chờ.
Dùng để sống sót qua sự cố, ghi lại lý do, tạo việc sửa gốc, rồi sửa đúng ngay sau đó.
6.11.6 — Ba lệnh rà dự án
1. Tìm mọi dạng sync-over-async:
grep -rn "\.Result\|\.Wait()\|GetAwaiter()\.GetResult()" --include=*.cs src/Không phải mọi kết quả đều nguy hiểm. Trong Program.cs lúc khởi động hoặc trong Main của console app thì chấp nhận được, vì chưa có request nào đang chạy. Trong đường xử lý request thì luôn phải sửa.
2. Tìm async void:
grep -rn "async void" --include=*.cs src/ | grep -v "_Click\|EventHandler"async void không await được và exception trong nó làm sập tiến trình (bài 6.2). Chỉ hợp lệ cho event handler của giao diện, thứ không tồn tại trong backend.
3. Tìm Task.Run bọc code async:
grep -rn "Task.Run(async" --include=*.cs src/// THỪA — thêm một lần xếp lịch lên thread pool, không đem lại gì
await Task.Run(async () => await _service.DoAsync());
// ĐÚNG
await _service.DoAsync();Lambda async bên trong Task.Run chỉ dùng một thread của pool trong đoạn ngắn tới await đầu tiên; trong lúc chờ I/O nó không giữ thread nào, giống hệt gọi thẳng. Nên bản Task.Run không chiếm thêm thread, nó chỉ thừa: thêm một lần xếp lịch, stack trace khi lỗi thêm rối, và người đọc tưởng có lý do đặc biệt để đẩy sang thread khác.
6.11.7 — Ngăn tái diễn
Cài gói analyzer:
dotnet add package Microsoft.VisualStudio.Threading.AnalyzersRồi đặt mức lỗi trong .editorconfig:
# sync-over-async: .Result, .Wait(), GetAwaiter().GetResult()
dotnet_diagnostic.VSTHRD002.severity = error
# async void
dotnet_diagnostic.VSTHRD100.severity = error
# gọi bản đồng bộ khi đang ở trong phương thức async
dotnet_diagnostic.VSTHRD103.severity = errorBuild sẽ thất bại nếu ai đó thêm .Result, tốt hơn nhiều so với hy vọng người review nhìn thấy. Bài 6.7 liệt kê những gì analyzer bắt được và không bắt được.
Thêm một cảnh báo trên số liệu:
CẢNH BÁO khi: độ dài hàng đợi thread pool > 100 trong 1 phútNó bắt được vấn đề trước khi người dùng phàn nàn, và bắt được cả những nguyên nhân khác cũng gây starvation, không riêng .Result (bài 15.8).
6.11.8 — Rà lại code của bạn
Danh sách rà soát thread pool starvation
- Không có .Result, .Wait hay GetAwaiter().GetResult() trong đường xử lý request.
- Không có async void nào ngoài event handler giao diện.
- Không bọc code async trong Task.Run.
- Async đi suốt chuỗi, không có mắt xích đồng bộ ở giữa.
- Có phân tích tĩnh chặn sync-over-async ở mức lỗi.
- Giám sát độ dài hàng đợi thread pool, không chỉ CPU.
Bài tập áp dụng
Bài 1 — Tái hiện sự cố: endpoint vô can chậm theo
Viết một endpoint dùng .Result gọi một "API" chậm 500 ms, một bản await của cùng endpoint đó, và một endpoint /fast chỉ trả về chuỗi. Bắn 100 request đồng thời vào bản .Result, và trong lúc đó gọi /fast một lần. Làm lại với bản await.
Tiêu chí hoàn thành: bạn đo được thời gian của /fast trong cả hai trường hợp, số thread đỉnh của pool, và giải thích được vì sao /fast chậm dù nó không có lỗi.
Gợi ý và lời giải — Bài 1
Gợi ý. Bài 1 của bài 6.1 đã cho thấy request của chính endpoint lỗi chậm thế nào. Điểm mới ở đây là endpoint khác: /fast cũng cần một thread của cùng pool để chạy.
Lời giải — server (dotnet new web):
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
var app = builder.Build();
int peak = 0;
_ = Task.Run(async () =>
{
while (true) { peak = Math.Max(peak, ThreadPool.ThreadCount); await Task.Delay(20); }
});
// Giả lập API điểm thưởng chậm 500 ms
static async Task<string> GetLoyaltyPointsAsync() { await Task.Delay(500); return "120 điểm"; }
app.MapGet("/summary-sync", () => GetLoyaltyPointsAsync().Result); // CHIẾM thread
app.MapGet("/summary-async", async () => await GetLoyaltyPointsAsync()); // trả thread lại
app.MapGet("/fast", () => "nhanh"); // endpoint không có lỗi
app.MapGet("/stats", () => $"thread đỉnh={peak}");
app.Run("http://127.0.0.1:5090");Client (một project console khác, chạy với tham số /summary-sync hoặc /summary-async):
using System.Diagnostics;
var baseUrl = "http://127.0.0.1:5090";
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
await http.GetStringAsync(baseUrl + "/fast"); // làm nóng: server đã chạy sẵn như production
var sw = Stopwatch.StartNew();
var load = Task.WhenAll(Enumerable.Range(0, 100)
.Select(_ => http.GetStringAsync(baseUrl + args[0]))); // 100 request cùng lúc
await Task.Delay(200); // giữa đợt tải, gọi endpoint "vô can"
var fast = Stopwatch.StartNew();
await http.GetStringAsync(baseUrl + "/fast");
var fastMs = fast.ElapsedMilliseconds;
await load;
Console.WriteLine($"{args[0]}: 100 request xong sau {sw.ElapsedMilliseconds} ms; " +
$"/fast mất {fastMs} ms; {await http.GetStringAsync(baseUrl + "/stats")}");Chạy server bằng dotnet run -c Release và khởi động lại server trước mỗi lần đo.
Kết quả đo trên .NET 10, máy 14 nhân (ba lần chạy bản .Result):
/summary-async: 100 request xong sau 530 ms; /fast mất 1 ms; thread đỉnh=15
/summary-sync: 100 request xong sau 3526 ms; /fast mất 2819 ms; thread đỉnh=48
/summary-sync: 100 request xong sau 3005 ms; /fast mất 2279 ms; thread đỉnh=48
/summary-sync: 100 request xong sau 5533 ms; /fast mất 4823 ms; thread đỉnh=52await | .Result | |
|---|---|---|
| 100 request xong sau | 0,53 giây | 3,0–5,5 giây |
/fast (chỉ trả về một chuỗi) mất | 1 ms | 2,3–4,8 giây |
| Thread của pool lúc đỉnh | 15 | 48–52 |
Vì sao /fast chậm. Nó không có lỗi gì, nhưng request của nó cũng phải chờ một thread rảnh trong cùng thread pool, mà pool đang bận giữ các request .Result. Đó chính là bước 6 ở mục 6.11.3: người dùng thấy cả hệ thống chậm, không riêng chỗ lỗi.
Chữ ký thứ hai: số thread. 15 thread phục vụ đủ 100 request async, vì thread được trả lại trong lúc chờ. Bản .Result đẩy pool lên khoảng 50 thread chỉ trong vài giây: đó là cơ chế bù thread của .NET 6+ khi phát hiện .Result. Nhờ vậy sự cố "chỉ" kéo dài vài giây chứ không lâu hơn.
Một quan sát nữa khi đo: lúc server vừa khởi động thì tệ hơn nhiều. Khi bỏ dòng làm nóng (bắn 100 request .Result ngay vào server vừa bật), bốn lần chạy cho kết quả 59–67 giây, /fast cũng chờ chừng ấy, và pool phải nở tới khoảng 100 thread. Tài liệu Microsoft cũng cảnh báo điều này: mỗi lần ứng dụng khởi động lại (mỗi lần deploy, mỗi lần pod mới được tạo), nó lại trải qua một đợt starvation trong lúc pool nở ra.
Vì sao CPU thấp là phần đánh lừa nhiều nhất:
Khoảng 50 thread, tất cả đang nằm chờ -> không thread nào TÍNH TOÁN
-> CPU gần như rảnh
Đội vận hành nhìn biểu đồ: "CPU còn dư nhiều, hạ tầng ổn"
Người dùng: đợi vài giây cho một request đáng lẽ mất 1 mili-giây
Phản xạ sai thường thấy: THÊM MÁY
-> mỗi máy mới cũng lặp lại đúng vấn đề đóTổ hợp chỉ số để nhận ra:
CPU thấp + độ trễ cao ở mọi endpoint + hàng đợi thread pool dài + số thread tăng
= thread pool starvation.
Từng chỉ số riêng lẻ đều có thể giải thích bằng nguyên nhân khác.
Tổ hợp của chúng thì gần như chỉ có một nguyên nhân.Bài 2 — Vì sao SetMinThreads không phải cách sửa
Thêm ThreadPool.SetMinThreads(200, 200); vào đầu Program.cs của bài 1, đo lại bản .Result. So sánh với bản await.
Tiêu chí hoàn thành: bạn có bảng ba cột (.Result, .Result + SetMinThreads, await), và nêu được ba cái giá của SetMinThreads.
Gợi ý và lời giải — Bài 2
Gợi ý. Sửa là chuyện nhỏ (đổi sang await, thêm CancellationToken). Phần đáng học là vì sao cách "sửa nhanh" phổ biến lại không phải cách sửa.
Lời giải. Thêm ThreadPool.SetMinThreads(200, 200); làm dòng đầu tiên của Program.cs phía server, chạy lại client của bài 1 với /summary-sync. Kết quả đo trên .NET 10 (ba lần chạy):
/summary-sync: 100 request xong sau 534 ms; /fast mất 1 ms; thread đỉnh=104
/summary-sync: 100 request xong sau 543 ms; /fast mất 2 ms; thread đỉnh=108
/summary-sync: 100 request xong sau 537 ms; /fast mất 2 ms; thread đỉnh=108| Chỉ số | .Result | .Result + SetMinThreads(200, 200) | await |
|---|---|---|---|
| Thời gian 100 request | 3,0–5,5 giây | 0,54 giây | 0,53 giây |
/fast mất | 2,3–4,8 giây | 1–2 ms | 1 ms |
| Thread pool lúc đỉnh | 48–52 | 104–108 | 15 |
SetMinThreads xoá bỏ giai đoạn "pool nở ra từ từ" -> triệu chứng biến mất NGAY
-> và đó chính là chỗ nguy hiểm: trông như đã sửa xongBa cái giá:
- Bộ nhớ. Mỗi thread giữ một stack riêng; khoảng 108 thread chỉ để ngồi chờ, trong khi bản
awaitcần 15. - Vấn đề gốc vẫn còn. Mỗi request vẫn chiếm một thread suốt thời gian chờ. Tải tăng lên 500 request đồng thời thì lại cạn, lại phải nâng con số lên.
- Che triệu chứng. Giám sát không còn kêu, nên không ai sửa gốc, và lần sau sự cố quay lại ở quy mô lớn hơn, khó chẩn đoán hơn.
Khi nào SetMinThreads vẫn hợp lý:
Đang có sự cố lúc 2 giờ sáng, cần hệ thống sống tới sáng:
-> đặt nó, ghi lại lý do, và tạo việc sửa gốc
Để nó lại trong code và coi như xong:
-> vấn đề sẽ quay lạiCách phòng lâu dài — hai lớp:
- Phân tích tĩnh, đặt mức lỗi (mục 6.11.7): build thất bại khi có
.Resultmới. - Cảnh báo trên production, trước khi người dùng thấy:
dotnet_threadpool_queue_length > 100
and
rate(process_cpu_seconds_total[5m]) < 0.5Tên metric phụ thuộc cách bạn xuất số liệu (OpenTelemetry, Prometheus exporter); ý tưởng là kết hợp hàng đợi dài với CPU thấp.
Bài 3 — Bật phân tích tĩnh trên dự án có sẵn
Cài analyzer, đặt mức warning, đếm số cảnh báo theo từng quy tắc trên một dự án thật (hoặc trên Project 1 của bạn), rồi phân loại.
Tiêu chí hoàn thành: bạn có con số theo từng quy tắc, đã phân loại theo vị trí, và biết hai chỗ .Result được phép giữ lại kèm lý do.
Gợi ý và lời giải — Bài 3
Gợi ý. Đặt thành error ngay từ đầu trên một dự án có sẵn thường làm build đỏ hàng trăm chỗ. Có cách vào dần.
Lời giải — cài và bật:
dotnet add package Microsoft.VisualStudio.Threading.Analyzers# .editorconfig
# tránh .Result/.Wait()
dotnet_diagnostic.VSTHRD002.severity = warning
# tránh async void
dotnet_diagnostic.VSTHRD100.severity = warning
# gọi bản async khi ở trong async
dotnet_diagnostic.VSTHRD103.severity = warning
# quan sát kết quả của lời gọi async
dotnet_diagnostic.VSTHRD110.severity = warningdotnet build --no-incremental 2>&1 | grep -oE "VSTHRD[0-9]+" | sort | uniq -c | sort -rnKết quả minh hoạ (một dự án giả định; mỗi cảnh báo có thể in hai lần nếu build nhiều project, hãy đếm theo đường dẫn file):
31 VSTHRD002
12 VSTHRD103
6 VSTHRD100
4 VSTHRD110Phân loại VSTHRD002 theo vị trí:
| Nhóm | Xử lý |
|---|---|
| Trong đường xử lý request | Sửa ngay: đây là nguồn starvation |
Trong BackgroundService | Sửa, ưu tiên thấp hơn |
| Trong code khởi động | Giữ được, xem dưới |
| Trong test | Sửa cho gọn, không gấp |
Trong Dispose đồng bộ | Giữ được, xem dưới |
Hai chỗ .Result được phép giữ lại:
1. Code khởi động, trước khi máy chủ nhận request, và chỉ khi không thể dùng await. Với top-level statements của .NET hiện đại, Program.cs dùng await được trực tiếp, nên thường không cần:
// Program.cs — chạy MỘT lần, chưa có request nào
var settings = await settingsLoader.LoadAsync(); // top-level statements cho phép await
builder.Services.AddSingleton(settings);2. Cài IDisposable đồng bộ cho một tài nguyên chỉ có API async, khi lớp cha hoặc thư viện bắt buộc Dispose đồng bộ:
public void Dispose()
{
#pragma warning disable VSTHRD002 // lớp cha yêu cầu Dispose đồng bộ; xem ticket CRM-2841
_connection.CloseAsync().GetAwaiter().GetResult();
#pragma warning restore VSTHRD002
}Cách đúng là cài IAsyncDisposable (bài 6.6) nếu người gọi dùng được await using. Giữ .Result trong Dispose chỉ khi buộc phải, và luôn kèm chú thích lý do.
Cách vào dần cho dự án có sẵn, không làm build đỏ ngay:
Bước 1: đặt severity = warning, đếm và ghi lại con số hiện tại làm mốc
Bước 2: sửa những chỗ trong đường xử lý request trước, nơi gây hại thật
Bước 3: khi con số đủ nhỏ, đổi sang error
Bước 4: dùng #pragma cho những chỗ cố tình giữ, KÈM LÝ DOVà chặn con số tăng lên trong lúc chờ, ví dụ một bước trong CI (GitHub Actions):
- name: Số cảnh báo VSTHRD002 không được tăng
run: |
count=$(dotnet build --no-incremental 2>&1 | grep -c "VSTHRD002" || true)
echo "Hiện có $count"
if [ "$count" -gt 31 ]; then
echo "::error::Tăng so với mốc 31 — có chỗ sync-over-async mới"
exit 1
fiSo với một mốc đã biết thay vì so với 0: không bắt cả đội dừng việc để sửa hết, nhưng đảm bảo con số không tăng mà không ai biết.
Ba quy tắc còn lại đáng bật cùng lúc:
// VSTHRD100 — async void: ngoại lệ làm sập tiến trình
public async void Process() { } // chỉ đúng cho event handler của UI
// VSTHRD103 — gọi bản đồng bộ khi đang ở trong async
public async Task<string> ReadAsync() => File.ReadAllText(path); // -> File.ReadAllTextAsync
// VSTHRD110 — gọi phương thức async mà bỏ kết quả
_service.SendEmailAsync(id); // ngoại lệ ở đây không ai thấyQuên một chữ await thì code vẫn biên dịch, vẫn chạy, nhưng lỗi bên trong biến mất không dấu vết và thứ tự thực thi sai. Đó là một trong những lỗi async khó tìm nhất, và analyzer bắt được nó ngay khi build.
Tự kiểm tra
Tổ hợp số liệu nào xác định thread pool starvation?
Hàng đợi thread pool dài và số thread tăng, trong khi CPU thấp và độ trễ cao ở mọi endpoint. Nó nghĩa là có nhiều việc đang xếp hàng nhưng thread không bận tính toán, tức chúng đang bị chiếm trong lúc chờ thứ gì đó.
Vì sao một endpoint chặn thread làm chậm cả endpoint khác?
Vì mọi endpoint dùng chung một thread pool. Khi endpoint lỗi giữ hết thread, request của endpoint khác cũng phải xếp hàng chờ thread. Đo ở bài tập 1: một endpoint chỉ trả về chuỗi mất 2,3–4,8 giây trong lúc 100 request .Result đang chạy, so với 1 mili-giây khi endpoint kia dùng await.
Endpoint được gọi 50 lần mỗi giây, mỗi lần .Result chờ một API mất 2 giây. Cần bao nhiêu thread?
Theo định luật Little: 50 × 2 = khoảng 100 thread bị giữ cùng lúc, chỉ cho riêng endpoint này. Lúc API đó nhanh (100 ms) thì chỉ cần 5, nên lỗi nằm im cho tới giờ cao điểm.
Từ .NET 6 thread pool đã bù thread nhanh hơn khi gặp .Result. Vậy còn lo starvation không?
Còn. Runtime phát hiện thread bị chặn bởi .Result hay .Wait() và tạo thêm thread nhanh hơn trước, nên đợt starvation ngắn lại, nhưng trong lúc pool đang nở ra request vẫn xếp hàng, mỗi thread thêm vào tốn bộ nhớ, và sau mỗi lần khởi động lại ứng dụng lại phải nở từ đầu. Chặn bằng Thread.Sleep hay I/O đồng bộ thì không được bù nhanh như vậy.
Tăng ThreadPool.SetMinThreads có sửa được vấn đề không?
Không, nó chỉ che triệu chứng và mua thêm thời gian. Mỗi thread tốn bộ nhớ, mỗi request vẫn chiếm một thread suốt lúc chờ, và vấn đề sẽ quay lại khi tải tăng thêm. Dùng để sống sót qua sự cố rồi sửa đúng ngay sau đó.
await Task.Run(async () => await _service.DoAsync()) sai ở đâu?
Nó thừa. Lambda async chỉ dùng thread pool trong đoạn ngắn tới await đầu tiên, sau đó không giữ thread nào trong lúc chờ, nên nó không chiếm thêm thread so với gọi thẳng. Nhưng nó thêm một lần xếp lịch lên thread pool, làm stack trace khi lỗi rối hơn, và không đem lại gì. Gọi thẳng await _service.DoAsync().
Kết luận
Ba điều đáng nhớ nhất:
- CPU thấp kèm độ trễ cao ở mọi endpoint nghĩa là đang chờ, không phải thiếu tài nguyên.
- Một dòng
.Resultở endpoint được gọi nhiều có thể làm chậm cả API, và lỗi chỉ lộ ra khi phụ thuộc chậm đi. - Phân tích tĩnh ở mức
errorlà cách chắc chắn nhất ngăn nó tái diễn.
Tham khảo
- Lập trình bất đồng bộ trong C#
- Debug ThreadPool starvation
- dotnet-counters
- Microsoft.VisualStudio.Threading.Analyzers
Điều hướng
- Bài trước: 6.10 — Case study: đồng bộ 500 khách hàng với song song có giới hạn
- Bài tiếp theo: 6.12 — Ôn tập và đánh giá Module 6
- Về module: Trang mục lục