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