在 CMWQ 之前的 workqueue 實作中,worker thread 的數量是與被建立的 workqueue 數量直接掛勾的,每個 workqueue 都有自己獨立的 work pool。意味著使用者每建立一個新的 workqueue,系統上的 worker thread 總量便會增加。實作中包含了兩種 worker thread 的建立方式:
在 MT wq 的部份,這代表系統需要建立與 CPU 數量乘上 workqueue 總量等價的 worker thread,這是很嚴重的的問題,但 multi threaded (MT) wq 浪費了大量的資源,但所提供的並行度仍然不是讓人滿意,因為在 MT workqueue 中,每個 CPU核心 上的關於這個 workqueue 只有一個 work thread。這個 work thread 負責處理該核心上分配的所有 work item。換句話說,同一個 workqueue 上的 所有 work items 都需要 依次排隊 由該核心上的線程來處理,這些 work items 會「爭奪」這個核心上唯一的 execution context,從而影響並行度。而 ST wq 是為整個系統提供唯一的 context,因此所有的 work 都是一一排隊執行,造成 concurrency level 不佳,雖然對資源的使用量比較低。
總結:
參考資料:Linux 核心設計: Concurrency Managed Workqueue(CMWQ)
參考資料:Workqueue
下列這五種為 workqueue 建立的 (API):
alloc_workqueue()
alloc_ordered_workqueue()
create_workqueue()
create_freezable_workqueue()
create_singlethread_workqueue()
這些 API 雖然名稱不同,但底層實作邏輯最終都會呼叫 alloc_workqueue()
,因此 alloc_workqueue()
是核心唯一真正的函式,其餘的只是為了語意清楚或向後相容所包裝的巨集。
今天說明 create_workqueue()
和 alloc_workqueue
兩種:
由上述這段程式碼可以知道 create_workqueue()
主要是將簡單的建立 workqueue 過程封裝起來,並提供了一個簡單的介面來建立一個綁定的 workqueue 。它是 alloc_workqueue()
的一個簡化版本。
create_workqueue 只要輸入字串就能建立,但有限制 Workqueue 其中的參數 WQ_MEM_RECLAIM
為有可能在記憶體回收路徑(memory reclaim paths)中使用的 workqueue ,必須設置這個旗標(WQ_MEM_RECLAIM)。有了這個旗標,即使系統處於記憶體壓力狀態,該 workqueue 也保證至少會有一個可執行的執行緒。
其中的參數 1 為決定每個 CPU 上可以同時指派給該 workqueue (wq)之 work item 的最大執行緒數量
@max_active determines the maximum number of execution contexts per CPU which can be assigned to the work items of a wq.
在 Workqueue 可以得知舊的 create_*workqueue()
API 都已經「棄用」,並預計在未來的 Linux 核心版本中被移除(刪掉)。
The original create_*workqueue() functions are deprecated and scheduled for removal.
而在閱讀 workqueue.h 時我發現到其中的錯字因此發了 [PATCH] workqueue: fix typo in comment。
後來收到 Tejun Heo 的回覆
而 alloc_workqueue()
可以不只像上述 create_*workqueue()
可以輸入字串和最大執行緒數量還能輸入其他旗標如下列所示:
WQ_BH
BH workqueues
總是以「每個 CPU 各自一組的方式存在,且其中的 work items
會在排入該工作的 CPU 上、以 softirq
的上下文中、依照排入順序執行。BH workqueue
都必須設定 max_active = 0
且唯一允許的額外旗標是 WQ_HIGHPRI
。BH work items
不能 sleep
。不過,delayed queueing
、flush
、cancel
等,仍然是支援的。WQ_UNBOUND
unbound workqueues
的 work items
,會由一組不綁定任何特定 CPU 的特殊 worker-pool 來處理。workqueues
的行為就像是一個簡單的「執行環境提供者」,而不進行並行管理。workqueues
的並行需求會大幅波動時WQ_FREEZABLE
workqueues
會參與系統暫停(suspend)操作中的凍結階段(freeze phase)。workqueues
中的 work items
會被清空,並且在系統 thaw 之前,不會執行任何新的 work item
。WQ_MEM_RECLAIM
workqueues
,必須加上這個旗標。WQ_HIGHPRI
work items
,會排入目標 CPU 的 高優先序(highpri)worker-pool。WQ_CPU_INTENSIVE
workqueues
的 work items
,不會被納入並行限制(concurrency level)的計算中。WQ_CPU_INTENSIVE
對 unbound workqueues
來說是無意義的。在成功建立 workqueue 之後,接下來的步驟就是將待執行的 work_struct
提交至 workqueue,以便讓 workqueue 開始處理。在提交 work_struct
之前要先定義與初始化 work_struct
後才提交 work_struct
。提交 work_struct
的函式有兩個:
queue_work()
schedule_work()
schedule_work()
的作用是將 work_struct
加入 kernel 的預設 workqueue 中,而另外queue_work()
用來將 work_struct
提交到指定的 workqueue 中的函式,但觀察下列 work_struct
可以發現到其中並沒有 task_struct
因此不會被排程器直接排程,所以我們要觀察到底執行這個 work_struct
是哪一個執行緒
於是我觀察了 kxo 這個核心模組是由哪個執行緒來執行 work_struct
,我在一端的終端機輸入了下列命令來觀察:
並在另外一個終端機執行 sudo ./xo-user
這個命令後得出了下列的結果:
可以發現到實際執行 work_struct
的執行緒為 kworker 就和 Workqueue 中所述的一樣:對於執行緒型 workqueue,由特別用途的執行緒(稱為 kworker)一個接一個地從 queue 中取出並執行函式。如果佇列中沒有工作,這些 worker thread 就會變成閒置狀態。這些 worker thread 是由 worker pool 管理的。
For threaded workqueues, special purpose threads, called [k]workers, execute the functions off of the queue, one after the other. If no work is queued, the worker threads become idle. These worker threads are managed in worker-pools.
worker thread 閒置的情形也可以觀察的到:
在觀察 Linux 核心原始程式碼後可以發現,當呼叫 schedule_work()
並傳入某個 work item 時,系統首先會檢查該工作是否已經處於 pending 狀態。若否,該工作將會被加入至其對應之 workqueue_struct
所屬的 pool_workqueue
(簡稱 pwq)中。
pool_workqueue
是 workqueue 在某個 worker_pool
上的具體對應實體,負責與底層的執行緒池協作,並管理實際的工作排程流程。
此時,該 work item 會被掛入 worker_pool 的 worklist,表示它正排隊等待被執行。若此 workqueue 為 threaded 類型,系統會喚醒對應的 kworker 執行緒來處理該工作。kworker 是由核心動態建立的核心執行緒,具備標準的 task_struct 結構,因此可以被 Linux 的排程器納入排程流程中,與一般使用者行程一樣,接受時間片管理與 CPU 分派。
一旦被喚醒,kworker 執行緒將會依序從 worklist 中取出各個 work item,並呼叫其對應的工作函式(即 work_struct 結構中的 func 函數指標)進行執行,直到佇列清空,或當前無待處理的工作為止。
在 Linux 中,任務有五種排程類別和六種排程策略,我們要查看 kworker 是被 cpu 以哪個排程策略排程的,因此我輸入了下列命令並觀察結果:
可以發現到 kworker 使用的排程策略為 SCHED_OTHER
也就是 SCHED_NORMAL
就是和一般使用者空間任務使用相同排程,之所以能找到這點是因為每個有 task_struct 的都有 sched_class
這個欄位。
而在 sched(7) — Linux manual page 中提到 SCHED_OTHER 排程策略只能用於靜態優先權為 0 的執行緒。當一個執行緒使用此策略時,排程器會從所有靜態優先權為 0 的執行緒清單中,選擇下一個要執行的對象。
在這個清單內部,執行緒的選擇並非隨機,而是依據動態優先權的值進行排序。
這個動態優先權也就是 Linux EEVDF 所使用的 virtual deadline,它是以 vruntime 為基礎,加上由 nice 值導出的執行時間預期值來計算,進而精確控制公平性與排程延遲具體,計算公式如下:
換句話說:
當一個執行緒的 nice 值越小(即優先權越高),它所對應的 task_weight 就越大。而在 calc_delta_fair() 中,當 nice 值越小時,slice / weight 的結果越小,使得 virtual_deadline 的值越小,也就是說該執行緒越容易被優先排程;
而更新 vruntime 為以下公式進行計算:
而其中的 delta_exec 為下列:
update_curr_se 函式的主要作用是:
利用 rq_clock_task(rq) 取得該 CPU 上當前最新的任務時鐘時間,再用這個時間減掉 curr->exec_start(該任務上一次開始執行的時間點),計算出此次執行的時間差 delta_exec。
接著,將 exec_start 更新為當前時間,並將計算出的 delta_exec 累加到 sum_exec_runtime 中,紀錄該任務的累計執行時間。
計算出 delta_exec 後,即可用它更新當前任務的 vruntime。
calc_delta_fair() 函式會根據該任務的權重(se->load.weight)對實際執行時間 delta 進行調整,將其換算成任務的虛擬執行時間。
當權重不是預設值 NICE_0_LOAD 時,函式會呼叫 __calc_delta() 來計算調整後的虛擬執行時間,並回傳最終結果。
這裡的 NICE_0_LOAD 為下列定義:
也就是說,這個值等同於 ,與我觀察到的 nice 值為 0 的任務的 se.load.weight 完全一致。
在觀察完下列結果後我去觀察 nice 為 -20 的 se.load.weight:
觀察到 se.load.weight 為 90,891,264 也就是 nice 值為 -20 的權重 88761 乘上 ,約為 nice 值為 0 的權重大約 86 倍。根據 __calc_delta 的註解,其計算方式為 delta_exec * weight / lw.weight。因此,nice 值較大的任務其對應的 delta 會較小,導致 vruntime 增加較慢,進而使得 virtual_deadline 也較小。這使得這類任務更容易在排程中被優先選中執行。
為了觀察高優先序的 work thread 與一般 work thread 在排程層面上的差異,我首先透過以下命令,檢查一般 work pool 中的 work thread 所使用的 nice 值:
而在上述 workqueue 建立中也有提到有一個旗幟叫做WQ_HIGHPRI
,此旗標表示該佇列的 work items
,會排入目標 CPU 的高優先序 worker-pool。
因此我將原本 kxo 的 workqueue 加上這個旗幟來去觀察他的 nice 值,下列為測試結果:
可以發現到高優先序的 worker-pool 的 worker thread 的 nice 值為 -20
為了驗證 nice 值是否會影響排程行為,我設計並執行了以下測試:
首先,我建立了兩個綁定型的 workqueue:
接著,我將一個包含 udelay(1000000) 的任務分別排入這兩個 workqueue,並設計為重複排程。
透過 perfetto 這個工具我觀察到在 20 秒內高優先權 worker thread 佔據了絕大部分的執行時間,也就是下列這張圖:
而要如何區隔一般任務和 workqueue 的任務呢?就是要觀察任務的 comm ,如果是 workqueue 的任務 comm 會等於 kworker/… 而一般使用者空間任務的 comm 會是可識別的應用名稱,例如 bash、chrome、sleep 等等,種類多且與實際執行程式有關,像下列我所觀察到的例子:
為了觀察實際 kworker 排程的情況我使用 bpftrace 追蹤 kxo 與 Linux 排程器互動
下列為追蹤的結果:
我們接下來觀察 /include/linux/sched.h 中關於 tsk->__state
的部分,其中定義了:
根據我的觀察,在 kworker 中,如果執行的任務函式運行時間比較長,任務完成後通常會保留該執行緒並標記為 TASK_WAKEKILL
,以便日後能快速重用這個執行緒。這樣可以避免頻繁創建與銷毀 kworker,節省系統開銷。
相對地,當 kworker 所執行的任務非常短時,核心可能會直接將該執行緒狀態標記為 TASK_DEAD
,意味著該執行緒不再重用,將被釋放以回收資源,但實際回收可能延遲。
我認為這可能是核心排程策略的一部分:當任務函式的執行時間較長時,系統往往傾向於保留該 kworker 執行緒,這可能是因為系統預期仍有後續任務會到來,或該執行緒剛完成工作時,idle pool 中的可用 worker 數量不足。
透過保留已處理過這些長時間任務的 kworker,系統能在下一次有類似工作抵達時,直接喚醒既有的執行緒,而無需重新建立新的 worker。這種重複使用的機制有助於減少執行緒建立與銷毀的成本,提升整體執行效率與響應速度。
從 create_worker() 函式的實作中可以觀察到,實際建立 kernel thread 的動作是透過下列這兩行完成的:
這行程式碼呼叫了 kthread_create_on_node()
,用來建立並命名一個 kernel thread。建立後的 thread 會處於停止狀態,你需要使用 wake_up_process()
才能啟動它。
新建立的 thread 會使用 SCHED_NORMAL
排程政策也就是一般任務(一般的 CFS 調度),並且預設允許在所有 CPU 上執行(無 CPU affinity 限制)。
其中第三個參數是輸入 NUMA node 或是 NUMA_NO_NODE
,如果是 NUMA node 的話是預期這個 thread 最終會綁定在某個特定的 CPU 上,要提供該 CPU 所屬的 NUMA node。這樣可以讓該 thread 的 kernel stack(以及其他執行相關的資料結構)分配在對應的 NUMA 記憶體節點上,提升記憶體親和性(memory locality)。否則,就請傳入 NUMA_NO_NODE 在目前呼叫 kthread_create_on_node
的 cpu 上建立
而如果是使用 kthread_create() 這個函式來建立 thread 的話,實際上它是一個巨集包裝,如下所示:
可以看到它實際上會呼叫 kthread_create_on_node(),並將 NUMA_NO_NODE 傳入,代表 不指定特定的 NUMA node。這表示:
而在 create_worker 中還有下列這段程式碼:
這是將這個 worker thread(worker->task)綁定在 pool_allowed_cpus(pool) 所回傳的 CPU mask 上,使它只能在指定的 CPU 上被 scheduler 調度執行,而其中的 pool_allowed_cpus
為下列這段:
如果兩者同時成立,表示 work pool 未指定特定 CPU,但要嚴格限制 CPU 親和性,這時返回 __pod_cpumask
,而另一個 affn_strict
為如果為而 __pod_cpumask
的定義可以在 linux/include/linux/workqueue.h 中找到,而另一個 affn_strict
若未設定,workqueue 將盡力在 __pod_cpumask
範圍內啟動 worker ,但排程器可自由將其遷移 __pod_cpumask
到範圍外的 CPU 執行,反之,若 affn_strict 被設定,worker 則只允許在 __pod_cpumask
範圍內運行。
__pod_cpumask 跟 numa 節點的關係?
不然,返回一般的 CPU 掩碼 cpumask。
官方文件指出,WQ_UNBOUND 所排入的工作項目會由一組特殊的 worker pool 處理。這些 worker 並不綁定在任何特定的 CPU,因此理論上應該可以在任意 CPU 上執行。
不過,實際上 WQ_UNBOUND 是有 CPU affinity 的:它會優先嘗試使用當前執行緒所在的 CPU,前提是該 CPU 存在於 wq_unbound_cpumask 中(目前允許 unbound workqueues 執行的 CPU 清單)。
當前 CPU 如果不在允許 wq_unbound_cpumask ,系統會進一步透過 round-robin 機制,從上次選用的 CPU 開始,依序掃描下一顆可用的 CPU,條件是該 CPU 必須同時存在於:
經過測試不管是哪種程度的 suspend 都會在 suspend_enter
階段做 sync filesystem
,嘗試將 dirty page
寫回磁碟中,但是根據 System Sleep States 所述 memory 只有在 Hibernation 的情況下才會斷電,因此 memory 只要不是 Hibernation 狀況下都不會消失,那是不是只要不是使用 Hibernation 就能不用 sync filesystem
不去將dirty page
寫回磁碟中?
This state (also referred to as STR or S2RAM), if supported, offers significant energy savings as everything in the system is put into a low-power state, except for memory。
在 suspend_enter
階段可能會做 sync filesystem
,嘗試將 dirty page
寫回磁碟中。
當系統喚醒後,控制權會交還給平台韌體或直接給 boot loader(依系統配置而定),接著會啟動一個新的核心實例,稱為恢復核心(restore kernel)。這個恢復核心會從持久性儲存裝置中搜尋並載入休眠映像(hibernation image),以還原系統至休眠前的狀態。
在 Runtime Suspend Callback 和 Runtime Resume Callback 中提到,如果 resume 回傳錯誤,則 PM core 將此視為致命錯誤,需要先透過特殊的 helper 將其狀態復原為 "active" 或 "suspended"
在 Atosuspend 中提到:驅動裝置應該等到裝置處於非活動狀態至少一段時間後,才主動將其至於低功耗的狀態。
在閱讀 How-To-Ask-Questions-The-Smart-Way 後做了下列的筆記:
在提問之前需要做好以下事項:
當你在完成上述幾點後,主動表達自己願意在尋找答案的過程中付出努力,這就是一個非常良好的開始。舉例來說,像是「有人可以給我一點提示嗎?」這類的話語,不只是表達你有求知的態度,更讓人感受到:只要有人能為你指出正確的方向,你就有能力也有決心把事情完成。
其二,我學到的是:使用有意義且具體明確的標題,是一種有效吸引專家目光的方式。控制在 50 字以內的標題,能快速傳達問題核心。在「目標」部分明確指出出問題的對象或環境;在「差異」部分描述實際行為與預期結果之間的落差。
例如:「X.org 6.8.1 的滑鼠游標在某牌顯示卡 MV1005 晶片組下出現變形」。
其三,我學到的是:使用清晰、正確、精確且合乎文法的語句同樣非常重要。語言表達反映一個人的思維習慣,若在文字上不注意細節,往往也會在寫程式和思考時顯得粗心大意。
其四,我學到的是:精確的描述問題並言之有物和話不在多而在精也就是下列幾點:
這是我在 How-To-Ask-Questions-The-Smart-Way 中所學到最重要的,儘量去揣測收件人會怎樣反問你,在他提問的時候預先給他答案。
那為什麼要話不在多而在精呢?原因在於下列三點:
其五,我學到的是:描述問題症狀而非猜測必須確保自己完整描述了問題的實際症狀,而不是僅僅傳達你對問題的解釋或推測。
其六,我學到的是:描述目標而不是過程。當你想弄清楚某件事的做法時,應該在開頭清楚說明你想達成的目標,接著再具體說明你在實作過程中卡住的步驟,這樣他人才更容易理解並提供有效的協助。
其七,我學到的是:清楚明確地表達你的需求。要明確表述需要回答者做什麼(如提供指點、發送一段程式碼、檢查你的 patch 、或是其他等等),就最有可能得到有用的答案。
以上是我在 How-To-Ask-Questions-The-Smart-Way 中所學到的提問的技巧。