.NET Core 執行緒池(ThreadPool)底層原理淺談

China Soft發表於2024-12-05

https://www.cnblogs.com/lmy5215006/p/18566995

文提到,建立執行緒在作業系統層面有4大無法避免的開銷。因此複用執行緒明顯是一個更優的策略,切降低了使用執行緒的門檻,提高程式設計師的下限。

.NET Core執行緒池日新月異,不同版本實現都有差別,在.NET 6之前,ThreadPool底層由C++承載。在之後由C#承載。本文以.NET 8.0.8為藍本,如有出入,請參考原始碼.

ThreadPool結構模型圖

image

眼見為實

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.cs
上原始碼 and windbg

internal sealed partial class ThreadPoolWorkQueue
{
        internal readonly ConcurrentQueue<object> workItems = new ConcurrentQueue<object>();//全域性佇列
        internal readonly ConcurrentQueue<object> highPriorityWorkItems = new ConcurrentQueue<object>();//高優先順序佇列,比如Timer產生的定時任務
        internal readonly ConcurrentQueue<object> lowPriorityWorkItems =
            s_prioritizationExperiment ? new ConcurrentQueue<object>() : null!;//低優先順序佇列,比如回撥
        internal readonly ConcurrentQueue<object>[] _assignableWorkItemQueues =
            new ConcurrentQueue<object>[s_assignableWorkItemQueueCount];//CPU 核心大於32個,全域性佇列會分裂為好幾個,目的是降低CPU核心對全域性佇列的鎖競爭
}

image

ThreadPool生產者模型

image

眼見為實

        public void Enqueue(object callback, bool forceGlobal)
        {
            Debug.Assert((callback is IThreadPoolWorkItem) ^ (callback is Task));

            if (_loggingEnabled && FrameworkEventSource.Log.IsEnabled())
                FrameworkEventSource.Log.ThreadPoolEnqueueWorkObject(callback);

#if CORECLR
            if (s_prioritizationExperiment)//lowPriorityWorkItems目前還是實驗階段,CLR程式碼比較偷懶,這一段程式碼很不優雅,沒有連續性。
            {
                EnqueueForPrioritizationExperiment(callback, forceGlobal);
            }
            else
#endif
            {
                ThreadPoolWorkQueueThreadLocals? tl;
                if (!forceGlobal && (tl = ThreadPoolWorkQueueThreadLocals.threadLocals) != null)
                {
                    tl.workStealingQueue.LocalPush(callback);//如果沒有特殊情況,預設加入本地佇列
                }
                else
                {
                    ConcurrentQueue<object> queue =
                        s_assignableWorkItemQueueCount > 0 && (tl = ThreadPoolWorkQueueThreadLocals.threadLocals) != null
                            ? tl.assignedGlobalWorkItemQueue//CPU>32 加入分裂的全域性佇列
                            : workItems;//CPU<=32 加入全域性佇列
                    queue.Enqueue(callback);
                }
            }

            EnsureThreadRequested();
        }

細心的朋友,會發現highPriorityWorkItems的注入判斷哪裡去了?目前CLR對於高優先順序佇列只開放給內部,比如timer/Task使用

當使用ThreadPool.QueueUserWorkItem新增任務時,forceGlobal=ture.也就是預設進入全域性佇列。
也包括如下這個方法
ThreadPool.UnsafeQueueUserWorkItem,Task.Factory.StartNew,TaskCreationOptions.PreferFairness,Task.Yield

其它情況下任務會進入本地佇列中。

