)
簡(jiǎn)介本資源面向嵌入式開發(fā)與C/C后端初學(xué)者提供一份基于mongoose庫(kù)實(shí)現(xiàn)多線程HTTP服務(wù)器的完整工程源碼幫助理解輕量級(jí)Web服務(wù)器如何通過線程池提升并發(fā)處理能力。壓縮包共11個(gè)文件約221KB以cpp與h源碼為主包含mongoose核心實(shí)現(xiàn)、服務(wù)器主程序及VS2008工程文件另附可執(zhí)行程序與說明文檔便于直接編譯運(yùn)行和對(duì)照閱讀。內(nèi)容涉及線程池設(shè)計(jì)、互斥鎖與條件變量同步、請(qǐng)求隊(duì)列分發(fā)及多線程錯(cuò)誤處理等要點(diǎn)并保留mongoose 6.7版本代碼對(duì)理解舊版多線程實(shí)現(xiàn)方式及后續(xù)版本遷移具有參考價(jià)值。目前已有3851人學(xué)習(xí)下載適合希望掌握mongoose API、多線程編程模型與高并發(fā)服務(wù)器基礎(chǔ)架構(gòu)的開發(fā)者研讀。1. 從零手搓一個(gè)多線程 Http Server為什么 mongoose 是那個(gè)被低估的選項(xiàng)如果你寫過嵌入式設(shè)備上的 Web 配置頁(yè)或者給某個(gè) C 桌面工具加過一個(gè)本地調(diào)試接口大概率繞不開一個(gè)需求用盡量少的依賴起一個(gè)能同時(shí)扛住多個(gè)瀏覽器連接的小型 Http Server。很多人第一反應(yīng)是上 Boost.Beast 或者直接手寫 socket但真到落地時(shí)會(huì)發(fā)現(xiàn)前者編譯鏈太重后者光是解析請(qǐng)求行和 header 就能耗掉兩天。mongoose 這個(gè)庫(kù)在國(guó)內(nèi)討論度不算高但它在「單文件、無外部依賴、C/C 通吃」這條賽道上幾乎沒有對(duì)手。標(biāo)題里的「多線程」不是指 mongoose 自己開線程池而是指我們?nèi)绾伟?mongoose 的事件循環(huán)和業(yè)務(wù)線程池組合起來讓一個(gè) Http Server 在并發(fā)請(qǐng)求下不阻塞、不串?dāng)?shù)據(jù)、不莫名其妙地?cái)噙B。這篇文章面向的是需要快速交付一個(gè)可嵌入 Http 服務(wù)的 C/C 工程師尤其是那些跑在資源受限環(huán)境、又不想引入整套框架的人。mongoose 的核心設(shè)計(jì)是「事件驅(qū)動(dòng) 非阻塞」它把網(wǎng)絡(luò) IO、定時(shí)器、協(xié)議解析都收在一個(gè)mg_mgr事件管理器里。單線程跑事件循環(huán)時(shí)所有回調(diào)都在同一個(gè)線程執(zhí)行一旦某個(gè)回調(diào)里做了耗時(shí)操作比如讀大文件、查數(shù)據(jù)庫(kù)整個(gè)服務(wù)就會(huì)卡住。這就是為什么標(biāo)題強(qiáng)調(diào)多線程——不是 mongoose 內(nèi)置了多線程而是我們要在它的事件循環(huán)之上把耗時(shí)業(yè)務(wù)甩到工作線程同時(shí)保證響應(yīng)能安全地回寫到連接上。理解這一點(diǎn)后面的選型、代碼和踩坑才有落腳點(diǎn)。2. mongoose 事件模型與多線程方案的選型理由2.1 為什么不能直接在回調(diào)里開線程處理請(qǐng)求mongoose 的連接對(duì)象mg_connection生命周期完全由事件管理器掌控。當(dāng)你在MG_EV_HTTP_MSG回調(diào)里拿到mg_http_message這個(gè)結(jié)構(gòu)體里的指針指向的是 mongoose 內(nèi)部接收緩沖區(qū)回調(diào)返回后緩沖區(qū)可能被復(fù)用或釋放。如果你把mg_http_message*直接丟給另一個(gè)線程等那個(gè)線程慢悠悠處理完再回來讀大概率讀到的是已經(jīng)被覆蓋的臟數(shù)據(jù)甚至觸發(fā)段錯(cuò)誤。這是最常見的翻車點(diǎn)很多人第一次做多線程 mongoose 就栽在這里。正確的做法是在回調(diào)里同步完成請(qǐng)求內(nèi)容的拷貝把需要的信息方法、URI、header、body復(fù)制到自己的結(jié)構(gòu)體里然后把任務(wù)投遞到線程池。工作線程只操作這份拷貝處理完后不能直接調(diào)用mg_http_reply或mg_send因?yàn)?mongoose 的發(fā)送接口不是線程安全的。必須把響應(yīng)數(shù)據(jù)再投遞回事件循環(huán)線程由事件循環(huán)線程統(tǒng)一發(fā)送。2.2 兩種主流架構(gòu)單事件循環(huán) 工作線程池 vs 多事件循環(huán)常見做法有兩種。第一種是單事件循環(huán) 工作線程池主線程跑mg_mgr_poll工作線程只做計(jì)算結(jié)果通過線程安全隊(duì)列回傳主線程在每次 poll 之前或之后檢查隊(duì)列并發(fā)送響應(yīng)。這種方案邏輯簡(jiǎn)單連接狀態(tài)只在一個(gè)線程里被修改幾乎不會(huì)出現(xiàn)競(jìng)態(tài)。缺點(diǎn)是所有網(wǎng)絡(luò) IO 仍然串行如果響應(yīng)體很大、發(fā)送本身耗時(shí)主線程會(huì)成為瓶頸。第二種是多事件循環(huán)每個(gè)線程一個(gè)mg_mgr用SO_REUSEPORT讓多個(gè)線程各自監(jiān)聽同一端口內(nèi)核負(fù)責(zé)分發(fā)新連接。這種方案能充分利用多核但連接一旦建立就固定在某一個(gè)線程上線程間不需要共享連接狀態(tài)反而更干凈。缺點(diǎn)是跨線程廣播比如向所有連接推送消息需要額外機(jī)制而且SO_REUSEPORT在部分平臺(tái)上行為有差異。我一般會(huì)先上第一種因?yàn)榻^大多數(shù)「多線程 Http Server」的真實(shí)瓶頸在業(yè)務(wù)處理而不是網(wǎng)絡(luò)收發(fā)。只有當(dāng)壓測(cè)顯示mg_mgr_poll本身占用過高時(shí)才考慮第二種。下面給出第一種方案的完整骨架。2.3 線程安全隊(duì)列的最小實(shí)現(xiàn)跨線程傳遞任務(wù)和響應(yīng)需要一個(gè)線程安全隊(duì)列。不要用std::queue加一把大鎖就完事那樣在高并發(fā)下鎖競(jìng)爭(zhēng)會(huì)很嚴(yán)重。常見做法是用std::mutexstd::condition_variablestd::deque配合移動(dòng)語(yǔ)義減少拷貝。下面是一個(gè)夠用的實(shí)現(xiàn)// thread_safe_queue.h #pragma once #include deque #include mutex #include condition_variable #include optional template typename T class ThreadSafeQueue { public: void push(T item) { { std::lock_guardstd::mutex lock(mtx_); queue_.push_back(std::move(item)); } cond_.notify_one(); // 喚醒一個(gè)等待的消費(fèi)者 } // 非阻塞彈出事件循環(huán)線程用這個(gè)避免卡住 poll std::optionalT try_pop() { std::lock_guardstd::mutex lock(mtx_); if (queue_.empty()) return std::nullopt; T item std::move(queue_.front()); queue_.pop_front(); return item; } // 阻塞彈出工作線程用這個(gè) T wait_pop() { std::unique_lockstd::mutex lock(mtx_); cond_.wait(lock, [this] { return !queue_.empty(); }); T item std::move(queue_.front()); queue_.pop_front(); return item; } private: std::dequeT queue_; std::mutex mtx_; std::condition_variable cond_; };邏輯說明push在鎖內(nèi)完成入隊(duì)后立刻釋放鎖再notify_one避免喚醒的線程因?yàn)槟貌坏芥i又立刻阻塞。try_pop給事件循環(huán)線程用保證mg_mgr_poll不會(huì)被隊(duì)列阻塞。wait_pop給工作線程用沒有任務(wù)時(shí)休眠不空轉(zhuǎn)燒 CPU。參數(shù)上T建議是std::shared_ptr或可移動(dòng)的小結(jié)構(gòu)體避免在鎖內(nèi)做深拷貝。3. 用 mongoose 跑通多線程 Http Server 的最小可運(yùn)行代碼3.1 請(qǐng)求上下文與響應(yīng)結(jié)構(gòu)體的定義先定義跨線程傳遞的數(shù)據(jù)。請(qǐng)求上下文包含連接標(biāo)識(shí)和拷貝后的請(qǐng)求內(nèi)容響應(yīng)結(jié)構(gòu)體包含連接標(biāo)識(shí)、狀態(tài)碼、內(nèi)容類型和 body。連接標(biāo)識(shí)不能用mg_connection*裸指針跨線程傳遞因?yàn)檫B接可能在工作線程處理期間已經(jīng)斷開。常見做法是給每個(gè)連接分配一個(gè)自增 ID在mg_connection的fn_data或自定義結(jié)構(gòu)里保存這個(gè) ID發(fā)送響應(yīng)前先校驗(yàn)連接是否還存在。// context.h #pragma once #include string #include map #include cstdint struct RequestContext { uint64_t conn_id; // 連接唯一標(biāo)識(shí) std::string method; // GET / POST std::string uri; // 請(qǐng)求路徑 std::string body; // 請(qǐng)求體拷貝 std::mapstd::string, std::string headers; // 需要的 header 拷貝 }; struct ResponseContext { uint64_t conn_id; int status_code; std::string content_type; std::string body; };邏輯說明conn_id是跨線程追蹤連接的唯一憑據(jù)。headers只拷貝業(yè)務(wù)需要的字段不要全量拷貝否則大請(qǐng)求下內(nèi)存放大明顯。body用std::string持有確保生命周期獨(dú)立于 mongoose 緩沖區(qū)。3.2 事件循環(huán)線程的完整骨架事件循環(huán)線程負(fù)責(zé)初始化mg_mgr、注冊(cè) HTTP 監(jiān)聽、在每次 poll 后處理響應(yīng)隊(duì)列、發(fā)送響應(yīng)。關(guān)鍵點(diǎn)是發(fā)送前校驗(yàn)連接是否仍然有效。// server.cpp #include mongoose.h #include thread_safe_queue.h #include context.h #include thread #include vector #include atomic #include unordered_map static ThreadSafeQueueRequestContext g_req_queue; static ThreadSafeQueueResponseContext g_resp_queue; static std::atomicuint64_t g_next_conn_id{1}; static std::unordered_mapuint64_t, mg_connection* g_conn_map; // 僅事件循環(huán)線程訪問 // 每個(gè)連接的自定義數(shù)據(jù) struct ConnData { uint64_t conn_id; }; static void handle_http_event(mg_connection *c, int ev, void *ev_data) { if (ev MG_EV_HTTP_MSG) { auto *hm (mg_http_message *)ev_data; auto *cd (ConnData *)c-fn_data; if (!cd) return; RequestContext ctx; ctx.conn_id cd-conn_id; ctx.method.assign(hm-method.buf, hm-method.len); ctx.uri.assign(hm-uri.buf, hm-uri.len); ctx.body.assign(hm-body.buf, hm-body.len); // 按需拷貝 header這里以 Content-Type 為例 auto ct mg_http_get_header(hm, Content-Type); if (ct) ctx.headers[Content-Type].assign(ct-buf, ct-len); g_req_queue.push(std::move(ctx)); // 投遞給工作線程 } else if (ev MG_EV_CLOSE) { auto *cd (ConnData *)c-fn_data; if (cd) { g_conn_map.erase(cd-conn_id); // 連接關(guān)閉移除映射 delete cd; c-fn_data nullptr; } } } static void handle_listen(mg_connection *c, int ev, void *ev_data) { if (ev MG_EV_ACCEPT) { auto *cd new ConnData(); cd-conn_id g_next_conn_id.fetch_add(1); c-fn_data cd; g_conn_map[cd-conn_id] c; } handle_http_event(c, ev, ev_data); } int main() { mg_mgr mgr; mg_mgr_init(mgr); mg_connection *listener mg_http_listen(mgr, http://0.0.0.0:8000, handle_listen, nullptr); if (!listener) return 1; // 啟動(dòng)工作線程 std::vectorstd::thread workers; for (int i 0; i 4; i) { workers.emplace_back([] { while (true) { RequestContext req g_req_queue.wait_pop(); ResponseContext resp; resp.conn_id req.conn_id; // 這里替換成真實(shí)業(yè)務(wù)邏輯 if (req.uri /hello) { resp.status_code 200; resp.content_type text/plain; resp.body hello from worker std::to_string(std::hashstd::thread::id{}(std::this_thread::get_id())); } else { resp.status_code 404; resp.content_type text/plain; resp.body not found; } g_resp_queue.push(std::move(resp)); } }); } while (true) { mg_mgr_poll(mgr, 50); // 50ms 超時(shí)保證響應(yīng)隊(duì)列能被及時(shí)處理 // 處理工作線程回傳的響應(yīng) while (auto resp g_resp_queue.try_pop()) { auto it g_conn_map.find(resp-conn_id); if (it g_conn_map.end()) continue; // 連接已斷開丟棄響應(yīng) mg_connection *c it-second; mg_http_reply(c, resp-status_code, (Content-Type: resp-content_type \r\n).c_str(), %s, resp-body.c_str()); } } for (auto t : workers) t.join(); mg_mgr_free(mgr); return 0; }邏輯說明handle_listen在MG_EV_ACCEPT時(shí)給連接分配 ID 并存入g_conn_map這個(gè) map 只在事件循環(huán)線程讀寫不需要加鎖。handle_http_event在收到完整 HTTP 消息時(shí)拷貝請(qǐng)求并投遞注意hm-body可能為空assign對(duì)空指針和零長(zhǎng)度是安全的。主循環(huán)里mg_mgr_poll的超時(shí)設(shè)為 50ms這個(gè)值決定了響應(yīng)回傳的最大延遲太小會(huì)空轉(zhuǎn)太大會(huì)增加延遲50ms 是常見折中。發(fā)送前用g_conn_map.find校驗(yàn)連接避免向已關(guān)閉連接寫入導(dǎo)致崩潰。參數(shù)說明監(jiān)聽地址http://0.0.0.0:8000中0.0.0.0表示所有網(wǎng)卡端口按需改。工作線程數(shù) 4 是經(jīng)驗(yàn)值一般設(shè)為 CPU 核心數(shù)或核心數(shù)加一。mg_mgr_poll的超時(shí)單位是毫秒。3.3 編譯與運(yùn)行驗(yàn)證mongoose 是單文件庫(kù)把mongoose.c和mongoose.h放到工程里即可。用 CMake 或直接 g 編譯g -stdc17 -O2 -pthread server.cpp mongoose.c -o http_server ./http_server然后用 curl 驗(yàn)證并發(fā)# 開 20 個(gè)并發(fā)請(qǐng)求觀察是否都能正確返回 for i in $(seq 1 20); do curl -s http://127.0.0.1:8000/hello done; wait如果每個(gè)請(qǐng)求都返回了帶不同線程 ID 的 hello說明多線程分發(fā)已經(jīng)生效。如果出現(xiàn)連接重置或空響應(yīng)優(yōu)先檢查g_conn_map的清理邏輯和響應(yīng)發(fā)送時(shí)機(jī)。4. 多線程 mongoose 的避坑與排查清單4.1 現(xiàn)象偶發(fā)段錯(cuò)誤堆棧指向 mg_http_reply原因工作線程直接調(diào)用了mg_http_reply或者事件循環(huán)線程在連接已關(guān)閉后仍然發(fā)送。mongoose 的所有發(fā)送接口都必須在擁有該連接的事件循環(huán)線程調(diào)用且連接必須仍然有效。解決嚴(yán)格遵循「工作線程只計(jì)算事件循環(huán)線程只發(fā)送」的原則。發(fā)送前用連接 ID 查表校驗(yàn)查不到就丟棄響應(yīng)。不要試圖給mg_connection加鎖鎖保護(hù)不了已經(jīng)被釋放的內(nèi)存。4.2 現(xiàn)象請(qǐng)求體讀到亂碼或空字符串原因把mg_http_message的指針傳給了工作線程回調(diào)返回后緩沖區(qū)被復(fù)用。mongoose 的接收緩沖區(qū)是復(fù)用的hm-body.buf指向的內(nèi)存在下一次 poll 時(shí)可能被覆蓋。解決在回調(diào)內(nèi)同步完成所有需要的數(shù)據(jù)拷貝。body 用std::string::assign(hm-body.buf, hm-body.len)header 用mg_http_get_header后同樣拷貝??截惓杀具h(yuǎn)低于調(diào)試亂碼的時(shí)間。4.3 現(xiàn)象高并發(fā)下響應(yīng)延遲飆升CPU 占用卻不高原因mg_mgr_poll的超時(shí)設(shè)得太大響應(yīng)隊(duì)列里的數(shù)據(jù)要等 poll 返回后才被處理。如果 poll 超時(shí)是 1000ms最壞情況下響應(yīng)會(huì)延遲 1 秒。解決把 poll 超時(shí)降到 10 到 50ms 之間。如果業(yè)務(wù)對(duì)延遲極敏感可以在工作線程投遞響應(yīng)后通過管道或 eventfd 喚醒事件循環(huán)讓 poll 立即返回。mongoose 支持mg_wakeup但跨線程使用需要謹(jǐn)慎常見做法還是靠短超時(shí)輪詢。4.4 現(xiàn)象連接數(shù)一多就內(nèi)存暴漲原因g_conn_map只增不減或者ConnData在連接關(guān)閉時(shí)沒有釋放。MG_EV_CLOSE事件里必須清理映射并 delete 自定義數(shù)據(jù)。解決在MG_EV_CLOSE里做三件事從g_conn_map擦除、deleteConnData、把c-fn_data置空。注意MG_EV_CLOSE可能對(duì)同一個(gè)連接觸發(fā)多次置空后要判空再操作。4.5 現(xiàn)象多線程下日志交錯(cuò)難以定位問題原因多個(gè)工作線程同時(shí)往 stdout 寫日志內(nèi)容互相穿插。解決日志要么加鎖要么每個(gè)線程寫?yīng)毩⑽募从脽o鎖隊(duì)列把日志投遞到單獨(dú)線程輸出。調(diào)試階段建議先單線程跑通邏輯再開多線程否則日志本身就是干擾源。5. 進(jìn)階把響應(yīng)發(fā)送也做成異步與背壓控制前面骨架里事件循環(huán)線程在try_pop到響應(yīng)后立即調(diào)用mg_http_reply。如果響應(yīng)體很大比如幾 MB 的文件mg_http_reply內(nèi)部會(huì)嘗試一次性寫入可能阻塞事件循環(huán)。更穩(wěn)的做法是把大響應(yīng)拆成「發(fā)送任務(wù)」由事件循環(huán)線程分片發(fā)送并在連接可寫時(shí)繼續(xù)。mongoose 提供mg_send和MG_EV_WRITE事件可以配合實(shí)現(xiàn)背壓。一個(gè)具體技巧是給每個(gè)連接維護(hù)一個(gè)待發(fā)送隊(duì)列在MG_EV_WRITE里檢查隊(duì)列并繼續(xù)發(fā)送發(fā)送隊(duì)列為空時(shí)才停止關(guān)注可寫事件。這樣即使工作線程瞬間產(chǎn)出大量響應(yīng)事件循環(huán)也不會(huì)被單個(gè)大響應(yīng)卡死。參數(shù)上單次mg_send的切片大小建議 16KB 到 64KB太小會(huì)增加系統(tǒng)調(diào)用次數(shù)太大則失去分片意義。驗(yàn)證方法用ab或wrk壓測(cè)觀察 P99 延遲和事件循環(huán)單次 poll 的耗時(shí)。如果 poll 耗時(shí)超過 10ms說明有阻塞操作混在事件循環(huán)里需要繼續(xù)拆分。我自己的習(xí)慣是任何在事件循環(huán)線程里超過 1ms 的操作都要打日志標(biāo)記定期 review 這些標(biāo)記點(diǎn)。多線程 mongoose 的難點(diǎn)從來不是線程本身而是邊界——哪些數(shù)據(jù)能跨線程、哪些接口只能在哪個(gè)線程調(diào)、連接什么時(shí)候會(huì)消失。把這三件事守住剩下的就是業(yè)務(wù)邏輯的體力活。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取