Skip to main content

6.12 — Ví dụ thực tế nhanh

Summary

Một dòng code duy nhất — .Result — làm toàn bộ API treo dưới tải, trong khi CPU chỉ 8%. Đâ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, CPU thấp, bộ nhớ ổn, database nhàn — nhưng p99 là 14 giây và người dùng không dùng được gì. Nguyên nhân là thread pool starvation, và điều khiến nó khó chẩn đoán là triệu chứng xuất hiện ở endpoint hoàn toàn khác với endpoint chứa lỗi: một endpoint hiếm dùng chiếm hết thread, và mọi endpoint còn lại chết theo. Bài này giải thích cơ chế và cho ba lệnh tìm ra mọi chỗ tương tự trong dự á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ải thích cơ chế vì sao một dòng làm sập cả API.
  • Tìm mọi dạng sync-over-async trong dự án.
  • Ngăn nó tái diễn bằng phân tích tĩnh.

Nội dung bài học​

6.12.1 — Triệu chứng​

Giờ cao điểm, 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 <- nhanh

Mọ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.12.2 — Chẩn đoán​

dotnet-counters monitor -p 1 --counters System.Runtime
ThreadPool Thread Count            112
ThreadPool Queue Length 1,847 <- 1.847 việc ĐANG XẾP HÀNG
ThreadPool Completed Work Items ~12/s <- chỉ xong 12 việc mỗi giây
CPU Usage (%) 8

ThreadPool Queue Length cao với CPU thấp là kết luận dứt khoát: thread pool starvation. Có 1.847 việc chờ trong khi CPU nhàn rỗi, nghĩa là thread không bận tính toán — chúng đang bị chiếm trong lúc chờ thứ gì đó (bài 19.4).

Tìm thủ phạm:

grep -rn "\.Result\|\.Wait()\|GetAwaiter().GetResult()" --include=*.cs src/
src/Crm.Api/Controllers/ReportsController.cs:47:
var data = _reportService.GenerateAsync(request).Result;

Một dòng. Trong một endpoint xuất báo cáo được gọi khoảng 20 lần mỗi ngày.

6.12.3 — Cơ chế​

Thread pool có N thread (mặc định ~ số core, tăng rất chậm)

1. Request báo cáo gọi .Result
2. Thread bị CHIẾM, KHÔNG trả về pool, chờ I/O 3 giây
3. Thêm một request báo cáo -> chiếm thêm một thread
4. Pool tăng thêm thread — nhưng chỉ 1-2 thread MỖI GIÂY
5. Request thường đến nhanh hơn tốc độ tăng thread
6. Queue dài ra -> MỌI endpoint chậm, kể cả endpoint không có lỗi
7. CPU vẫn 8% vì mọi thread đều đang CHỜ, không tính toá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 biểu hiện triệu chứng. Người báo lỗi nói "màn hình danh sách khách hàng chậm", còn thủ phạm nằm ở màn hình báo cáo mà gần như không ai dùng.

Vì sao thread pool tăng chậm là có chủ đích: tạo thread tốn kém (mỗi thread khoảng 1 MB stack), và trong trường hợp bình thường việc tăng ồ ạt sẽ hại nhiều hơn lợi. .NET cố ý tăng dần để tránh phản ứng thái quá với đỉnh tải ngắn — nhưng đặc tính đó khiến starvation trở thành vòng xoáy không thoát ra được.

6.12.4 — Sửa​

// TRƯỚC
[HttpGet("export")]
public IActionResult Export([FromQuery] ReportRequest request)
{
var data = _reportService.GenerateAsync(request).Result; // chiếm thread
return File(data, "application/pdf");
}

// SAU — async suot chuoi
[HttpGet("export")]
public async Task<IActionResult> Export(
[FromQuery] ReportRequest request, CancellationToken ct)
{
var data = await _reportService.GenerateAsync(request, ct);
return File(data, "application/pdf");
}
p50 cua moi endpoint:  4.200ms -> 78ms
ThreadPool Queue: 1.847 -> 0-3
Ty le loi: 18% -> 0,02%

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<byte[]> GenerateAsync(ReportRequest r, CancellationToken ct)
{
var data = _repository.GetDataAsync(r, ct).Result; // <- vẫn chặn ở đây
return Render(data);
}