本地佇列的好處(源於大模型)

  1. 減少執行緒競爭

    • 在多執行緒環境下,多個執行緒同時訪問共享資源(如全域性佇列)時,需要進行同步操作以避免資料不一致。例如,使用鎖來確保同一時間只有一個執行緒能夠訪問和修改佇列。而執行緒池中的本地佇列是與每個工作執行緒相關聯的,每個執行緒主要處理自己本地佇列中的任務。這就意味著執行緒在訪問本地佇列時不需要與其他執行緒進行競爭,減少了因同步操作而帶來的開銷。
    • 比如,在一個高併發的Web伺服器應用中,有多個請求需要處理。如果所有請求都放在一個全域性佇列中,當多個執行緒同時嘗試獲取任務時,就會頻繁地爭奪鎖,導致效能下降。而本地佇列可以讓每個執行緒獨立地從自己的佇列中獲取任務,避免這種過度競爭。
  2. 提高快取利用率(資料區域性性)

    • 當任務在本地佇列中時,由於任務更傾向於在同一個執行緒中執行,這有助於利用資料的區域性性。對於一些需要頻繁訪問的資料(如快取資料),如果任務在本地執行緒處理,這些資料更有可能已經在該執行緒的快取中,從而減少了從主存或其他共享儲存中讀取資料的次數。
    • 例如,在一個資料處理應用程式中,執行緒可能會對本地快取中的資料進行一系列操作。如果任務在本地佇列並且在本地執行緒執行,每次訪問快取資料的延遲會更低,因為快取資料可能已經在CPU快取或者執行緒本地的快取記憶體中,這大大提高了資料訪問的速度,進而提高了任務執行的效率。
  3. 工作竊取機制的基礎

    • 本地佇列是工作竊取機制的重要基礎。工作竊取允許一個空閒的執行緒從其他繁忙執行緒的本地佇列中“竊取”任務來執行。當一個執行緒完成了自己本地佇列中的所有任務後,它可以檢視其他執行緒的本地佇列,找到有任務的佇列並從中竊取任務。
    • 這種機制能夠有效地平衡執行緒之間的工作負載。例如,在一個平行計算任務中,部分執行緒可能因為分配的任務較簡單而提前完成,此時透過工作竊取,這些執行緒可以從其他任務較多的執行緒那裡獲取任務繼續執行,避免了某些執行緒過度繁忙而其他執行緒閒置的情況,提高了整個執行緒池的資源利用率和任務處理效率。
  4. 降低上下文切換成本

    • 執行緒在處理本地佇列中的任務時,由於不需要頻繁地切換到其他執行緒的任務(相比之下,從全域性佇列獲取任務可能會導致更頻繁的執行緒切換),減少了上下文切換的次數。上下文切換涉及到儲存當前執行緒的執行狀態(如暫存器的值、棧資訊等),並載入新執行緒的執行狀態,這是一個相對複雜且消耗資源的過程。
    • 例如,在一個計算密集型的任務場景中,如果任務都在本地佇列中,執行緒可以在較長時間內專注於自己的任務,減少了因頻繁切換任務而導致的上下文切換開銷,使得執行緒能夠更高效地利用CPU時間來完成任務。

ThreadPool消費者模型

image

當執行緒池Dequeue時,會優先檢查本地佇列(LIFO),如果為空就查詢高優先順序佇列,再檢查全域性佇列,再檢查低優先順序佇列。
如果檢查完低優先順序佇列還是為空,那麼它會"竊取"其它執行緒的本地佇列。
需要注意一點的是,當竊取其它執行緒的任務時,它會使用FIFO順序來訪問,被竊取的執行緒還是不變使用LIFO。這樣一個從頭部讀取(被竊取的執行緒),一個從尾部讀取(竊取的執行緒)。無需同步鎖,減少衝突

眼見為實

public object? Dequeue(ThreadPoolWorkQueueThreadLocals tl, ref bool missedSteal)
        {
            // Check for local work items
            object? workItem = tl.workStealingQueue.LocalPop();
            if (workItem != null)
            {
                return workItem;
            }

            // Check for high-priority work items
            if (tl.isProcessingHighPriorityWorkItems)
            {
                if (highPriorityWorkItems.TryDequeue(out workItem))
                {
                    return workItem;
                }

                tl.isProcessingHighPriorityWorkItems = false;
            }
            else if (
                _mayHaveHighPriorityWorkItems != 0 &&
                Interlocked.CompareExchange(ref _mayHaveHighPriorityWorkItems, 0, 1) != 0 &&
                TryStartProcessingHighPriorityWorkItemsAndDequeue(tl, out workItem))
            {
                return workItem;
            }

            // Check for work items from the assigned global queue
            if (s_assignableWorkItemQueueCount > 0 && tl.assignedGlobalWorkItemQueue.TryDequeue(out workItem))
            {
                return workItem;
            }

            // Check for work items from the global queue
            if (workItems.TryDequeue(out workItem))
            {
                return workItem;
            }

            // Check for work items in other assignable global queues
            uint randomValue = tl.random.NextUInt32();
            if (s_assignableWorkItemQueueCount > 0)
            {
                int queueIndex = tl.queueIndex;
                int c = s_assignableWorkItemQueueCount;
                int maxIndex = c - 1;
                for (int i = (int)(randomValue % (uint)c); c > 0; i = i < maxIndex ? i + 1 : 0, c--)
                {
                    if (i != queueIndex && _assignableWorkItemQueues[i].TryDequeue(out workItem))
                    {
                        return workItem;
                    }
                }
            }

#if CORECLR
            // Check for low-priority work items
            if (s_prioritizationExperiment && lowPriorityWorkItems.TryDequeue(out workItem))
            {
                return workItem;
            }
#endif

            // Try to steal from other threads' local work items
            {
                WorkStealingQueue localWsq = tl.workStealingQueue;
                WorkStealingQueue[] queues = WorkStealingQueueList.Queues;
                int c = queues.Length;
                Debug.Assert(c > 0, "There must at least be a queue for this thread.");
                int maxIndex = c - 1;
                for (int i = (int)(randomValue % (uint)c); c > 0; i = i < maxIndex ? i + 1 : 0, c--)
                {
                    WorkStealingQueue otherQueue = queues[i];
                    if (otherQueue != localWsq && otherQueue.CanSteal)
                    {
                        workItem = otherQueue.TrySteal(ref missedSteal);
                        if (workItem != null)
                        {
                            return workItem;
                        }
                    }
                }
            }

            return null;
        }

