《多線(xiàn)程服務(wù)器的適用場(chǎng)合》-- 例釋與答疑作者: 陳碩 (3 篇文章) 日期: 四月 28, 2010 在 9:31 下午《多線(xiàn)程服務(wù)器的適用場(chǎng)合》(以下簡(jiǎn)稱(chēng)《適用場(chǎng)合》)一文在博客登出之后,有熱心讀者提出質(zhì)疑,,我自己也覺(jué)得原文沒(méi)有把道理說(shuō)通說(shuō)透,,這篇文章試 圖用一些實(shí)例來(lái)解答讀者的疑問(wèn)。我本來(lái)打算修改原文,,但是考慮到已經(jīng)讀過(guò)的讀者不一定會(huì)注意到文章的變動(dòng),,干脆另寫(xiě)一篇。為方便閱讀,,本文以問(wèn)答體呈現(xiàn),。 這篇文章可能會(huì)反復(fù)修改擴(kuò)充,請(qǐng)注意上面的版本號(hào),。 本文所說(shuō)的“多線(xiàn)程服務(wù)器”的定義與前文一樣,,同時(shí)參見(jiàn)《多線(xiàn)程服務(wù)器的常用編程模型》(以下簡(jiǎn)稱(chēng)《常用模型》)一文的詳細(xì)界定,,以下“連接,、端口”均指 TCP 協(xié)議。 1. Linux 能同時(shí)啟動(dòng)多少個(gè)線(xiàn)程,? 對(duì)于 64-bit 系統(tǒng),線(xiàn)程數(shù)目可大大增加,,具體數(shù)字我沒(méi)有測(cè)試,,因?yàn)槲覍?shí)際用不到那么多線(xiàn)程。 以下的關(guān)于線(xiàn)程數(shù)目的討論以 32-bit Linux 為例,。 2. 多線(xiàn)程能提高并發(fā)度嗎,? 由問(wèn)題 1 可知,,假如單純采用 thread per connection 的模型,那么并發(fā)連接數(shù)最多 300,這遠(yuǎn)遠(yuǎn)低于基于事件的單線(xiàn)程程序所能輕松達(dá)到的并發(fā)連接數(shù)(幾千上萬(wàn),,甚至幾萬(wàn)),。所謂“基于事件”,指的是用 IO multiplexing event loop 的編程模型,,又稱(chēng) Reactor 模式,,在《常用模型》一文中已有介紹。 那么采用《常用模型》一文中推薦的 event loop per thread 呢,?至少不遜于單線(xiàn)程程序,。 小結(jié):thread per connection 不適合高并發(fā)場(chǎng)合,其 scalability 不佳,。event loop per thread 的并發(fā)度不比單線(xiàn)程程序差,。 3. 多線(xiàn)程能提高吞吐量嗎? 假設(shè)有一個(gè)耗時(shí)的計(jì)算服務(wù),用單線(xiàn)程算需要 0.8s,。在一臺(tái) 8 核的機(jī)器上,,我們可以啟動(dòng) 8 個(gè)線(xiàn)程一起對(duì)外服務(wù)(如果內(nèi)存夠用,啟動(dòng) 8 個(gè)進(jìn)程也一樣),。這樣完成單個(gè)計(jì)算仍然要 0.8s,,但是由于這些進(jìn)程的計(jì)算可以同時(shí)進(jìn)行,理想情況下吞吐量可以從單線(xiàn)程的 1.25cps (calc per second) 上升到 10cps,。(實(shí)際情況可能要打個(gè)八折——如果不是打?qū)φ鄣脑?huà),。) 假如改用并行算法,用 8 個(gè)核一起算,,理論上如果完全并行,,加速比高達(dá) 8,那么計(jì)算時(shí)間是 0.1s,,吞吐量還是 10cps,,但是首次請(qǐng)求的響應(yīng)時(shí)間卻降低了很多。實(shí)際上根據(jù) Amdahl's law,,即便算法的并行度高達(dá) 95%,,8 核的加速比也只有 6,計(jì)算時(shí)間為 0.133s,,這樣會(huì)造成吞吐量下降為 7.5cps,。不過(guò)以此為代價(jià),換得響應(yīng)時(shí)間的提升,,在有些應(yīng)用場(chǎng)合也是值得的,。 這也回答了問(wèn)題 4,。 如果用 thread per request 的模型,每個(gè)客戶(hù)請(qǐng)求用一個(gè)線(xiàn)程去處理,,那么當(dāng)并發(fā)請(qǐng)求數(shù)大于某個(gè)臨界值 T’ 時(shí),,吞吐量反而會(huì)下降,因?yàn)榫€(xiàn)程多了以后上下文切換的開(kāi)銷(xiāo)也隨之增加(分析與數(shù)據(jù)請(qǐng)見(jiàn)《A Design Framework for Highly Concurrent Systems》 by Matt Welsh et al.),。thread per request 是最簡(jiǎn)單的使用線(xiàn)程的方式,,編程最容易,簡(jiǎn)單地把多線(xiàn)程程序當(dāng)成一堆串行程序,,用同步的方式順序編程,,比如 Java Servlet 中,一次頁(yè)面請(qǐng)求由一個(gè)函數(shù) HttpServlet#service(HttpServletRequest req, HttpServletResponse resp) 同步地完成,。 為了在并發(fā)請(qǐng)求數(shù)很高時(shí)也能保持穩(wěn)定的吞吐量,,我們可以用線(xiàn)程池,線(xiàn)程池的大小應(yīng)該滿(mǎn)足“阻抗匹配原則”,,見(jiàn)問(wèn)題 7,。 線(xiàn)程池也不是萬(wàn)能的,如果響應(yīng)一次請(qǐng)求需要做比較多的計(jì)算(比如計(jì)算的時(shí)間占整個(gè) response time 的 1/5 強(qiáng)),,那么用線(xiàn)程池是合理的,,能簡(jiǎn)化編程,。如果一次請(qǐng)求響應(yīng)中,,thread 主要是在等待 IO,那么為了進(jìn)一步提高吞吐,,往往要用其它編程模型,,比如 Proactor,見(jiàn)問(wèn)題 8,。 4. 多線(xiàn)程能降低響應(yīng)時(shí)間嗎,? 例1: 多線(xiàn)程處理輸入,。 以 memcached 服務(wù)端為例,。memcached 一次請(qǐng)求響應(yīng)大概可以分為 3 步: 1.讀取并解析客戶(hù)端輸入 比如,,有兩個(gè)用戶(hù)同時(shí)發(fā)出了請(qǐng)求,這兩個(gè)用戶(hù)的連接正好分配在兩個(gè) IO 線(xiàn)程上,,那么兩個(gè)請(qǐng)求的第 1 步操作可以在兩個(gè)線(xiàn)程上并行執(zhí)行,,然后匯總到第 2 步串行執(zhí)行,這樣總的響應(yīng)時(shí)間比完全串行執(zhí)行要短一些(在“讀取并解析”所占的比重較大的時(shí)候,,效果更為明顯),。請(qǐng)繼續(xù)看下面這個(gè)例子。 例2: 多線(xiàn)程分擔(dān)負(fù)載,。 假設(shè)我們要做一個(gè)求解 Sudoku 的服務(wù)(見(jiàn)《談?wù)剶?shù)獨(dú)》),,這個(gè)服務(wù)程序在 9981 端口接受請(qǐng)求,輸入為一行 81 個(gè)數(shù)字(待填數(shù)字用 0 表示),,輸出為填好之后的 81 個(gè)數(shù)字 (1 ~ 9),,如果無(wú)解,輸出 “NO\r\n”,。 由于輸入格式很簡(jiǎn)單,,用單個(gè)線(xiàn)程做 IO 就行了。先假設(shè)每次求解的計(jì)算用時(shí) 10ms,,用前面的方法計(jì)算,,單線(xiàn)程程序能達(dá)到的吞吐量上限為 100req/s,在 8 核機(jī)器上,,如果用線(xiàn)程池來(lái)做計(jì)算,,能達(dá)到的吞吐量上限為 800req/s。下面我們看看多線(xiàn)程如何降低響應(yīng)時(shí)間,。 假設(shè) 1 個(gè)用戶(hù)在極短的時(shí)間內(nèi)發(fā)出了 10 個(gè)請(qǐng)求,,如果用單線(xiàn)程“來(lái)一個(gè)處理一個(gè)”的模型,這些 reqs 會(huì)排在隊(duì)列里依次處理(這個(gè)隊(duì)列是操作系統(tǒng)的 TCP 緩沖區(qū),,不是程序里自己的任務(wù)隊(duì)列),。在不考慮網(wǎng)絡(luò)延遲的情況下,第 1 個(gè)請(qǐng)求的響應(yīng)時(shí)間是 10ms,;第 2 個(gè)請(qǐng)求要等第 1 個(gè)算完了才能獲得 CPU 資源,,它等了 10ms,算了 10ms,,響應(yīng)時(shí)間是 20ms,;依次類(lèi)推,,第 10 個(gè)請(qǐng)求的響應(yīng)時(shí)間為 100ms,;10個(gè)請(qǐng)求的平均響應(yīng)時(shí)間為 55ms。 如果 Sudoku 服務(wù)在每個(gè)請(qǐng)求到達(dá)時(shí)開(kāi)始計(jì)時(shí),,會(huì)發(fā)現(xiàn)每個(gè)請(qǐng)求都是 10ms 響應(yīng)時(shí)間,,而從用戶(hù)的觀點(diǎn),10 個(gè)請(qǐng)求的平均響應(yīng)時(shí)間為 55ms,,請(qǐng)讀者想想為什么會(huì)有這個(gè)差異,。 下面改用多線(xiàn)程:1 個(gè) IO 線(xiàn)程,8 個(gè)計(jì)算線(xiàn)程(線(xiàn)程池),。二者之間用 BlockingQueue 溝通,。同樣是 10 個(gè)并發(fā)請(qǐng)求,第 1 個(gè)請(qǐng)求被分配到計(jì)算線(xiàn)程1,,第 2 個(gè)請(qǐng)求被分配到計(jì)算線(xiàn)程 2,,以此類(lèi)推,直到第 8 個(gè)請(qǐng)求被第 8 個(gè)計(jì)算線(xiàn)程承擔(dān),。第 9 和第 10 號(hào)請(qǐng)求會(huì)等在 BlockingQueue 里,,直到有計(jì)算線(xiàn)程回到空閑狀態(tài)才能被處理。(請(qǐng)注意,,這里的分配實(shí)際上是由操作系統(tǒng)來(lái)做,,操作系統(tǒng)會(huì)從處于 Waiting 狀態(tài)的線(xiàn)程里挑一個(gè),不一定是 round-robin 的,。) 這樣一來(lái),,前 8 個(gè)請(qǐng)求的響應(yīng)時(shí)間差不多都是 10ms,,后 2 個(gè)請(qǐng)求屬于第二批,,其響應(yīng)時(shí)間大約會(huì)是 20ms,總的平均響應(yīng)時(shí)間是 12ms,??梢钥闯霰葐尉€(xiàn)程快了不少。 由于每道 Sudoku 題目的難度不一,,對(duì)于簡(jiǎn)單的題目,,可能 1ms 就能算出來(lái),,復(fù)雜的題目最多用 10ms。那么線(xiàn)程池方案的優(yōu)勢(shì)就更明顯,,它能有效地降低“簡(jiǎn)單任務(wù)被復(fù)雜任務(wù)壓住”的出現(xiàn)概率,。 以上舉的都是計(jì)算密集的例子,即線(xiàn)程在響應(yīng)一次請(qǐng)求時(shí)不會(huì)等待 IO,,下面談?wù)劯鼜?fù)雜的情況。 5. 多線(xiàn)程程序如何讓 IO 和“計(jì)算”相互重疊,,降低 latency? 例1: logging 在多線(xiàn)程服務(wù)器程序中,,日志 (logging) 至關(guān)重要,,本例僅考慮寫(xiě) log file 的情況,不考慮 log server,。 在一次請(qǐng)求響應(yīng)中,可能要寫(xiě)多條日志消息,,而如果用同步的方式寫(xiě)文件(fprintf 或 fwrite),,多半會(huì)降低性能,,因?yàn)椋?/p> 文件操作一般比較慢,服務(wù)線(xiàn)程會(huì)等在 IO 上,,讓 CPU 閑置,,增加響應(yīng)時(shí)間。 盡管 logging 很重要,但它不是程序的主要邏輯,,因此對(duì)程序的結(jié)構(gòu)影響越小越好,最好能簡(jiǎn)單到如同一條 printf 語(yǔ)句,,且不用擔(dān)心其他性能開(kāi)銷(xiāo),,而一個(gè)好的多線(xiàn)程異步 logging 庫(kù)能幫我們做到這一點(diǎn),。(Apache 的 log4cxx 和 log4j 都支持 AsyncAppender 這種異步 logging 方式,。) 例2: memcached 客戶(hù)端 假設(shè)我們用 memcached 來(lái)保存用戶(hù)最后發(fā)帖的時(shí)間,那么每次響應(yīng)用戶(hù)發(fā)帖的請(qǐng)求時(shí),,程序里要去設(shè)置一下 memcached 里的值,。這一步如果用同步 IO,會(huì)增加延遲,。 對(duì)于“設(shè)置一個(gè)值”這樣的 write-only idempotent 操作,,我們其實(shí)不用等 memcached 返回操作結(jié)果,,這里也不用在乎 set 操作失敗,那么可以借助多線(xiàn)程來(lái)降低響應(yīng)延遲,。比方說(shuō)我們可以寫(xiě)一個(gè)多線(xiàn)程版的 memcached 的客戶(hù)端,,對(duì)于 set 操作,調(diào)用方只要把 key 和 value 準(zhǔn)備好,,調(diào)用一下 asyncSet() 函數(shù),,把數(shù)據(jù)往 BlockingQueue 上一放就能立即返回,,延遲很小,。剩下的時(shí)就留給 memcached 客戶(hù)端的線(xiàn)程去操心,而服務(wù)線(xiàn)程不受阻礙,。 其實(shí)所有的網(wǎng)絡(luò)寫(xiě)操作都可以這么異步地做,,不過(guò)這也有一個(gè)缺點(diǎn),那就是每次 asyncWrite 都要在線(xiàn)程間傳遞數(shù)據(jù),,其實(shí)如果 TCP 緩沖區(qū)是空的,,我們可以在本線(xiàn)程寫(xiě)完,不用勞煩專(zhuān)門(mén)的 IO 線(xiàn)程,。Jboss 的 Netty 就使用了這個(gè)辦法來(lái)進(jìn)一步降低延遲,。 以上都僅討論了“打一槍就跑”的情況,如果是一問(wèn)一答,,比如從 memcached 取一個(gè)值,那么“重疊 IO”并不能降低響應(yīng)時(shí)間,,因?yàn)槟銦o(wú)論如何要等 memcached 的回復(fù)。這時(shí)我們可以用別的方式來(lái)提高并發(fā)度,,見(jiàn)問(wèn)題8。(雖然不能降低響應(yīng)時(shí)間,,但也不要浪費(fèi)線(xiàn)程在空等上,,對(duì)吧) 另外以上的例子也說(shuō)明,BlockingQueue 是構(gòu)建多線(xiàn)程程序的利器,。 6. 為什么第三方庫(kù)往往要用自己的線(xiàn)程,? 對(duì)于 Java,,這個(gè)問(wèn)題還好辦一些,因?yàn)?thread pool 在 Java 里有標(biāo)準(zhǔn)實(shí)現(xiàn),叫 ExecutorService,。如果第三方庫(kù)支持線(xiàn)程池,,那么它可以和主程序共享一個(gè) ExecutorService ,而不是自己創(chuàng)建一堆線(xiàn)程,。(比如在初始化時(shí)傳入主程序的 obj。)對(duì)于 C++,,情況麻煩得多,Reactor 和 Thread pool 都沒(méi)有標(biāo)準(zhǔn)庫(kù),。 例1:libmemcached 只支持同步操作 libmemcached 支持所謂的“非阻塞操作”,,但沒(méi)有暴露一個(gè)能被 select/poll/epoll 的 file describer,它的 memcached_fetch 始終會(huì)阻塞,。它號(hào)稱(chēng) memcached_set 可以是非阻塞的,實(shí)際意思是不必等待結(jié)果返回,,但實(shí)際上這個(gè)函數(shù)會(huì)同步地調(diào)用 write(),仍可能阻塞在網(wǎng)絡(luò) IO 上,。 如果在我們的 reactor event handler 里調(diào)用了 libmemcached 的函數(shù),那么 latency 就堪憂(yōu)了,。如果想繼續(xù)用 libmemcached,我們可以為它做一次線(xiàn)程封裝,,按問(wèn)題 5 例 2 的辦法,同額外的線(xiàn)程專(zhuān)門(mén)做 memcached 的 IO,,而程序主體還是 reactor,。甚至可以把 memcached “數(shù)據(jù)就緒”作為一個(gè) event,注入到我們的 event loop 中,,以進(jìn)一步提高并發(fā)度。(例子留待問(wèn)題 8 講) 萬(wàn)幸的是,,memcached 的協(xié)議非常簡(jiǎn)單,大不了可以自己寫(xiě)一個(gè)基于 reactor 的客戶(hù)端,,但是數(shù)據(jù)庫(kù)客戶(hù)端就沒(méi)那么幸運(yùn)了,。 例2:MySQL 的官方 C API 不支持異步操作 MySQL 的客戶(hù)端只支持同步操作,對(duì)于 UPDATE/INSERT/DELETE 之類(lèi)只要行為不管結(jié)果的操作(如果代碼需要得知其執(zhí)行結(jié)果則另當(dāng)別論),,我們可以用一個(gè)單獨(dú)的線(xiàn)程來(lái)做,,以降低服務(wù)線(xiàn)程的延遲,??煞抡涨懊? memcached_set 的例子,,不再贅言,。麻煩的是 SELECT,如果要把它也異步化,,就得動(dòng)用更復(fù)雜的模式了,見(jiàn)問(wèn)題 8,。 相比之下,PostgreSQL 的 C 客戶(hù)端 libpq 的設(shè)計(jì)要好得多,,我們可以用 PQsendQuery() 來(lái)發(fā)起一次查詢(xún),,然后用標(biāo)準(zhǔn)的 select/poll/epoll 來(lái)等待 PQsocket,,如果有數(shù)據(jù)可讀,那么用 PQconsumeInput 處理之,,并用 PQisBusy 判斷查詢(xún)結(jié)果是否已就緒,最后用 PQgetResult 來(lái)獲取結(jié)果,。借助這套異步 API,我們可以很容易地為 libpq 寫(xiě)一套 wrapper,,使之融入到程序所用的 reactor 模型中。 7. 什么是線(xiàn)程池大小的阻抗匹配原則,? 如果池中線(xiàn)程在執(zhí)行任務(wù)時(shí),,密集計(jì)算所占的時(shí)間比重為 P (0 < P <= 1),而系統(tǒng)一共有 C 個(gè) CPU,,為了讓這 C 個(gè) CPU 跑滿(mǎn)而又不過(guò)載,線(xiàn)程池大小的經(jīng)驗(yàn)公式 T = C/P,。(T 是個(gè) hint,考慮到 P 值的估計(jì)不是很準(zhǔn)確,,T 的最佳值可以上下浮動(dòng) 50%。) 以后我再講這個(gè)經(jīng)驗(yàn)公式是怎么來(lái)的,,先驗(yàn)證邊界條件的正確性,。 假設(shè) C = 8, P = 1.0,,線(xiàn)程池的任務(wù)完全是密集計(jì)算,那么 T = 8,。只要 8 個(gè)活動(dòng)線(xiàn)程就能讓 8 個(gè) CPU 飽和,,再多也沒(méi)用,,因?yàn)?CPU 資源已經(jīng)耗光了。 假設(shè) C = 8, P = 0.5,,線(xiàn)程池的任務(wù)有一半是計(jì)算,,有一半等在 IO 上,那么 T = 16,??紤]操作系統(tǒng)能靈活合理地調(diào)度 sleeping/writing/running 線(xiàn)程,那么大概 16 個(gè)“50% 繁忙的線(xiàn)程”能讓 8 個(gè) CPU 忙個(gè)不停,。啟動(dòng)更多的線(xiàn)程并不能提高吞吐量,反而因?yàn)樵黾由舷挛那袚Q的開(kāi)銷(xiāo)而降低性能,。 如果 P < 0.2,,這個(gè)公式就不適用了,T 可以取一個(gè)固定值,,比如 5*C,。 另外,,公式里的 C 不一定是 CPU 總數(shù),可以是“分配給這項(xiàng)任務(wù)的 CPU 數(shù)目”,,比如在 8 核機(jī)器上分出 4 個(gè)核來(lái)做一項(xiàng)任務(wù),,那么 C=4。 8. 除了你推薦的 reactor + thread poll,,還有別的 non-trivial 多線(xiàn)程編程模型嗎,? 如果一次請(qǐng)求響應(yīng)中要和別的進(jìn)程打多次交道,,那么 proactor 模型往往能做到更高的并發(fā)度。當(dāng)然,,代價(jià)是代碼變得支離破碎,,難以理解。 這里舉 http proxy 為例,,一次 http proxy 的請(qǐng)求如果沒(méi)有命中本地 cache,,那么它多半會(huì): 1.解析域名 (不要小看這一步,,對(duì)于一個(gè)陌生的域名,,解析可能要花半秒鐘) 1.向 DNS 問(wèn)域名,等待回復(fù),; 這時(shí)我們有兩個(gè)解決思路: 1.把“域名已解析”,,“連接已建立”,,“對(duì)方已完成響應(yīng)”做成 event,繼續(xù)按照 Reactor 的方式來(lái)編程,。這樣一來(lái),每次客戶(hù)請(qǐng)求就不能用一個(gè)函數(shù)從頭到尾執(zhí)行完成,,而要分成多個(gè)階段,,并且要管理好請(qǐng)求的狀態(tài)(“目前到了第幾步,?”)。 Proactor 能提高吞吐,但不能降低延遲,,所以我沒(méi)有深入研究,。 9. 模式 2 和模式 3a 該如何取舍? 我認(rèn)為,,在其他條件相同的情況下,,可以根據(jù)工作集 (work set) 的大小來(lái)取舍,。工作集是指服務(wù)程序響應(yīng)一次請(qǐng)求所訪(fǎng)問(wèn)的內(nèi)存大小。 如果工作集較大,,那么就用多線(xiàn)程,避免 CPU cache 換入換出,,影響性能,;否則,,就用單線(xiàn)程多進(jìn)程,享受單線(xiàn)程編程的便利,。 例如,,memcached 這個(gè)內(nèi)存消耗大戶(hù)用多線(xiàn)程服務(wù)端就比在同一臺(tái)機(jī)器上運(yùn)行多個(gè) memcached instance 要好,。(除非你在 16G 內(nèi)存的機(jī)器上運(yùn)行 32-bit memcached,那么多 instance 是必須的,。) 又例如,,求解 Sudoku 用不了多大內(nèi)存,,如果單線(xiàn)程編程更方便的話(huà),,可以用單線(xiàn)程多進(jìn)程來(lái)做。再在前面加一個(gè)單線(xiàn)程的 load balancer,,仿 lighttpd + fastcgi 的成例,。 線(xiàn)程不能減少工作量,即不能減少 CPU 時(shí)間,。如果解決一個(gè)問(wèn)題需要執(zhí)行一億條指令(這個(gè)數(shù)字不大,,不要被嚇到),那么用多線(xiàn)程只會(huì)讓這個(gè)數(shù)字增加,。但是通過(guò)合理調(diào)配這一億條指令在多個(gè)核上的執(zhí)行情況,,我們能讓工期提早結(jié)束。這聽(tīng)上去像統(tǒng)籌方法,,確實(shí)也正是統(tǒng)籌方法,。 |
|