源碼解讀 TDengine 中執行緒池的實現
這篇文章中提到了 tsched 的源碼可以一讀,所以去閱讀了一下,總共220來行。
1. 閱讀前工作
通過上文了解到這段程式實現的是一個任務隊列,同時帶有執行緒池。這段程式是電腦作業系統里經典的consumer-producer (生產者-消費者)問題的實現。凡是學過作業系統這門課的,都應該知道這個問題,做過習題。在閱讀源碼之前可以先嘗試用偽程式碼實現上述生產者-消費者問題。
2. 如何閱讀?
了解清楚使用場景
這是一個執行緒池,客戶端可以提交任務,執行緒池按照順序調度執行任務。通過閱讀 tsched.h 頭文件,知道主要有三個函數:
- 初始化命名的調度器、執行緒池:taosInitScheduler
- 生產者提交某個任務:taosScheduleTask
- 程式結束時的清理工作:taosCleanUpScheduler
通過搜索上述三個函數的調用, 知道初始化了兩個調度器,有三個地方會提交任務。
兩個執行緒池
- 定時器里的 tmr 執行緒池 : 隊列長度一萬,只有一個執行緒服務。此執行緒會執行到期的 timer 的回調函數。
- tsc 執行緒池:隊列長度一萬,執行緒數量為所在機器 CPU 核心數的一半。這些執行緒負責:非同步操作如執行語句,固定大小滑動窗口流式數據處理
兩個生產者
上面提到了,有三個生產者會提交任務給執行緒池:
了解了清楚使用方、使用場景後,就容易讀懂邏輯了。這裡是一個標準的作業系統中生產者消費者的問題,用的也是標準解法:使用一個互斥量,兩個訊號量。執行緒池使用 pthread 來創建。
關鍵的數據結構
SSchedQueue 裡面就是上述問題中的核心數據結構,除了放置上述提到的互斥量,訊號量,還需要一個隊列來存儲要具體執行的任務。
SSchedMsg 結構來表示執行緒池任務,包含要執行的具體函數及所需參數。
源碼里注釋並不多,只能通過看具體實現來了解上述支援的執行模式。看到支援兩種模式:執行fp,或者執行 tfp(ahandle, thandle)。
核心調度邏輯
上面提到了生產者,一直沒有提到消費者。接著讀 sched.c 里的源碼,可以看到消費者就是執行緒池裡每個執行緒的主框架邏輯: taosProcessSchedQueue。平常這些執行緒處於阻塞狀態,等待任務。一旦生產者提交任務後,就會通知到消費者。消費者拿到提交的任務及參數,去執行。執行完之後繼續進入上述阻塞的狀態,這樣周而復始。
這裡有個疑問,消費者和生產者之間是非同步的。消費完之後,總得有辦法通知消費者,這一步在哪裡做呢?讀到這裡可以花點時間翻翻源碼,找找答案。
其實秘密也藏在當時提交任務的數據結構里。TDengine 里有樣例程式碼,翻了翻,找到了這個 async demo。可以看到 taos_query_a 就是一個非同步的query函數,裡面帶了 query語句非同步執行完成後的回調函數:taos_insert_call_back)。
3. 一些思考
看的時候內心不斷在思考、對比,比如優勢、劣勢是什麼?我會怎麼實現
優勢
為何使用執行緒池?
- 通過固定執行緒池大小來固定資源開銷,而且是程式初始化時申請資源,這在嵌入式設備里是非常重要的,如果資源不夠用,那就快速失敗,在程式一開始啟動時就報錯。
- 復用了執行緒,因為創建、銷毀執行緒都是有開銷的。這樣在頻繁創建、銷毀執行緒情況下,可以節省開銷,復用之前的執行緒。
- 任務和執行緒解耦:需要使用多執行緒的地方,只管提交任務就好了。執行緒的初始化、運行、狀態切換由執行緒池來負責。
劣勢
- 操作非同步化,對程式設計師的心智要求更高。需要使用回調函數,需要存儲上下文。但是在上述場景里還好, 都是一些固定的邏輯。
- 調試較麻煩,不是直來直去的邏輯。需要通過分析上下文及回調函數里的日誌來分析問題。
有沒有其他實現方式?
如果用 Go 語言實現,會很簡單。使用 channel 來做任務分發,本身就是執行緒安全的。
使用 C 來寫,個人覺得會限制 TDengine 的開源參與方。因為現在市場上會 C 的人比較少,而且主要集中在嵌入式領域。而且 C 的生態一般,語言的輪子比較少,所以很多工作都需要自己做,比如 http server,rpc 等。如果讓我來設計實現 TDengine,我可能會優先考慮 Rust,既能精準控制記憶體,又有比較完善的社區,而且語言處於上升期,容易成為其中的明星項目,會有推廣優勢,比如能吸引一些本身對資料庫不怎麼關注,但是對 Rust 感興趣的程式設計師。
4. 一個思考題
通過搜索 pthread_create 可以發現系統中還有其他創建執行緒的地方,並沒有用到上述的執行緒池,比如 dnodeMWrite, TcpPool,cache,sync等。這些地方為什麼沒有使用執行緒池呢?