這篇文章中提到了 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等。這些地方為什么沒有使用線程池呢?