6.12 — Ví dụ thực tế nhanh
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.