什麼是執行緒飢餓?

執行緒飢餓(Thread Starvation)是指執行緒長時間得不到排程(時間片),從而無法完成任務。

  1. 執行緒被無限阻塞
    當某個執行緒獲取鎖後長期不釋放,其它執行緒一直在等待
  2. 執行緒優先順序降低
    作業系統鎖競爭中,高優先順序執行緒,搶佔低優先順序執行緒的CPU時間
  3. 執行緒在等待
    比如執行緒Wait/Result時,執行緒池資源不夠,導致得不到執行

眼見為實

@一線碼農 使用大佬的案例
https://www.cnblogs.com/huangxincheng/p/15069457.html
https://www.cnblogs.com/huangxincheng/p/17831401.html

windbg sos bug依舊存在

大佬的文章中,描述sos存在bug,無法顯示執行緒堆積情況
image

經實測,在.net 8中依舊存在此bug
image
99851個積壓佇列,沒有顯示出來

ThreadPool如何改善執行緒飢餓

CLR執行緒池使用爬山演算法來動態調整執行緒池的大小來來改善執行緒飢餓的問題。
本人水平有限,放出地址,有興趣的同學可以自行研究
https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.HillClimbing.cs

ThreadPool如何增加執行緒

在 PortableThreadPool 中有一個子類叫 GateThread,它就是專門用來增減執行緒的類

其底層使用一個while (true) 每隔500ms來輪詢執行緒數量是否足夠,以及一個AutoResetEvent來接收注入執行緒Event.
如果不夠就新增

《CLR vir C#》 一書中,提過一句 CLR執行緒池每秒最多新增1~2個執行緒。結論的源頭就是在這裡
注意:是執行緒池注入執行緒每秒1~2個,不是每秒只能建立1~2個執行緒。OS建立執行緒的速度塊多了。

眼見為實

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.cs
image
image

眼見為實

        static void Main(string[] args)
        {
            for (int i = 0;i<=100000;i++)
            {
                ThreadPool.QueueUserWorkItem((x) =>
                {
                    Console.WriteLine($"當前執行緒Id:{Thread.CurrentThread.ManagedThreadId}");
                    Thread.Sleep(int.MaxValue);
                });
            }

            Console.ReadLine();
        }

可以觀察輸出,判斷是不是每秒注入1~2個執行緒

Task

不用多說什麼了吧?

Task的底層呼叫模型圖

image
Task的底層實現主要取決於TaskSchedule,一般來說,除了UI執行緒外,預設是排程到執行緒池

眼見為實

Task.Run(() => { { Console.WriteLine("Test"); } });

其底層會自動呼叫Start(),Start()底層呼叫的TaskShedule.QueueTask().而作為實現類ThreadPoolTaskScheduler.QueueTask底層呼叫如下。
image

可以看到,預設情況下(除非你自己實現一個TaskShedule抽象類).Task的底層使用ThreadPool來管理。

有意思的是,對於長任務(Long Task),直接是用一個單獨的後臺執行緒來管理,完全不參與排程。

Task對執行緒池的最佳化

既然Task的底層是使用ThreadPool,而執行緒池注入速度是比較慢的。Task作為執行緒池的高度封裝,有沒有最佳化呢?
答案是Yes
當使用Task.Result時,底層會呼叫InternalWaitCore(),如果Task還未完成,會呼叫ThreadPool.NotifyThreadBlocked()來通知ThreadPool當前執行緒已經被阻塞,必須馬上注入一個新執行緒來代替被阻塞的執行緒。
相對每500ms來輪詢注入執行緒,該方式採用事件驅動,注入執行緒池的速度會更快。

眼見為實

點選檢視程式碼

image
image

其底層透過AutoResetEvent來觸發注入執行緒的Event訊息

相關文章