bthread效率為什么更高?
1 基本概念
bthread是brpc中的用戶態線程(也可稱為M:N線程庫),目的是:提高程序的并發度,同時降低編碼難度,在多核cpu上提供更好的scalability和cache locality。其采用M:N模型,即多個用戶線程(bthread)映射到少量的系統線程(pthread)上。
linux當下的pthread實現(NPTL)是1:1的,M個bthread也相當于映射至N個LWP。
bthread前身是Distributed Process(DP)中的fiber,一個N:1的合作式線程庫,等價于event-loop庫,但是同步方式。
2 高效做法
- 用戶態調度:避免內核態和用戶態之間的切換開銷,上下文切換更快。系統線程的切換需要內核接入,而用戶態線程的切換完全在用戶空間完成,減少了系統調用和上下文切換的開銷。
- 更輕量級的上下文切換:用戶態線程的上下文數據量風小,只需要保存必要的寄存器狀態,而內核線程需要保存更多的狀態信息,比如浮點寄存器、信號處理器等。
- M:N模型:多個用戶線程由較少的系統線程調度,減少了系統線程的創建和銷毀開銷,同時也減少了上下文切換的次數。系統線程的數量通常與cpu核心數相當,避免了過多的線程競爭。
- 無鎖或細粒度鎖的數據結構:任務隊列使用無鎖隊列或細粒度鎖,減少了線程間的競爭和等待時間,提高了并發性能。
- 工作竊取(work stealing):當某個工作線程的任務隊列為空時,可以從其他線程的隊列中竊取任務,實現負載均衡,避免線程空閑,提高資源利用率。
- 定制化的內存池管理:采用內存池技術,復用棧空間,減少內存分配和釋放的開銷,避免頻繁的系統調用。
- 避免阻塞系統調用:通過異步IO或非阻塞IO配合事件驅動,減少了線程因IO操作而阻塞的情況,提高了CPU利用率。
進一步解釋
- 用戶態調度
避免內核陷入;
能夠實現0系統調用(無需內核調度器);
類型 | 上下文切換時長 | 操作 |
---|---|---|
用戶態 | 50 - 100 ns | 僅需保存/恢復必要的寄存器(約10個reg) |
內核態 | 1-5 us | 保存完整的上下文(浮點寄存器、信號處理器等);切換內核態堆棧 |
- M:N模型
維度 | M:N模型 | 1:1模型(eg. pthread) |
---|---|---|
線程數量 | 百萬級用戶線程 | 千級系統線程 |
調度開銷 | 用戶態協作式調度 | 內核搶占式調度 |
內存占用 | 每個線程約4-64KB棧 | 每個線程約2-10MB |
創建/銷毀成本 | 微秒級(純用戶態操作) | 毫秒級(需內核參與) |
- 任務調度策略
- 工作竊取算法
Task *steal_task()
{for (Worker &w : other_workers) {if (Task *t = w.queue.try_steal()) {return t;}}return nullptr;
}// 每個worker線程維護本地任務隊列
// 空閑worker從其他worker的隊列尾部竊取任務
// 減少鎖競爭,提高CPU緩存命中率
- 協作式調度
顯式yield讓出cpu;
避免不必要的搶占,減少上下文切換;
- 內存管理優化
- 棧內存復用
class StackPool {
public:static constexpr int MAX_CACHED_STACKS = 1000;std::vector<void*> cached_stacks;void *alloc() {if (!cached_stacks.empty()) {return pop_back();}return ::malloc(STACK_SIZE);}void free(void *stack) {if (cached_stacks.size() < MAX_CACHED_STACKS) {cached_tasks.push_back(stack);} else {::free(stack);}}
};// 減少頻繁的malloc/free
// 避免內存碎片
** 個人疑問?**
棧內存復用的場景是什么?
- 與異步I/O深度集成
- 事件驅動架構
void async_read(int fd, void *buf, size_t size) {epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, ...);bthread_yield();// IO完成后由事件循環喚醒
}// 通過epoll/kqueue實現非阻塞IO
// IO等待期間自動yield,不阻塞worker線程
3 性能對比數據
參考網絡數據,本人未驗證。
場景 | bthread吞吐量 | pthread吞吐量 |
---|---|---|
10k空循環任務 | 1.2M tasks/sec | 120K tasks/sec |
網絡代理(1KB包) | 850K req/sec | 65K req/sec |
數據庫訪問 | 720K QPS | 45K QPS |
4 bthread適用場景
- 高并發網絡服務(eg. web服務器、rpc框架)
- 大規模并行計算(eg. 分布式任務調度)
- 低延遲交易系統(eg. 金融訂單處理)
- 資源受限環境(eg. 嵌入式設備)
5 代價與限制
-
開發復雜度高
eg. 需要手動處理yield點 -
無法利用多核并行
單個worker線程仍綁定單個cpu核心 -
調試困難
用戶態線程的堆棧跟蹤不如內核線程直觀
6 匯總原因
bthread的高效源自現代多核硬件和網絡服務特征的深度優化,通過減少不必要的內核交互、精細化資源管理和智能調度策略,在特定場景下可帶來數量級的性能提升。
7 FAQ
7.1 bthread不是coroutine
我們常說的協程特指N:1線程庫,即所有的協程運行于一個系統線程中,計算能力和各類eventloop庫等價。由于不跨線程,協程之間的切換不需要系統調用,可以非常快(100ns-200ns),受cache一致性的影響也小。但代價是協程無法高效地利用多核,代碼必須非阻塞,否則所有的協程都被卡住,對開發者要求苛刻。協程的這個特點使其適合寫運行時間確定的IO服務器,典型如http server,在一些精心調試的場景中,可以達到非常高的吞吐。但百度內大部分在線服務的運行時間并不確定,且很多檢索由幾十人合作完成,一個緩慢的函數會卡住所有的協程。在這點上eventloop是類似的,一個回調卡住整個loop就卡住了,比如ubaserver(注意那個a,不是ubserver)是百度對異步框架的嘗試,由多個并行的eventloop組成,真實表現糟糕:回調里打日志慢一些,訪問redis卡頓,計算重一點,等待中的其他請求就會大量超時。所以這個框架從未流行起來。
bthread是一個M:N線程庫,一個bthread被卡住不會影響其他bthread。關鍵技術兩點:work stealing調度和butex,前者讓bthread更快地被調度到更多的核心上,后者讓bthread和pthread可以相互等待和喚醒。這兩點協程都不需要。
7.2 最好不要在用戶程序中調用bthread
除非你需要在一次RPC過程中讓一些代碼并發運行,你不應該直接調用bthread函數,把這些留給brpc做更好。
7.3 bthread和pthread woker
pthread worker在任何時間只會運行一個bthread,當前bthread掛起時,pthread worker先嘗試從本地runqueue彈出一個待運行的bthread,若沒有,則隨機偷另一個worker的待運行bthread,仍然沒有才睡眠并會在有新的待運行bthread時被喚醒。
7.4 bthread中能調用阻塞的pthread或系統函數
只阻塞當前pthread worker。其他pthread worker不受影響
若bthread因bthread API而阻塞,它會把當前pthread worker讓給其他bthread。若bthread因pthread API或系統函數而阻塞,當前pthread worker上待運行的bthread會被其他空閑的pthread worker偷過去運行
7.5 pthread可以調用bthread API
bthread API在bthread中被調用時影響的是當前bthread,在pthread中被調用時影響的是當前pthread。使用bthread API的代碼可以直接運行在pthread中。
7.6 若有大量的bthread調用了阻塞的pthread或系統函數,會影響RPC運行
比如有8個pthread worker,當有8個bthread都調用了系統usleep()后,處理網絡收發的RPC代碼就暫時無法運行了。只要阻塞時間不太長, 這一般沒什么影響, 畢竟worker都用完了, 除了排隊也沒有什么好方法. 在brpc中用戶可以選擇調大worker數來緩解問題, 在server端可設置ServerOptions.num_threads或-bthread_concurrency, 在client端可設置-bthread_concurrency.
規避方法
- 一個容易想到的方法是動態增加worker數. 但實際未必如意, 當大量的worker同時被阻塞時, 它們很可能在等待同一個資源(比如同一把鎖), 增加worker可能只是增加了更多的等待者.
- 那區分io線程和worker線程? io線程專門處理收發, worker線程調用用戶邏輯, 即使worker線程全部阻塞也不會影響io線程. 但增加一層處理環節(io線程)并不能緩解擁塞, 如果worker線程全部卡住, 程序仍然會卡住, 只是卡的地方從socket緩沖轉移到了io線程和worker線程之間的消息隊列. 換句話說, 在worker卡住時, 還在運行的io線程做的可能是無用功. 事實上, 這正是上面提到的沒什么影響真正的含義. 另一個問題是每個請求都要從io線程跳轉至worker線程, 增加了一次上下文切換, 在機器繁忙時, 切換都有一定概率無法被及時調度, 會導致更多的延時長尾.
- 一個實際的解決方法是限制最大并發, 只要同時被處理的請求數低于worker數, 自然可以規避掉"所有worker被阻塞"的情況.
- 另一個解決方法當被阻塞的worker超過閾值時(比如8個中的6個), 就不在原地調用用戶代碼了, 而是扔到一個獨立的線程池中運行. 這樣即使用戶代碼全部阻塞, 也總能保留幾個worker處理rpc的收發. 不過目前bthread模式并沒有這個機制, 但類似的機制在打開pthread模式時已經被實現了. 那像上面提到的, 這個機制是不是在用戶代碼都阻塞時也在做"無用功"呢? 可能是的. 但這個機制更多是為了規避在一些極端情況下的死鎖, 比如所有的用戶代碼都lock在一個pthread mutex上, 并且這個mutex需要在某個RPC回調中unlock, 如果所有的worker都被阻塞, 那么就沒有線程來處理RPC回調了, 整個程序就死鎖了. 雖然絕大部分的RPC實現都有這個潛在問題, 但實際出現頻率似乎很低, 只要養成不在鎖內做RPC的好習慣, 這是完全可以規避的.
7.7 bthread沒有channel
channel代表的是兩點間的關系,而很多現實問題是多點的,這個時候使用channel最自然的解決方案就是:有一個角色負責操作某件事情或某個資源,其他線程都通過channel向這個角色發號施令。如果我們在程序中設置N個角色,讓它們各司其職,那么程序就能分類有序地運轉下去。所以使用channel的潛臺詞就是把程序劃分為不同的角色。channel固然直觀,但是有代價:額外的上下文切換。做成任何事情都得等到被調用處被調度,處理,回復,調用處才能繼續。這個再怎么優化,再怎么尊重cache locality,也是有明顯開銷的。另外一個現實是:用channel的代碼也不好寫。由于業務一致性的限制,一些資源往往被綁定在一起,所以一個角色很可能身兼數職,但它做一件事情時便無法做另一件事情,而事情又有優先級。各種打斷、跳出、繼續形成的最終代碼異常復雜。
我們需要的往往是buffered channel,扮演的是隊列和有序執行的作用,bthread提供了ExecutionQueue,可以完成這個目的。