Làm gì khi .NET bị thread starvation?
nếu anh em lười đọc phần dưới: Khi site của anh em bị đứng, response time tăng bất thình lình, số lượng thread tăng liên tục thì có khả năng là bị thread starvation. Cách check nhanh nhất là lấy dump file rồi tìm mấy chỗ gọi GetAwaiter().GetResult(). Sửa nhanh thì trong link này có những thông số để giúp tạo thread nhanh hơn, bạn có thể tham khảo rồi tăng những con số đấy trong runtime settings lên là được, ví dụ System.Threading.ThreadPool.Blocking.ThreadsToAddWithoutDelay_ProcCountFactor
nhưng mà cách này không khuyến khích lắm
Vấn đề cần xử lí là gì
Code trên production gặp các vấn đề này:
- Site đứng im như tượng, response time tăng
- Thread count cứ tăng đều đều theo thời gian, tuy nhiên CPU usage lại bình thường.
- Khi check dump file thì thấy một đống thread bị block
GetAwaiter().GetResult()
Thì rất có thể bạn đã bị thread starvation.
Tóm tắt về Task, Threadpool và Thread starvation trong .NET
Thread starvation nghĩa là tình trạng "đói thread" - tức là khi tất cả thread trong threadpool đều đang bận/bị block, không còn thread nào rảnh để xử lý task mới.
Giống như nhà hàng có 50 bàn (50 threads trong threadpool), mà có 50 khách đang ngồi không chịu về (bị block bởi GetResult
chẳng hạn), thì khách mới vào không có chỗ ngồi (tasks mới không được xử lý) -> nhà hàng "đói" bàn trống.
Trong .NET:
- ThreadPool có một số lượng thread nhất định để xử lý tasks
- Khi thread bị block (ví dụ bởi
GetAwaiter().GetResult()
), nó không thể xử lý task khác - Nếu nhiều thread bị block cùng lúc, threadpool sẽ thiếu thread để xử lý tasks mới.
- Dẫn đến tình trạng ứng dụng bị chậm hoặc không phản hồi, tại những task mới được tạo ra sẽ phải đợi các thread trong threadpool hết bị lock.
Vấn đề này thường gặp ở những project legacy, ban đầu chỉ dùng model thread thuần thông thường, sau đó Task ra đời nhưng code không được chuyển hết sang dùng Task native mà dở dơ ương ương, ví dụ như là đáng lẽ phải gọi
await ExecuteReaderAsync()
thì lại đi gọiExecuteReader().GetAwaiter().GetResult()
. Cơ bản lúc bình thường thì không sao, nhưng mà nếu tải tăng và đụng tới những flow tạo nhiều task như vầy thì site chết không phải hiếm gặp.
Demo thread starvation
Ví dụ điển hình như này, bạn có thể sẽ expect là chạy xong 2000 task này chỉ tốn tầm 4 giây hơn 1 xíu vì việc đợi 4 giây của từng task là hoàn toàn độc lập. Nhưng không, hàm CallSyncOverAsync
gọi GetAwaiter().GetResult()
, tức là thread sẽ bị block 4 giây cho tới khi lấy được kết quả từ LongHeavyReadAsync
. Giả sử lúc đầu threadpool của bạn có 50 threads, tức là liền 1 cái sẽ có 50 tasks chiếm 50 threads trong 4 giây, còn 1950 tasks khác trong 4 giây đó làm gì? Ngồi đợi thread nào trong threadpool rảnh lại. Thời gian chắc chắn lâu hơn 4 giây rất nhiều.
const int n = 2000;
Console.WriteLine("Hello, World!");
var legacy = new SomeLegacyCode();
var tasks = new Task[n];
for (int i = 0; i < n; i++)
{
var task = Task.Run(()=>legacy.CallSyncOverAsync());
tasks[i]=task;
}
Task.WaitAll(tasks);
class ElasticSearchClient
{
public async Task<int> LongHeavyReadAsync()
{
await Task.Delay(4000);
return 42;
}
}
class SomeLegacyCode
{
public void CallSyncOverAsync()
{
var client = new ElasticSearchClient();
var x = client.LongHeavyReadAsync().GetAwaiter().GetResult();
Console.WriteLine($"return value from async {x}");
}
}
Mình chạy thử thì code này sẽ chạy xong trong 1 phút 10s, lâu hơn so với 4s rất nhiều
Giải pháp
Fix tạm thời:
Bình thường .NET threadpool vẫn tự động thêm/bớt thread dựa theo tình hình (ví dụ: số lượng task tăng đột biến, ...)
Nhưng khi phát hiện threadpool bị block bởi mấy cái GetAwaiter().GetResult()
(sync-over-async), .NET sẽ kích hoạt chế độ khẩn cấp, tăng số thread nhanh hơn bình thường để giảm tải.
Tuy nhiên đây không phải giải pháp lâu dài vì việc tăng thread nhanh thế này chỉ là chữa cháy, không giải quyết được gốc rễ vấn đề.
Lặp lại ví dụ với demo lúc nãy, giờ điểm khác biệt là mình sẽ thêm vào runtimeconfig.template.json
cái này
{
"configProperties": {
"System.Threading.ThreadPool.Blocking.ThreadsToAddWithoutDelay_ProcCountFactor": 400, // khi hết thread trong thread pool, ae sẽ được tạo thêm 400 * số core trong máy (16 trong trường hợp của mình) = 400*16 = 6400 threads ngay lập tức mà không bị delay.
"System.Threading.ThreadPool.Blocking.IgnoreMemoryUsage": true
}
}
thì code sẽ chạy xong trong 15s. Nó vẫn lớn hơn con số 4s, bởi vì đương nhiên là việc tạo thread vô tội vạ cũng khá tốn tài nguyên của máy.
Fix đúng cách:
- Bỏ hết
GetAwaiter.GetResult()
, chuyển sangasync
await
toàn bộ. Như vậy thread sẽ không bị block nữa, nhưng mà nói thì dễ hơn làm, tại vì bạn chỉnh 1 functionA
thànhasync
await
thì tất cả những thằng gọiA
đều phải chỉnh thành async await hết.
Nguồn:
https://learn.microsoft.com/vi-vn/dotnet/core/runtime-config/threading#thread-injection-in-response-to-blocking-work-items https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/ https://medium.com/@ynskrn54/using-polly-and-the-bulkhead-pattern-in-net-f4a9639e2fcd
Cảm ơn các bạn đã đọc bài! Dạo này mình cũng hay đọc internal của .NET cũng hơi nhiều, nên sắp tới cũng tính sẽ viết thêm các bài về .net nữa, nếu thấy hay thì mong ae ủng hộ, còn dở hay là sai thì ae cứ ném hết gạch đá, mình xin nhận.
All rights reserved