.NET Framework 4.5 開始引入 Task.Run
,它可以很方便的幫助我們使用 async
/ await
語法,同時還使用線程池來幫助我們管理線程。以至於我們編寫異步代碼可以像編寫同步代碼一樣方便。
不過,如果濫用,也可能導致應用的性能急劇下降。本文將說明在默認線程池配置(ThreadPoolTaskScheduler
)的情況下,應該如何使用 Task.Run
來避免性能的急劇降低。
如何使用 Task.Run?
- 對於 IO 操作,盡量使用原生提供的
Async
方法(不要自己使用Task.Run
調用一個同步的版本占用線程池資源); - 對於沒有
Async
版本的 IO 操作,如果可能耗時很長,則指定CreateOptions
為LongRunning
。 - 其他短時間執行的任務才推薦使用
Task.Run
。
接下來分析原因:
示例程序和示例代碼
在開始之前,我們先准備一個測試程序。這個程序一開始就使用 Task.Run
跑起來 10 個異步任務,每一個里面都等待 5 秒。
可以發現,雖然我們是同一時間啟動的 10 個異步任務,但任務的實際開始時間並不相同 —— 前面 8 個任務立刻開始了,而后面每隔一秒才會啟動一個新的異步任務。
示例程序的代碼如下:
class Program
{
static async Task Main(string[] args)
{
Console.Title = "walterlv task demo";
var task = Enumerable.Range(0, 10).Select(i => Task.Run(() => LongTimeTask(i))).ToList();
await Task.WhenAll(task);
Console.Read();
}
private static void LongTimeTask(int index)
{
var threadId = Thread.CurrentThread.ManagedThreadId.ToString().PadLeft(2, ' ');
var line = index.ToString().PadLeft(2, ' ');
Console.WriteLine($"[{line}] [{threadId}] [{DateTime.Now:ss.fff}] 異步任務已開始……");
// 這一句才是關鍵,等待。其他代碼只是為了輸出。
Thread.Sleep(5000);
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"[{line}] [{threadId}] [{DateTime.Now:ss.fff}] 異步任務已結束……");
Console.ForegroundColor = ConsoleColor.White;
}
}
TaskScheduler
造成以上異步任務不馬上開始的原因,與 Task
使用的 TaskScheduler
有關。默認情況下,Task.Run
使用的是 .NET 提供的默認 Scheduler,可以通過 TaskScheduler.Default
獲取到。
Task 使用 TaskScheduler
來決定何時執行一個異步任務,如果你不設置,默認的實現是 ThreadPoolTaskScheduler
。
你可以前往 .NET Core 的源碼頁面查看源碼:ThreadPoolTaskScheduler.QueueTask。
於是,你在線程池中的設置將決定一個 Task 將在何時開啟一個線程執行。
ThreadPool
通過 ThreadPool.GetMinThreads
可以獲得最小的線程數和異步 IO 完成線程數;通過 ThreadPool.GetMaxThreads
來獲得其最大值。通過對應的 set
方法來設置最小值和最大值。
在 ThreadPool.GetMinThreads(Int32, Int32) Method (System.Threading) - Microsoft Docs
- 線程池按需提供新的工作線程或 I/O 完成線程直到它達到每個類別的最小值。
- 默認情況下,最小線程數設置為在系統上的處理器數。
- 當達到最小值時,線程池可以創建該類別中的其他線程或等待,直到一些任務完成。
- 需求較低時,線程池線程的實際數量可以低於最小值。
於是便會出現我們在本文一開始運行時出現的結果圖。在我的計算機上(八核),最小線程數是 8,於是開始的 8 個任務可以立即開始執行。當達到數量 8 而依然沒有線程完成執行的時候,線程池會嘗試等待任務完成。但是,1 秒后依然沒有任務完成,於是線程池創建了一個新的線程來執行新的任務;接下來是每隔一秒會開啟一個新的線程來執行現有任務。當有任務完成之后,就可以直接使用之前完成了任務的線程繼續完成新的任務。
不過,每個類別創建線程的總數量受到最大線程數限制。
推薦的使用方法
了解到 ThreadPoolTaskScheduler
的默認行為之后,我們可以做這些事情來充分利用線程池帶來的優勢:
- 對於 IO 操作,盡量使用原生提供的
Async
方法,這些方法使用的是 IO 完成端口,占用線程池中的 IO 線程而不是普通線程(不要自己使用Task.Run
占用線程池資源); - 對於沒有
Async
版本的 IO 操作,如果可能耗時很長,則指定CreateOptions
為LongRunning
(這樣便會直接開一個新線程,而不是使用線程池)。 - 其他短時間執行的任務才推薦使用
Task.Run
。
參考資料
- TaskScheduler Class (System.Threading.Tasks) - Microsoft Docs
- TaskCreationOptions Enum (System.Threading.Tasks) - Microsoft Docs
- Parallel Tasks - Microsoft Docs
- Attached and Detached Child Tasks - Microsoft Docs
- 在 ThreadPool.GetMinThreads(Int32, Int32) Method (System.Threading) - Microsoft Docs
- Managed Threading Best Practices - Microsoft Docs