主講人: jserv / 課程討論區: 2024 年系統軟體課程
返回「Linux 核心設計/實作」課程進度表
Linux 核心提供 workqueue 介面以達成非同步 (asynchronous) 的流程執行,使用 struct list_head
將要處理的每一個任務連接,使用者以一個 "work item" 來描述需被執行的 context,將其加入到所建立佇列之中,Linux 核心隨後會選出合適的 "worker" thread 來完成,處理結束者會從佇列中刪除,目的是要簡化執行緒的建立,可根據目前系統的 CPU 個數建立執行緒,使得執行緒得以並行。
在 CMWQ 之前的 workqueue 實作中,worker thread 的數量是與被建立的 workqueue 數量直接掛勾的。這意味著使用者每建立一個新的 workqueue,系統上的 worker thread 總量便會增加。實作中包含了兩種 worker thread 的建立方式:
在 MT workqueue 的部份,這代表系統需要建立與 CPU 數量乘上 workqueue 總量等價的 worker thread,這是很嚴重的問題。畢竟隨著 Linux 的演進,一方面 workqueue 的使用者逐漸增加,而現代電腦的 CPU 核心數量同時也不斷增加。這代表一些系統在剛啟動 (booting) 時,其 32k 的 PID 空間就會被 worker thread 的行程給佔滿。
資源限制和並行等級之間存在取捨關係,ST workqueue 是為整個系統提供唯一的 context,因此所有的 work 都是逐一排隊執行,造成並行等級不佳,雖然對資源的使用量比較低。而 MT workqueue 為每個 CPU 提供一個 context,代價是消耗大量資源。但即便如此它提供的 concurrency 效果卻不盡如人意。一個關鍵的原因是 MT workqueue 的 worker thread 的數目是與 CPU 數量嚴格綁定的,且 worker thread 的 work item 也不能互相轉移。另一方面,傳統 workqueue 也容易出現 deadlock 問題。
總結來說,使用 ST workqueue 得到的並行等級較低,但較省資源。然而使用 MT workqueue 固然有嚴重的消耗資源,能提高的並行等級卻很有限。這樣不對等的 trade off 讓過去的 workqueue 並不是那麼好用。
CMWQ 就在此背景下誕生,並專注在以下目標:
CMWQ 架構圖:
worker pool 管理每個 worker
workqueue 連接所有的 work 所形成的佇列
在 CMWQ 中,每一項函式流程的執行被抽象為一個 work item (work_struct
)。
work item 中包含一個 func
成員,是所要被執行的函式指標。當 Linux 裝置驅動程式或子系統想要非同步的方式執行某個函式,它就必須建立 work_struct
並設定 func
以指向要運行的函式,並將其加入到 workqueue 中。
worker thread 負責從佇列中逐一取出 work item 並執行其中的 func
。若沒有 work item 在排隊,則 worker thread 就會處於 idle 狀態。在 worker thread 之上對其管理的結構稱為 worker-pools。
設計上,CMWQ 中對於每個 CPU 會有兩個 worker-pools。一個針對普通的 work,另一個則針對高優先級的 work。此外還有一些未綁定 CPU 的額外 worker-pools,這些 pools 的數量則是動態的。使用者通過 workqueue API 建立 work item 並進行排隊,並且允許設定旗標來影響部份 work item 的執行方式。例如 CPU locality、並行的限制、優先等級等。
當將 work item 排隊到 workqueue 時,會根據佇列參數和 workqueue 的屬性決定目標的 worker-pools,比如可以設定 workqueue 中的 work item 僅由指定 CPU 之普通或高優先級的 worker-pools 來執行。
對於限定 CPU 的 worker-pools,其並行管理的方式是將自身與 CPU 排程器掛勾。則每當任一 worker 被喚醒或是進入睡眠時,worker-pools 會收到通知,藉此來跟踪目前可運行的 worker 數量。一般而言,work item 不太會佔用 CPU 資源,因此設計上傾向使用最少數量的 worker thread 但避免 bandwidth 的損失。實作上,只要該 CPU 上有一個或多個 runnable worker thread,worker-pools 就暫時不會執行新的 work。一直到最後一個正在運行的 worker thread 進入睡眠狀態時,才立即排程一個新的 worker thread,這樣 CPU 就不會在仍有尚未處理的 work item 時無所事事,但也不至於過度的建立大量 worker thread。
worker-pools 類型:
另一方面,除了 kthreads 的記憶體空間之外,保留 idle 的 worker thread 不會消耗其他資源,因此 CWMQ 會保留一個 idle 的 worker thread 一段時間。這樣若隨即有 work 要處理則可以直接沿用,不必再重新建立。
若使用不限定 CPU 的 workqueue,使用者可以藉由對應 API 來為此設定自定義的屬性,則 workqueue 將自動建立與屬性對應的 worker-pools。此種 workqueue 對並行程度的調整將由使用者定義。此外,若想讓綁定 CPU 的 workqueue 也有此特性,API 也提供相應的旗標以忽略並行的管理。
create_workqueue
改以 alloc_workqueue
引入 alloc_workqueue
來自這項 2010 年的 commit 中提到 workqueue 有很多的功能存在,皆是利用參數去設定,並用旗標去指定功能(如:WQ_UNBOUND
和 WQ_DFL_ACTIVE
)
Now that workqueue is more featureful, there should be a public workqueue creation function which takes paramters to control them. Rename _create_workqueue() to alloc_workqueue() and make 0 max_active mean WQ_DFL_ACTIVE. In the long run, all create_workqueue*() will be converted over to alloc_workqueue().
可以看到在這則之前的 commit 建立一個 real time 的 workqueue
在 __create_workqueue
巨集中要更改到整個函式的 prototype,而使用到此巨集的函式皆要跟著改動所有的函式宣告配合巨集,在新增功能的時候會花費大量時間去改動相關的函式,舉例:
更改後相關的 workqueue
實作以新增巨集或是修改巨集為主要方向,在未來有更多的想法上都是藉由這樣的方式,如:
workqueue: remove WQ_SINGLE_CPU and use WQ_UNBOUND instead
simrupt 專案名稱由 simulate 和 interrupt 二個單字組合而來,其作用是模擬 IRQ 事件,並展示以下 Linux 核心機制的運用:
kfifo 是 Linux 核心裡頭 First-In-First-Out (FIFO) 的結構,在 Single Producer Single Consumer (SPSC) 情況中是 safe 的,即不需要額外的 lock 維護,在程式碼中註解中也有提及。
在此專案中有一個 kfifo 資料結構 rx_fifo,用來儲存即將傳到 userspace 的 data。
將 Data 插入到 rx_fifo 中,並檢查寫入的長度與避免過度輸出日誌而影響效能,之所以對 len 進行檢查的原因在於 kfifo_in 所回傳之值,是實際成功插入的數量。
kfifo_in(fifo, buf, n);
kfifo_to_user(fifo, to, len, copied);
kfifo_alloc(fifo, size, gfp_mask);
kfifo_free(fifo);
實現。Circular Buffer 是個固定大小的緩衝區,其中具有 2 個 indicies:
head index
: the point at which the producer inserts items into the buffer.tail index
: the point at which the consumer finds the next item in the buffer.當 head 和 tail 重疊時,代表目前是空的緩衝區。相反的,當 head 比 tail 少 1 時,代表緩衝區是滿的。
當有項目被添加時,head index 會增加,當有項目被移除時,tail index 會被增加,tail 不會超過 head,且當兩者都到達緩衝區的末端時,都必須被設定回 0。也可以藉由此方法清除緩衝區中的資料。
Measuring power-of-2 buffers: 讓緩衝區大小維持 2 的冪,就可以使用 bitwise 操作去計算緩衝區空間,避免使用較慢的 modulus (divide) 操作。
CIRC_SPACE*()
被 producer 使用,CIRC_CNT*()
是 consumer 所用。
在 simrupt 中,一個「更快」的 circular buffer 被拿來儲存即將要放到 kfifo 的資料。
READ_ONCE()
是個 relaxed-ordering 且保證 atomic 的 memory operation,可以確保在多執行緒環境中,讀取到的值是正確的,並保證讀寫操作不會被編譯器最佳化所影響。
smp_rmb()
是個 memory barrier,會防止記憶體讀取指令的重排,確保先讀取索引值後再讀取內容。在〈Lockless patterns: relaxed access and partial memory barriers〉提到 smp_rmb()
與 smp_wmb()
的 barrier 效果比 smp_load_acquire()
與 smp_store_release()
還要來的差,但是因為 load-store 之間的排序關係很少有影響,所以開發人員常以 smp_rmb()
和 smp_wmb()
作為 memory barrier。
fast_buf_get
扮演一個 consumer 的角色,會從緩衝區中取得資料,並更新 tail index。
fast_buf_put 扮演一個 producer 的角色,藉由 CIRC_SPACE()
判斷 buffer 中是否有剩餘空間,並更新 head index。
process_data 函式呼叫 fast_buf_put(update_simrupt_data());
,其中 update_simrupt_data()
會產生資料,這些資料的範圍在 0x20
到 0x7E
之間,即 ASCII 中的可顯示字元,這些資料會被放入 circular buffer 中,最後交由 tasklet_schedule
進行排程。
tasklet 是基於 softirq 之上建立的,但最大的差別在於 tasklet 可以動態配置且可以被用在驅動裝置上。
tasklet 可以被 workqueues, timers 或 threaded interrupts 取代,但 kernel 中尚有使用 tasklet 的情況,Linux 核心開發者已著手 API 變更,而 DECLARE_TASKLET_OLD
的存在是顧及相容性。
首先會先確保函式在 interrupt context 和 softirq context 中執行,使用 queue_work 將 work 放入 workqueue 中,並記錄執行時間。
藉由上述註解可以得知:
softirq | tasklet | |
---|---|---|
多個在同一個 CPU 執行? | No | No |
相同的可在不同 CPU 執行? | Yes | No |
會在同個 CPU 執行? | Yes | Maybe |
當 tasklet_schedule()
被呼叫時,代表此 tasklet 被允許在 CPU 上執行,詳見 linux/include/linux/interrupt.h
linux/include/linux/workqueue.h
定義兩個 mutex lock: producer_lock
和 consumer_lock
。
get_cpu()
獲取目前 CPU 編號並 disable preemption,最後需要 put_cpu()
重新 enable preemption。
24-26行使用 mutex_lock(&consumer_lock)
鎖住消費者區域,防止其它的任務取得 circular buffer 的資料。
32-34行使用 mutex_lock(&producer_lock)
鎖住生產者區域,防止其它的任務寫入 kfifo buffer。
wake_up_interruptible(&rx_wait)
會換醒 wait queue 上的行程,將其狀態設置為 TASK_RUNNING。
在 workqueue 中執行的 work,可由以下方式定義。
DECLARE_WORK(name, void (*func) (void *), void *data)
會在編譯時,靜態地初始化 work。INIT_WORK(struct work_struct *work, woid(*func) (void *), void *data)
在執行時,動態地初始化一個 work。藉由 timer_setup()
初始化 timer。
目標是模擬 hard-irq,所以必須確保目前是在 softirq context,欲模擬在 interrupt context 中處理中斷,所以針對該 CPU disable interrupts。
使用 mod_timer 對 timer 進行排程。
Jiffy 表示不具體的非常短暫的時間段,可藉由以下公式進行轉換。
該函式進行許多資料結構的初始化:
/dev/simrupt
來存取和控制該設備
掛載核心模組。
掛載後,會產生一個裝置檔案/dev/simrupt
,藉由以下命令可見到輸出的資料。
參考輸出: (可能會有異)
dmesg
顯示核心訊息,加入 --follow
可即時查看。
參考輸出:
kfifo 是一個 Circular buffer 的資料結構,而 ring-buffer 就是參考 kfifo 所實作。
kfifo 適合的使用情境,可以在 linux/kfifo.h 中看到:
選定 linux/samples/kfifo/ 作為應用案例,並參考 kfifo-examples 進行實驗。
record-example.c
kfifo_in(&test, &hello, sizeof(hello))
將 struct hello 寫入 kfifo buffer,並用 kfifo_peek_len(&test)
印出 kfifo buffer 下一個 record 的大小。kfifo_in(&test, buf, i + 1)
寫入 kfifo buffer。kfifo_skip(&test)
跳過 kfifo buffer 的第一個值,即跳過 "hello"。kfifo_out_peek(&test, buf, sizeof(buf)
會在不刪除元素情況下,印出 kfifo buffer 的第一個元素。kfifo_len(&test)
印出目前 kfifo buffer 已佔用的空間。kfifo_out(&test, buf, sizeof(buf))
逐一比對 kfifo buffer 中的元素是不是和 excepted_result 中的元素一樣。掛載核心模組。
利用 dmesg 查看核心訊息
bytestream-example.c
kfifo_in
與 kfifo_put
將字串 "hello" 與數字 0-9 放入 kfifo buffer。
kfifo_in
: 可一次將 n Bytes 的 object 放到 kfifo buffer 中。kfifo_put
: 與 kfifo_in
相似,只是用來處理要將單一個值放入 kfifo buffer 的情境,若要插入時,buffer 已滿,則會返回 0。kfifo_out
先將 kfifo buffer 前 5 個值拿出,即 "hello"。kfifo_out
將 kfifo buffer 前 2 個值 (0、1) 拿出,再用 kfifo_in
重新將 0、1 放入 kfifo buffer,並用 kfifo_skip
拿出並忽略 buffer 中第一個值。kfifo_get
逐一檢查 buffer 內的值是否與 expected_result 中的值一樣,若一樣,則 test passed。掛載核心模組。
利用 dmesg 查看核心訊息
設計一個 kfifo 的生產者與消費者實驗,包含一個 producer 與一個 consumer,producer 函式每 1 秒會將一個值放入 kfifo 中,並從 1 遞增到 10,而consumer 函式每 2 秒會消耗一個 kfifo 的值。
在 example_init 中,使用 kthread_run
建立兩個 kernel thread,分別是 producer_thread
與 consumer_thread
。
在 example_exit
中,會用 kfifo_get
逐一檢查 kfifo 剩餘的值是否與 expected_result 相同。