6.12.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ó khiến pool tạo 200 thread ngay thay vì tăng dần, nên hệ thống thở được. Nhưng:

  • Mỗi thread tốn khoảng 1 MB stack — 200 thread là 200 MB.
  • 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.

Dùng để sống sót qua sự cố, rồi sửa đúng ngay sau đó.

6.12.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ó không bắt được — nó làm sập tiến trình. 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/
// SAI — chiếm HAI thread thay vì không chiếm cái nào
await Task.Run(async () => await _service.DoAsync());

// ĐÚNG
await _service.DoAsync();

Task.Run đẩy việc sang một thread pool thread khác. Với code đã async, việc đó chỉ thêm chi phí — code async vốn không cần thread riêng trong lúc chờ I/O.

6.12.7 — Ngăn tái diễn​

<!-- .editorconfig -->
dotnet_diagnostic.VSTHRD002.severity = error <- sync-over-async
dotnet_diagnostic.VSTHRD100.severity = error <- async void
dotnet_diagnostic.VSTHRD103.severity = error <- gọi bản đồng bộ khi có bản async

Cài gói Microsoft.VisualStudio.Threading.Analyzers và đặt mức error. Build 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.

Thêm một cảnh báo trên số liệu:

CẢNH BÁO khi: ThreadPool Queue Length > 100 trong 1 phút

Nó 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.9).

6.12.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 ThreadPool Queue Length, không chỉ CPU.
  • •Có cảnh báo khi queue length vượt ngưỡng.
  • •Hiểu rằng CPU thấp kèm độ trễ cao nghĩa là đang chờ.

Bài tập áp dụng​

Bài 1 — Tái hiện sự cố​

Viết một endpoint dùng .Result gọi một API chậm 3 giây, tạo tải 100 request đồng thời, và quan sát mọi endpoint khác chậm theo.

Tiêu chí hoàn thành: bạn đo được hai bản chênh nhau bao nhiêu lần, và đọc được chữ ký của starvation từ số luồng chứ không chỉ từ thời gian.

Gợi ý và lời giải — Bài 1

Gợi ý. Hai endpoint giống hệt nhau, chỉ khác một chữ. Đó là cách so sánh sạch nhất.

Lời giải:

async Task<string> PhuThuocChamAsync() { await Task.Delay(500); return "xong"; }

app.MapGet("/dong-bo", () => PhuThuocChamAsync().Result); // CHIẾM luồng
app.MapGet("/bat-dong-bo", async () => await PhuThuocChamAsync()); // trả luồng lại
app.MapGet("/nhanh", () => "nhanh");
int luongDinh = 0;
var theoDoi = Task.Run(async () =>
{
while (!cts.IsCancellationRequested)
{
luongDinh = Math.Max(luongDinh, ThreadPool.ThreadCount);
await Task.Delay(20);
}
});

var sw = Stopwatch.StartNew();
await Task.WhenAll(Enumerable.Range(0, 100)
.Select(_ => http.GetStringAsync($"http://127.0.0.1:5233{duong}")));
sw.Stop();

Kết quả đo trên .NET 9.0.203, máy 4 nhân (ThreadPool.GetMinThreads trả về 4):

bất đồng bộ | 100 request:   704 ms | luồng đỉnh   5
đồng bộ | 100 request: 4.356 ms | luồng đỉnh 41
Bất đồng bộĐồng bộ (.Result)
Tổng thời gian704 ms4.356 ms
So với mức lý tưởng (500 ms)1,4×8,7×
Luồng thread pool lúc đỉnh541
Bộ nhớ cho stack (1 MB/luồng)5 MB41 MB

Con số 41 luồng là chữ ký rõ nhất, rõ hơn cả thời gian:

