muduo网络库:09---多线程服务器之(单线程、多线程服务器的适用场合)
生活随笔
收集整理的這篇文章主要介紹了
muduo网络库:09---多线程服务器之(单线程、多线程服务器的适用场合)
小編覺得挺不錯的,現在分享給大家,幫大家做個參考.
- 本文內容銜接于前一篇文章(進程間通信只用TCP):https://blog.csdn.net/qq_41453285/article/details/104997453
一、服務器開發概述
“服務器開發”包羅萬象,用一句話形容是:跑在多核機器上的Linux用戶態的沒有用戶界面的長期運行(例如wget是不長期運行,httpd是長期運行的)的網絡應用程序,通常是分布式系統的組成部件
并發處理
- 開發服務端程序的一個基本任務是處理并發連接,現在服務端網絡編程處理并發連接主要有兩種方式:
- 當“線程”很廉價時,一臺機器上可以創建遠高于CPU數目的“線程”。這時一個線程只處理一個TCP連接(甚至半個),通常使用阻塞 IO(至少看起來如此)。例如,Python gevent、Go goroutine、Erlang actor。這里的“線程”由語言的runtime自行調度,與操作系統線程不是一 回事
- 當線程很寶貴時,一臺機器上只能創建與CPU數目相當的線程。 這時一個線程要處理多個TCP連接上的IO,通常使用非阻塞IO和IO multiplexing。例如,libevent、muduo、Netty。這是原生線程,能被操 作系統的任務調度器看見
- 在處理并發連接的同時,也要充分發揮硬件資源的作用,不能讓CPU資源閑置:
- 以上列出的庫不是每個都能做到這一點
- 既然討論的是C++編程,那么只考慮后一種方式,這是在Linux下使用native語言編寫用戶態高性能網絡程序的最成熟的模式
- 本節主要討論的是這些“線程”應該屬于一個進程(以下模式2),還是分屬多個進程(模式3)
- 本文的“進程”指的是fork系統調用的產物。“線程”指的是pthread_create()的產物,因此是寶貴的那種原生線程。而且我指的Pthreads是NPTL的,每個線程由clone產生,對應一個內核的task_struct
相關模式
- 首先,一個由多臺機器組成的分布式系統必然是多進程的(字面意義上),因為進程不能跨OS邊界。在這個前提下,我們把目光集中到 一臺機器,一臺擁有至少4個核的普通服務器。如果要在一臺多核機器上提供一種服務或執行一個任務,可用的模式有(這里的“模式”不是pattern,而是model):
- 1.運行一個單線程的進程
- 2.運行一個多線程的進程
- 3.運行多個單線程的進程
- 4.運行多個多線程的進程
- 這些模式之間的比較已經是老生常談,簡單地總結如下:
- 模式1是不可伸縮的(scalable),不能發揮多核機器的計算能力
- 模式3是目前公認的主流模式。它有以下兩種子模式:
- 3a.簡單地把模式1中的進程運行多份(如果能用多個TCP port對外提供服務的話)
- 3b.主進程+woker進程,如果必須綁定到一個TCP port,比如 httpd+fastcgi
- 模式2是被很多人所鄙視的,認為多線程程序難寫,而且與模式3 相比并沒有什么優勢
- 模式4更是千夫所指,它不但沒有結合2和3的優點,反而匯聚了二者的缺點
- 本文主要想討論的是模式2和模式3b的優劣,即:什么時候一個服務器程序應該是多線程的:
- 從功能上講,沒有什么是多線程能做到而單 線程做不到的,反之亦然,都是狀態機嘛(我很高興看到反例)
- 從性能上講,無論是IO bound還是CPU bound的服務,多線程都沒有什么優勢
- Paul E. McKenney在《Is Parallel Programming Hard, And, If So, What Can You Do About It?》第3.5節指出,“As a rough rule of thumb, use the simplest tool that will get the job done.”。比方說,使用速率為50MB/s的數據壓縮庫、在進程創建銷毀的開銷是800μs、線程創建銷毀的開銷是 50μs的前提下,考慮如何執行壓縮任務:
- 如果要偶爾壓縮1GB的文本文件,預計運行時間是20s,那么起一個進程去做是合理的,因為進程啟動和銷毀的開銷遠遠小于實際任務的 耗時
- 如果要經常壓縮500kB的文本數據,預計運行時間是10ms,那么每次都起進程似乎有點浪費了,可以每次單獨起一個線程去做
- 如果要頻繁壓縮10kB的文本數據,預計運行時間是200μs,那么每次起線程似乎也很浪費,不如直接在當前線程搞定。也可以用一個線程池,每次把壓縮任務交給線程池,避免阻塞當前線程(特別要避免阻塞IO線程)
- 由此可見,多線程并不是萬靈丹,它有適用的場合。那么究竟什么時候該用多線程?在回答這個問題之前,我先談談必須用單線程的場合
二、必須用單線程的場合
- 據我所知,有兩種場合必須使用單線程:
- 1.程序可能會fork
- 2.限制程序的CPU占用率
①只有單線程程序能fork
- 根據后面“多線程與fork()”文章的分析,一個設計為可能調用fork的程序必須是單線程的,比如后面“多線程與fork()”文章中提到的“看門狗進程”
- 多線程程序不是不能調用fork,而是這么做會遇到很多麻煩, 我想不出做的理由
- 一個程序fork之后一般有兩種行為:
- 1.立刻執行exec(),變身為另一個程序。例如shell和inetd;又比如 lighttpd fork()出子進程,然后運行fastcgi程序。或者集群中運行在計算 節點上的負責啟動job的守護進程(即我所謂的“看門狗進程”)
- 2.不調用exec(),繼續運行當前程序。要么通過共享的文件描述符與父進程通信,協同完成任務;要么接過父進程傳來的文件描述符,獨 立完成工作,例如20世紀80年代的Web服務器NCSA httpd
- 這些行為中,我認為只有“看門狗進程”必須堅持單線程,其他的均可替換為多線程程序(從功能上講)
②單線程程序能限制程序的CPU占用率
- 單線程程序能限制程序的CPU占用率這個很容易理解
- 比如在一個8核的服務器上,一個單線程程序即便發生busy-wait(無論是因為bug,還是因為overload),占滿1個core,其CPU使用率也只有12.5%。在這種最壞的情況下,系統還是有87.5%的計算資源可供其他服務進程使用
- 因此對于一些輔助性的程序,如果它必須和主要服務進程運行在同一臺機器的話(比如它要監控其他服務進程的狀態),那么做成單線程的能避免過分搶奪系統的計算資源,比方說:
- 如果要把生產服務器上的日志文件壓縮后備份到NFS上,那么應該使用普通單線程壓縮工具 (gzip/bzip2)。它們對系統造成的影響較小,在8核服務器上最多占滿1個core
- 如果有人為了“提高速度”,開啟了多線程壓縮或者同時起多個進程來壓縮多個日志文件,有可能造成的結果是非關鍵任務耗盡了CPU資源,正常客戶的請求響應變慢。這是我們不愿意看到的
三、單線程程序的優缺點
- 從編程的角度,單線程程序的優勢無須贅言:簡單
- 單線程程序的結構:
- 是一個基于IO multiplexing的event loop。event loop的典型代碼框架參閱前文:https://blog.csdn.net/qq_41453285/article/details/104954338
- 或者如云風所言,直接用阻塞IO(參閱:http://blog.codingnow.com/2006/04/iocp_kqueue_epoll.html)
- Event loop有一個明顯的缺點,它是非搶占的:
- 假設事件a的優先級高于事件b,處理事件a需要1ms,處理事件b需要 10ms。如果事件b稍早于a發生,那么當事件a到來時,程序已經離開了 poll(2)調用,并開始處理事件b。事件a要等上10ms才有機會被處理,總的響應時間為11ms
- 這等于發生了優先級反轉。這個缺點可以用多線程來克服,這也是多線程的主要優勢
多線程程序有性能優勢嗎
- 前面說過,無論是IO bound還是CPU bound的服務,多線程都沒有什么絕對意義上的性能優勢。這句話是說,如果用很少的CPU負載就能讓IO跑滿,或者用很少的IO流量就能讓CPU跑滿,那么多線程沒啥用處
- 舉例來說:
- 對于靜態Web服務器,或者FTP服務器,CPU的負載較輕,主要瓶頸在磁盤IO和網絡IO方面。這時候往往一個單線程的程序(模式1)就能撐滿IO。用多線程并不能提高吞吐量,因為IO硬件容量已經飽和了。 同理,這時增加CPU數目也不能提高吞吐量
- CPU跑滿的情況比較少見,這里我只好虛構一個例子。假設有一個服務,它的輸入是n個整數,問能否從中選出m個整數,使其和為 0(這里n<100, m>0)。這是著名的subset sum問題,是NP-Complete 的。對于這樣一個“服務”,哪怕很小的n值也會讓CPU算死。比如n= 30,一次的輸入不過200字節(32-bit整數),CPU的運算時間卻能長達幾分鐘。對于這種應用,模式3a是最適合的,能發揮多核的優勢,程序也簡單
- 也就是說,無論任何一方早早地先到達瓶頸,多線程程序都沒啥優勢
四、適合多線程程序的場景
- 我認為多線程的適用場景是:提高響應速度,讓IO和“計算”相互重疊,降低latency(延遲)。雖然多線程不能提高絕對性能,但能提高平均響應性能
- 一個程序要做成多線程的,大致要滿足:
- 有多個CPU可用。單核機器上多線程沒有性能優勢(但或許能簡 化并發業務邏輯的實現)
- 線程間有共享數據,即內存中的全局狀態。如果沒有共享數據, 用模型3b就行。雖然我們應該把線程間的共享數據降到最低,但不代表沒有
- 共享的數據是可以修改的,而不是靜態的常量表。如果數據不能修改,那么可以在進程間用shared memory,模式3就能勝任
- 提供非均質的服務。即,事件的響應有優先級差異,我們可以用專門的線程來處理優先級高的事件。防止優先級反轉
- latency和throughput同樣重要,不是邏輯簡單的IO bound或CPU bound程序。換言之,程序要有相當的計算量
- 利用異步操作。比如logging。無論往磁盤寫log file,還是往log server發送消息都不應該阻塞critical path
- 能scale up(按比例增加)。一個好的多線程程序應該能享受增加CPU數目帶來的 好處,目前主流是8核,很快就會用到16核的機器了
- 具有可預測的性能。隨著負載增加,性能緩慢下降,超過某個臨界點之后會急速下降。線程數目一般不隨負載變化
- 多線程能有效地劃分責任與功能,讓每個線程的邏輯比較簡單, 任務單一,便于編碼。而不是把所有邏輯都塞到一個event loop里,不同類別的事件之間相互影響
-
這些條件比較抽象,下面舉兩個具體的(雖然是虛構的)例子
例子①
- 假設要管理一個Linux服務器機群,這個機群里有8個計算節點,1 個控制節點。機器的配置都是一樣的,雙路四核CPU,千兆網互聯
- 現在需要編寫一個簡單的機群管理軟件(參考LLNL的SLURM20),這個軟件由3個程序組成:
- 1.運行在控制節點上的master,這個程序監視并控制整個機群的狀態
- 2.運行在每個計算節點上的slave,負責啟動和終止job,并監控本機的資源
- 3.供最終用戶使用的client命令行工具,用于提交job
- 根據前面的分析:
- slave是個“看門狗進程”,它會啟動別的job進程,因此必須是個單線程程序。另外它不應該占用太多的CPU資源,這也適合單線程模型
- master應該是個模式2的多線程程序:
- 它獨占一臺8核的機器,如果用模型1,等于浪費了87.5%的CPU資源
- 整個機群的狀態應該能完全放在內存中,這些狀態是共享且可變 的。如果用模式3,那么進程之間的狀態同步會成大問題。而如果大量 使用共享內存,則等于是掩耳盜鈴,是披著多進程外衣的多線程程序。 因為一個進程一旦在臨界區內阻塞或crash,其他進程會全部死鎖
- master的主要性能指標不是throughput,而是latency,即盡快地響 應各種事件。它幾乎不會出現把IO或CPU跑滿的情況
- master監控的事件有優先級區別,一個程序正常運行結束和異常崩 潰的處理優先級不同,計算節點的磁盤滿了和機箱溫度過高這兩種報警 條件的優先級也不同。如果用單線程,則可能會出現優先級反轉
- 假設master和每個slave之間用一個TCP連接,那么master采用2個或 4個IO線程來處理8個TCP connections能有效地降低延遲
- master要異步地往本地硬盤寫log,這要求logging library有自己的 IO線程
- master有可能要讀寫數據庫,那么數據庫連接這個第三方library可 能有自己的線程,并回調master的代碼
- master要服務于多個clients,用多線程也能降低客戶響應時間。也 就是說它可以再用2個IO線程專門處理和clients的通信
- master還可以提供一個monitor接口,用來廣播推送(pushing)機 群的狀態,這樣用戶不用主動輪詢(polling)。這個功能如果用單獨的 線程來做,會比較容易實現,不會搞亂其他主要功能
- master一共開了10個線程:
- ?4個用于和slaves通信的IO線程
- ?1個logging線程
- ?1個數據庫IO線程
- ?2個和clients通信的IO線程
- ?1個主線程,用于做些背景工作,比如job調度
- ?1個pushing線程,用于主動廣播機群的狀態
- 雖然線程數目略多于core數目,但是這些線程很多時候都是空閑 的,可以依賴OS的進程調度來保證可控的延遲
- 綜上所述,master用多線程方式編寫是自然且高效的
例子②
- 再舉一個TCP聊天服務器的例子,這里的“聊天”不完全指人與人聊 天,也可能是機器與機器“聊天”
- 這種服務的特點是:并發連接之間有數據交換,從一個連接收到的數據要轉發給其他多個連接
- 因此我們不能按模式3的做法,把多個連接分到多個進程中分別處理(這會帶來復雜的進程間通信),而只能用模式1或者模式2:
- 如果純粹只有數據交換, 那么我想模式1也能工作得很好,因為現在的CPU足夠快,單線程應付幾百個連接不在話下
- 如果功能進一步復雜化:加上關鍵字過濾、黑名單、防灌水等等功能,甚至要給聊天內容自動加上相關連接,每一項功能都會占用CPU資源
- 這時就要考慮模式2了,因為單個CPU的處理能力顯得捉襟見肘, 順序處理導致消息轉發的延遲增加。這時我們考慮把空閑的多個CPU利用起來,自然的做法是把連接分散到多個線程上,例如按round-robin的 方式把1000個客戶連接分配到4個IO線程上。這樣充分利用多核加速。
- 具體的例子見“muduo庫簡介之詳解muduo多線程模型”中的方案9,以及“muduo編程實例之“串并轉換”連接服務器機器自動化測試”文章
多線程中線程的分類
- 據我的經驗,一個多線程服務程序中的線程大致可分為3類:
- 1.IO線程,這類線程的主循環是IO multiplexing,阻塞地等在select/poll/epoll_wait系統調用上。這類線程也處理定時事件。當然它的功能不止IO,有些簡單計算也可以放入其中,比如消息的編碼或解碼
- 2.計算線程,這類線程的主循環是blocking queue,阻塞地等在 conditionvariable上。這類線程一般位于thread pool中。這種線程通常不 涉及IO,一般要避免任何阻塞操作
- 3.第三方庫所用的線程,比如logging,又比如database connection
- 服務器程序一般不會頻繁地啟動和終止線程。甚至,在我寫過的程序里,create thread只在程序啟動的時候調用,在服務運行期間是不調用的
- 總結:
- 在多核時代,要想充分發揮CPU性能,多線程編程是不可避免的,“鴕鳥算法”不是辦法
- 在學會多線程編程之前,我也一直認為單線程服務程序才是王道。在接觸多線程編程之后,經過一段時間的訓練和適應,我已能比較自如地編寫正確且足夠高效的多線程程序
- 學習多線 程編程還有一個好處,即訓練異步思維,提高分析并發事件的能力。這對設計分布式系統幫助巨大,因為運行在多臺機器上的服務進程本質上是異步的。熟悉多線程編程的話,很容易就能發現分布式系統在消息和 事件處理方面的race condition
五、附加
- 本專題未完結,參閱下一篇文章(“多線程服務器的適用場合”的例釋與答疑):https://blog.csdn.net/qq_41453285/article/details/105005152
總結
以上是生活随笔為你收集整理的muduo网络库:09---多线程服务器之(单线程、多线程服务器的适用场合)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: SpringBoot @RunWith注
- 下一篇: 互动媒体技术A1作业报告