)
做C網絡服務這幾年asio是我用得最多的庫。早先寫服務一個io_context走天下代碼簡單清爽直到某次給模擬項目X做壓測16核的容器只跑滿一顆CPUQPS到3萬就再也上不去我才意識到單io_context遲早會成為瓶頸也是從那次起我把asio多線程模型下的IOServicePool整套思路梳理了出來。這篇就來聊聊這個池子的設計動機、完整實現以及落到業(yè)務代碼里的姿勢。如果你是剛入門asio可以把它當作多線程進階篇如果你已經在用單線程跑業(yè)務、正發(fā)愁怎么加線程這篇直接給出能抄作業(yè)的類。先聲明這不是堆概念下面所有代碼都是我實際跑過并調整過的版本踩坑過程也會一并交代。1. 單io_context的“單核詛咒”為什么事件循環(huán)需要加人手1.1 run()一次只跑一個事件處理器很多人剛接觸asio時最先寫出來的就是這種代碼asio::io_context io; asio::ip::tcp::acceptor acceptor(io, endpoint); acceptor.async_accept(...); io.run();run()是一個事件循環(huán)它不斷從內部任務隊列里取出handler執(zhí)行再取下一個。關鍵就在這里——同一時間run()只能執(zhí)行一個handler。哪怕隊列里堆了幾千個事件也得排隊一個一個處理。這就像銀行只開了一個柜臺窗口。辦開戶、辦轉賬、辦掛失來再多客戶都擠在同一條隊伍里前面那位遲遲不走后面所有人只能干瞪眼。單線程的io_context就是那個唯一的窗口。那多開幾個“窗口”不就行了對但問題在于怎么開開成什么樣。asio官方文檔里有一句很樸素的話io_context是線程安全的但對同一個socket對象的并發(fā)操作不是。這句話背后的意思決定了所有多線程方案的分叉路口。1.2 什么信號說明該升級了不是所有服務都需要多線程。連接數少、回調短、單核沒打滿單線程跑得很舒服不用折騰。但出現下面幾種情況時就該認真考慮多線程模型了top里看到只有一個核接近100%其他核閑得看戲每個回調里計算量偏重比如簽名、壓縮、加解密整體吞吐上不去回調中偶爾有阻塞等待一個慢handler拖累整條事件鏈短連接頻繁建立accept、握手、讀寫事件全擠在一起。我習慣用一個簡單估算判斷臨界值假設單核每秒能處理P次事件單個連接每秒產生M次業(yè)務事件當連接數 × M開始逼近P時延遲就肉眼可見地漲了。這時候不需要精算直覺就能感受到“這個服務有點吃勁”。1.3 我犯過的錯直接把run()丟進多個線程最早我偷懶想都沒想就把同一個io_context交給四個線程跑for (int i 0; i 4; i) { threads.emplace_back([io] { io.run(); }); }表面看起來是“多線程了”跑起來之后卻問題不斷所有線程共享同一個任務隊列隊列鎖競爭很猛多核紅利被抵消大半同一個連接的回調可能這次在線程A跑下次在線程B跑連接內部的成員變量必須加鎖想用strand把同一連接串行化能行但每個連接都得小心翼翼地設計關聯稍不留神就死鎖。那次經驗讓我徹底理解了io_context的線程安全邊界管的是“多個線程可以同時調用run()”而不是“多個線程可以隨便共享IO對象”。真正要做的是把不同連接的調度分散到不同的獨立隊列中去。IOServicePool就是這個思路的標準化實現。2. ASIO多線程模型的三種姿勢我為什么選中IO池2.1 三種模型的本質區(qū)別先拉一張表把asio常見的多線程組織方式擺在一起對比模型并發(fā)機制數據競爭難度實現復雜度適合場景單io_context 多線程run()多線程共享一個任務隊列高共享對象到處要加鎖/strand低連接極少、回調極輕多io_context 每context一線程池每個io_context有獨立隊列低連接綁定單一線程中絕大多數服務端網絡場景io_context strand細粒度回調按strand分組散布到多線程中strand之間共享仍要鎖高追求極低延遲的復雜系統(tǒng)池模型的本質是把“不同連接”切分到“不同執(zhí)行上下文”。每個io_context相當于一間獨立辦公室里面只有一個線程在轉事件循環(huán)。連接A被分配到2號io_context那A的所有讀寫回調就固定在這個線程上跑天然串行不需要再為它加鎖。銀行開多個窗口就是這個道理每個窗口排各的隊客戶分流到不同窗口窗口內部還是按順序辦但整體并發(fā)能力上去了。2.2 為什么不用strand細粒度方案strand方案很靈活但它的成本在“心智負擔”。你必須為每一條連接、每一組共享數據定義邏輯串行邊界還得時刻警惕兩個strand之間的交叉調用。一旦出現A strand回調里等待B strand的結果而B strand又在等待A死鎖就靜悄悄來了。池模型簡單在拿到哪個io_context后續(xù)就固定在這個線程上跑。它把所有復雜的分組問題壓縮成了一個“取號分配”操作。對大多數業(yè)務來說連接之間本來就是相互獨立的池的天然分區(qū)已經足夠。2.3 池模型的實際覆蓋場景服務端自不必說accept到connection全鏈路都可以交給池??蛻舳送瑯舆m用比如爬蟲、網關批量建連、定時心跳任務都可以把連接分散到池中的不同io_context避免一個context承載幾百個socket時事件鏈過長。不是說池模型是萬金油。但在我接觸過的各類網關、代理、推送服務里它確實是投入產出比最高的一種好維護、掉坑少、壓測數據也不難看。3. IOServicePool實現從類結構到run循環(huán)3.1 類骨架io_context、work_guard、線程三件套下面是一個經過完整驗證的IOServicePool實現核心就三樣東西io_context數組、work_guard數組、線程數組。#include boost/asio.hpp #include thread #include vector #include memory #include atomic #include mutex #include iostream class IoServicePool { public: using IoContext boost::asio::io_context; using WorkGuard boost::asio::executor_work_guardIoContext::executor_type; explicit IoServicePool(std::size_t pool_size); ~IoServicePool(); IoServicePool(const IoServicePool) delete; IoServicePool operator(const IoServicePool) delete; IoContext get_io_context(); void stop(); private: std::vectorstd::unique_ptrIoContext io_contexts_; std::vectorWorkGuard work_guards_; std::vectorstd::thread threads_; std::atomicstd::size_t next_{0}; };構造函數里做了三件事創(chuàng)建io_context、創(chuàng)建work_guard、啟動線程。先給代碼IoServicePool::IoServicePool(std::size_t pool_size) { if (pool_size 0) { pool_size std::thread::hardware_concurrency(); if (pool_size 0) pool_size 2; } for (std::size_t i 0; i pool_size; i) { auto ctx std::make_uniqueIoContext(); work_guards_.emplace_back(boost::asio::make_work_guard(*ctx)); io_contexts_.push_back(std::move(ctx)); } for (auto ctx : io_contexts_) { threads_.emplace_back([ctx *ctx]() { try { ctx.run(); } catch (const std::exception e) { std::cerr io_context thread exception: e.what() std::endl; } catch (...) { std::cerr io_context thread unknown exception std::endl; } }); } }3.2 構造函數里藏著三個容易翻車的細節(jié)第一io_context對象地址必須穩(wěn)定。我早期用std::vectorIoContext直接存對象vector擴容時會移動對象而work_guard內部保存的executor還指著舊地址直接懸空。換成std::vectorstd::unique_ptrIoContext之后堆上對象地址永遠不變問題消失。第二work_guard必須和io_context一一對應且生命周期要覆蓋run()。work_guard的作用是讓run()在事件隊列為空時不要返回。沒有它只要某個io_context上的事件全部處理完run()立刻返回線程退出這個分區(qū)的所有連接就沒人管了。我見過一個服務上線后跑幾分鐘就“卡死”一半連接就是work_guard生命周期寫錯了地方。第三run()必須包住整個異常邊界。線程函數里如果handler拋了未捕獲異常整個進程可能直接terminate。包一層try/catch至少能保住線程不爆再配合日志就能知道哪個handler出問題。這里我用std::thread直接創(chuàng)建如果你用boost::asio::thread_pool同樣要在任務里兜住異常。3.3 get_io_context的輪詢分配連接要分到哪個io_context最簡單的做法是原子計數器輪詢IoServicePool::IoContext IoServicePool::get_io_context() { std::size_t current next_.fetch_add(1); return *io_contexts_[current % io_contexts_.size()]; }std::atomic的fetch_add是原子操作多線程同時調用也不會錯亂性能代價只有一條原子指令。輪詢策略在連接生命周期較長的服務里配合大量連接數分布足夠均勻。要不要做帶權負載均衡比如記錄每個io_context當前活躍連接數優(yōu)先分給少的那個我做過一輪實驗收益不大卻平白多了一把鎖來保護計數。連接活躍度差異造成的負載不均靠后續(xù)觀察和手動拆分高流量連接更容易解決這章后面會展開。3.4 stop清理與析構順序停池子比想象中更容易翻車。先看stop實現void IoServicePool::stop() { for (auto ctx : io_contexts_) { ctx-stop(); } for (auto t : threads_) { if (t.joinable()) t.join(); } } IoServicePool::~IoServicePool() { stop(); }io_context::stop()會終止事件循環(huán)讓run()盡快返回。但要記住stop()不能立刻取消正在執(zhí)行的handler它只能保證不再處理新任務。所以stop后join線程時要允許當前正在跑的handler執(zhí)行完。成員聲明順序是io_contexts_、work_guards_、threads_析構時按相反順序銷毀也就是threads_先析構。如果一個線程還在run()里沒退出直接析構std::thread對象會觸發(fā)terminate。所以析構函數里必須先stop再join這是硬性要求不能省。另一個細節(jié)如果你忘記了手動stop析構函數要能兜底這就是上面代碼里~IoServicePool()存在的意義。很多人把stop()只寫在業(yè)務層析構時漏掉程序退出時偶發(fā)崩潰查起來很費勁。4. 把池子接入服務acceptor與連接的分配策略4.1 一個基礎的分配示例池子本身只是“線程容器”真正用得順手還要把連接分配邏輯接進來。下面是一個最通用的模式主線程或單獨線程跑acceptor每來一個連接從池里取出一個io_context把后續(xù)操作post過去。void start_accept(tcp::acceptor acceptor, IoServicePool pool) { acceptor.async_accept([acceptor, pool](boost::system::error_code ec, tcp::socket sock) { if (!ec) { auto target_ctx pool.get_io_context(); asio::post(target_ctx, [target_ctx, sock std::move(sock)]() mutable { auto session std::make_sharedSession(target_ctx, std::move(sock)); session-start(); }); } start_accept(acceptor, pool); }); }這里asio::post的作用是把“創(chuàng)建會話”這個任務投遞到目標io_context的隊列里讓目標線程執(zhí)行。表面上連接就“歸”2號線程管了。但如果你真的把這段代碼直接拿去壓測大概率會遇到詭異問題——原因就在下一小節(jié)。4.2 連接遷移的兩種實踐post回調與底層描述符重點來了asio的socket對象它的執(zhí)行器executor在構造時就被綁定到了某個io_context上移動構造socket并不會改變這個執(zhí)行器。所以上面代碼里sock雖然被move到了post的lambda里但socket內部綁定的還是acceptor所屬的舊io_context。你在Session里調sock.async_read_some完成的handler默認會被投遞回舊io_context執(zhí)行。結果就是看起來連接分給了2號線程實際IO回調還在舊線程分區(qū)直接失效。兩種可靠的處理方式方式A邏輯遷移用bind_executor把后續(xù)回調綁到目標io_context。asio::post(target_ctx, [target_ctx, sock std::move(sock)]() mutable { auto session std::make_sharedSession(target_ctx, std::move(sock)); session-start(); // 內部 async_read 時用 asio::bind_executor(target_ctx.get_executor(), handler) });這種方式不改socket的歸屬只是讓每個異步操作的完成handler在目標io_context上排隊執(zhí)行。底層的IO準備工作仍由原io_context負責但業(yè)務回調序列跑在目標線程絕大多數場景已經夠用。方式B徹底遷移把底層fd搬到新io_context。asio::ip::tcp::socket new_socket(target_ctx); new_socket.assign(asio::ip::tcp::v4(), sock.native_handle()); // 原 sock 析構或先 cancel避免重復管理 fdassign把系統(tǒng)fd直接掛到新socket上從此整個連接的收發(fā)都由目標io_context承擔。這是最徹底的分區(qū)也是高并發(fā)服務里我更推薦的方式。注意操作前要確保原socket上沒有未完成的異步操作否則兩個socket操作同一個fd會出亂子。這個細節(jié)網上很多教程都不講只看“post過去”就覺得萬事大吉等壓測一上才發(fā)現性能根本沒提上去。你要是能避開已經比很多人穩(wěn)了。4.3 客戶端、定時器與心跳任務也能用池服務端說完客戶端同樣可以吃這套紅利。比如一個批量采集網關要同時維持幾百個上游連接完全可以用池每個連接固定占用一個io_context某個io_context掛了只影響那一批連接不會拖垮全局。定時器任務也可以按分區(qū)跑void start_heartbeat(IoServicePool pool) { for (auto ctx : pool.all_contexts()) { auto timer std::make_sharedasio::steady_timer(*ctx); timer-expires_after(std::chrono::seconds(5)); timer-async_wait([](error_code ec) { if (ec ! asio::error::operation_aborted) { // 掃描本分區(qū)連接發(fā)心跳 } }); } }心跳掃描的是“本io_context上的連接”天然無鎖。但如果多個io_context都要更新同一個全局狀態(tài)比如在線人數、路由表那就必須給共享狀態(tài)加鎖或者用一個獨立的小io_context專門做狀態(tài)維護其他線程通過post投遞更新請求。這個邊界心里要清楚池內無鎖不代表池間無鎖。5. 實測數據、坑位清單與調優(yōu)思路5.1 模擬項目X的壓測對比為了驗證效果我在模擬項目X上做了一組對照壓測。場景是回顯服務加一點模擬業(yè)務計算16核容器同為異步模式模型QPS大約P99延遲大約單io_context單線程2.8萬6.1ms池4線程6.5萬3.2ms池8線程9.2萬2.4ms池16線程9.5萬2.5ms四線程相比單線程提升一倍多八線程提升到三倍左右但繼續(xù)加到16線程收益就非常有限了。原因不難猜核數上到16后鎖競爭、緩存失效、線程切換的開銷開始吃掉并行收益。線程數不是越多越好通常等于可用核數就是甜點位。在虛擬化環(huán)境里還要注意hardware_concurrency()可能返回宿主核數實際容器可用核數少得多最好從配置或環(huán)境變量讀取。5.2 坑位之一run線程異常會靜默消失有一次某服務線上連接數突然少了一半日志里沒有任何error。排查到后面才發(fā)現某個handler里拋了異常run()線程直接退出整個io_context分區(qū)上所有連接因為沒人處理事件而超時斷開。這個現象非常隱蔽因為線程退出不會打印任何東西。解決方案就是構造函數里的try/catch包裹還要在catch里記錄線程ID、異常信息甚至可以考慮在檢測到線程退出后重建這個分區(qū)的io_context。建議生產環(huán)境再套一層監(jiān)控定期檢查線程數組是否還活著。5.3 坑位之二跨io_context的promise/future一個“等死”白局某開發(fā)者在業(yè)務回調里寫了這樣一個操作往另一個io_context投遞一個任務然后用std::future::wait()等著結果返回。理想很豐滿現實是第一個io_context的線程在等結果第二個io_context的線程在處理任務處理完想回調時結果又要通過原線程完成下一步而原線程還在阻塞等待。兩邊都在等服務直接卡死。這不是說不能用promise/future而是不要在事件循環(huán)線程里同步等待另一個事件循環(huán)的結果。換異步回調或者用一個獨立的業(yè)務線程池處理同步等待場景別把等待放在run()線程里。這條規(guī)則只要是asio多線程模型都適用。5.4 坑位之三輪詢分配連接平均但負載不平均輪詢分配保證的是連接數量平均不是負載平均。如果一個io_context上分到了幾個大流量長連接它的CPU會明顯高于其他分區(qū)。觀察手段很直接top里看每個線程的CPU占用或者在每個io_context的回調入口做計數打點。遇到明顯的熱點分區(qū)我建議的做法是不搞復雜的動態(tài)遷移而是把熱連接的業(yè)務拆細或者把耗時操作挪出事件循環(huán)讓回調輕量化。連接一旦建立把socket從一個io_context遷到另一個成本不低頻繁遷移得不償失。5.5 耗時操作的處理建議很多服務卡頓不是并發(fā)不夠而是某個callback把事件循環(huán)堵了幾十毫秒。一次數據庫查詢、一次遠程調用、一次大文件讀寫只要這些操作同步出現在run()線程里整個分區(qū)的連接都在陪你等。我的習慣是把耗時操作交給獨立的業(yè)務線程池比如asio::thread_pool處理完再把結果post回連接所屬的io_contextasio::post(business_pool_, [this, msg std::move(msg)]() mutable { auto result slow_business_process(msg); asio::post(conn_ctx_, [this, result std::move(result)]() mutable { async_write(result); }); });這樣事件循環(huán)線程永遠只做輕量工作讀數據、轉發(fā)、寫數據。真正重的活兒在別處干完再送回來分區(qū)內其他連接就不會被拖下水。最后再分享一個小技巧。我第一次把work_guard放在類的尾部聲明析構時線程先被銷毀、guard還在直接觸發(fā)terminate。后來養(yǎng)成了習慣析構函數里無論如何都要stop()先行join收尾。你若想把池子做成動態(tài)伸縮版本也是在這個核心結構上做文章。IOServicePool不是萬金油但在絕大多數網絡服務里它提供了最耐看的線程模型——分區(qū)明確、維護簡單、壓測數據不丟人。希望這篇能把你的服務也從“單核詛咒”里救出來。