Bất đồng bộ: 5 luồng phục vụ 100 việc đang chờ
-> luồng được TRẢ LẠI trong lúc chờ I/O

Đồng bộ: mỗi request CHIẾM một luồng suốt 500 ms
-> pool phải nở ra, và nó nở CHẬM có chủ đích

Và tốc độ nở giải thích chính xác con số 4.356 ms:

Bắt đầu 4 luồng (= số nhân), tăng khoảng 1–2 luồng mỗi giây
Mỗi luồng xử lý 2 request/giây (500 ms mỗi request)

-> hệ thống phải đợi pool nở tới ~41 luồng
-> toàn bộ thời gian nở ra đó là thời gian người dùng đang chờ

Vì sao CPU thấp là phần đánh lừa nhiều nhất:

41 luồng, tất cả đang nằm chờ I/O -> không luồng nào TÍNH TOÁN
-> CPU khoảng 8%

Đội vận hành nhìn biểu đồ: "CPU còn dư nhiều, hạ tầng ổn"
Người dùng: đợi hơn 4 giây cho một request đáng lẽ mất nửa giây
Phản xạ sai thường thấy: THÊM MÁY
-> mỗi máy mới cũng chỉ lặp lại đúng vấn đề đó
-> tốn tiền, không giải quyết gì

Với phụ thuộc chậm 3 giây như đề bài, cùng phép tính cho kết quả tệ hơn nhiều:

Mỗi luồng xử lý 0,33 request/giây
100 request -> cần khoảng 100 luồng trước khi thông lượng đủ
-> pool nở 1–2 luồng/giây -> mất khoảng một phút

Và trong một phút đó, MỌI endpoint khác cũng không có luồng để chạy.

Tổ hợp chỉ số để nhận ra, lấy từ dotnet-counters:

dotnet-counters monitor --process-id <pid> \
--counters System.Runtime[threadpool-queue-length,threadpool-thread-count,cpu-usage],Microsoft.AspNetCore.Hosting
[System.Runtime]
CPU Usage (%) 8 <- THẤP
ThreadPool Queue Length 1.847 <- CAO
ThreadPool Thread Count 41 <- đang nở
[Microsoft.AspNetCore.Hosting]
Request Duration (ms, p99) 3.812 <- CAO
CPU thấp + độ trễ cao + queue length cao = thread pool starvation.

Đây là tổ hợp DUY NHẤT cho kết luận đó. Ba chỉ số riêng lẻ
đều có thể giải thích bằng nguyên nhân khác.

Bài 2 — Đo trước và sau​

Sửa thành await và đo lại ThreadPool Queue Length cùng p99.

Tiêu chí hoàn thành: bạn có bảng trước/sau, và hiểu vì sao SetMinThreads làm triệu chứng biến mất mà không sửa được vấn đề.

Gợi ý và lời giải — Bài 2

Gợi ý. Sửa là chuyện nhỏ. 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 — sửa:

// TRƯỚC
app.MapGet("/api/leads", () => _service.LayDanhSachAsync().Result);

// SAU
app.MapGet("/api/leads", async (CancellationToken ct) => await _service.LayDanhSachAsync(ct));

Bảng trước/sau, lấy từ số đo ở bài 1:

Chỉ sốTrước (.Result)Sau (await)
Tổng thời gian cho 100 request4.356 ms704 ms
Luồng thread pool đỉnh415
Bộ nhớ cho stack41 MB5 MB
CPUthấp (chờ)thấp (chờ)
CPU không đổi — cả hai bản đều chủ yếu là chờ I/O.
Thứ đổi là SỐ LUỒNG BỊ CHIẾM trong lúc chờ.

Và CancellationToken thêm vào không phải chi tiết phụ:

Không có nó: client ngắt kết nối -> máy chủ VẪN xử lý tới hết
-> lãng phí, và dưới tải cao thì lãng phí đó tích luỹ

Có nó: client ngắt -> truy vấn database bị huỷ -> luồng và kết nối
được trả lại ngay

Vì sao SetMinThreads không phải cách sửa:

ThreadPool.SetMinThreads(workerThreads: 200, completionPortThreads: 200);
Nó xoá bỏ giai đoạn "pool nở ra chậm" -> triệu chứng biến mất NGAY
-> và đó chính là chỗ nguy hiểm: trông như đã sửa xong
Cái giá:
200 luồng × 1 MB stack = 200 MB bộ nhớ
chuyển ngữ cảnh giữa 200 luồng = CPU bị tiêu vào việc điều phối
và vấn đề gốc vẫn còn nguyên = mỗi request vẫn chiếm một luồng
Ở quy mô lớn hơn, nó quay lại:
500 request đồng thời -> lại cạn -> lại phải nâng lên 500
-> 500 MB chỉ để giữ stack cho những luồng đang KHÔNG làm gì

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 một ticket sửa gốc

Để nó lại trong code và coi như xong:
-> vấn đề sẽ quay lại, ở quy mô lớn hơn và khó chẩn đoán hơn

Cách phòng lâu dài — ba lớp:

1. Phân tích tĩnh, đặt mức lỗi.

<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);VSTHRD002;VSTHRD103</WarningsAsErrors>
</PropertyGroup>

2. Test chặn hồi quy, rẻ hơn nhiều so với đo tải.

[Fact]
public async Task Endpoint_khong_chiem_luong_thread_pool()
{
var truoc = ThreadPool.ThreadCount;

await Task.WhenAll(Enumerable.Range(0, 50).Select(_ => _client.GetAsync("/api/leads")));

(ThreadPool.ThreadCount - truoc).Should().BeLessThan(10,
"50 request bất đồng bộ chỉ cần vài luồng; tăng nhiều nghĩa là có chỗ chặn luồng");
}
Chạy khoảng một giây, và bắt được đúng loại hồi quy khó thấy nhất khi đọc code.

3. 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.5
Queue dài + CPU thấp -> starvation, gần như chắc chắn
Và cảnh báo này kêu TRƯỚC khi p99 kịp xấu đi.

Bài 3 — Bật phân tích tĩnh​

Cài analyzer, đặt mức error, và đếm số lỗi build trên dự án thật.

Tiêu chí hoàn thành: bạn có con số, đã phân loại, và biết hai chỗ .Result được phép giữ lại.

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:

<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.11.20">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
# .editorconfig
dotnet_diagnostic.VSTHRD002.severity = error # tránh Result/Wait
dotnet_diagnostic.VSTHRD003.severity = warning # tránh await task của người khác
dotnet_diagnostic.VSTHRD100.severity = error # tránh async void
dotnet_diagnostic.VSTHRD103.severity = error # gọi bản async khi ở trong async
dotnet_diagnostic.VSTHRD110.severity = warning # quan sát kết quả của lời gọi async
dotnet build 2>&1 | grep -oE "VSTHRD[0-9]+" | sort | uniq -c | sort -rn
     31 VSTHRD002
12 VSTHRD103
6 VSTHRD100
4 VSTHRD110

Phân loại 31 chỗ VSTHRD002:

NhómSốXử lý
Trong đường xử lý request14Sửa ngay — đây là nguồn starvation
Trong BackgroundService6Sửa, ưu tiên thấp hơn
Trong code khởi động5Giữ được — xem bên dưới
Trong test4Sửa cho gọn, không gấp
Trong Dispose đồng bộ2Giữ được — xem bên dưới

Và đây là hai chỗ .Result được phép giữ lại:

1. Code khởi động, trước khi máy chủ nhận request.

// Program.cs — chạy MỘT lần, chưa có request nào
var cauHinh = _kho.TaiCauHinhAsync().GetAwaiter().GetResult();
builder.Services.AddSingleton(cauHinh);
Chưa có request nào -> không có gì để làm đói
Và Main không phải async trong một số khung dự án cũ
-> chặn ở đây vô hại
// Tốt hơn nếu framework cho phép — Main async
public static async Task Main(string[] args)
{
var cauHinh = await _kho.TaiCauHinhAsync();
...
}

2. Cài IDisposable đồng bộ cho một tài nguyên chỉ có API async.

public void Dispose()
{
// Không có lựa chọn khác nếu lớp cha bắt buộc Dispose đồng bộ
_ketNoi.CloseAsync().GetAwaiter().GetResult();
}
Cách đúng là cài IAsyncDisposable, nếu người gọi dùng được:
public async ValueTask DisposeAsync() => await _ketNoi.CloseAsync();

Giữ .Result trong Dispose chỉ khi buộc phải, và nên kèm chú thích lý do.
#pragma warning disable VSTHRD002 // lớp cha yêu cầu Dispose đồng bộ; xem ticket CRM-2841
_ketNoi.CloseAsync().GetAwaiter().GetResult();
#pragma warning restore VSTHRD002

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 (31)
Bước 2: sửa 14 chỗ trong đường xử lý request — nơi gây hại thật
Bước 3: khi con số về dưới 10, đổi sang error
Bước 4: dùng #pragma cho những chỗ cố tình giữ, KÈM LÝ DO

Và chặn con số tăng lên trong lúc chờ:

- name: Số cảnh báo async không được tăng
run: |
so=$(dotnet build 2>&1 | grep -c "VSTHRD002" || true)
echo "Hiện có $so"
if [ "$so" -gt 31 ]; then
echo "::error::Tăng so với mốc 31 — có chỗ sync-over-async mới"
exit 1
fi
So 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 31 chỗ
- nhưng đảm bảo con số KHÔNG TĂNG mà không ai biết

Và ba quy tắc còn lại đáng bật cùng lúc:

// VSTHRD100 — async void: ngoại lệ GIẾT tiến trình, không bắt được
public async void XuLy() { } // chỉ đúng cho event handler của UI

// VSTHRD103 — gọi bản đồng bộ khi đang ở trong async
public async Task DocAsync() => File.ReadAllText(duong); // -> File.ReadAllTextAsync

// VSTHRD110 — quên await, kết quả bị bỏ rơi
_service.GuiEmailAsync(id); // ngoại lệ ở đây không ai thấy
VSTHRD110 đáng chú ý: quên một chữ `await`
-> 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
-> đây là một trong những lỗi async khó tìm nhất, và analyzer bắt được nó trong một giây

Tự kiểm tra​

Frequently asked questions

Tổ hợp số liệu nào xác định thread pool starvation?

ThreadPool Queue Length cao trong khi CPU thấp. 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 sự cố này khó chẩn đoán?

Vì endpoint gây lỗi không phải endpoint biểu hiện triệu chứng. Một endpoint hiếm dùng chiếm hết thread, và mọi endpoint khác chậm theo, nên người báo lỗi chỉ tới sai chỗ.

Vì sao thread pool tăng chậm?

Vì tạo thread tốn kém, mỗi thread khoảng một megabyte stack, và tăng ồ ạt thường hại nhiều hơn lợi. .NET cố ý tăng dần để tránh phản ứng thái quá với đỉnh tải ngắn, nhưng đặc tính đó khiến starvation thành vòng xoáy.

Async đi suốt chuỗi nghĩa là gì?

Mọi mắt xích từ controller xuống tới lời gọi I/O đều phải async. Một chỗ dùng .Result ở giữa vẫn chiếm thread, kể cả khi hàm bao ngoài đã khai báo async.

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 khoảng một megabyte, 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 đó.

Vì sao Task.Run bọc code async là sai?

Vì code async vốn không cần thread riêng trong lúc chờ I/O. Bọc nó trong Task.Run đẩy việc sang một thread pool thread khác, nên chiếm hai thread thay vì không chiếm cái nào.

Kết luận​

Ba điều đáng nhớ nhất:

  1. CPU thấp kèm độ trễ cao nghĩa là đang chờ, không phải thiếu tài nguyên.
  2. Một dòng .Result ở endpoint hiếm dùng có thể làm sập cả API.
  3. Phân tích tĩnh ở mức error là cách duy nhất ngăn nó tái diễn.

Tham khảo​

Điều hướng​