国产一级a片免费看高清,亚洲熟女中文字幕在线视频,黄三级高清在线播放,免费黄色视频在线看

打開APP
userphoto
未登錄

開通VIP,暢享免費電子書等14項超值服

開通VIP
《多線程服務器的適用場合》-- 例釋與答疑

《多線程服務器的適用場合》-- 例釋與答疑

作者: 陳碩 (3 篇文章)日期: 四月 28, 2010 在 9:31 下午

《多線程服務器的適用場合》(以下簡稱《適用場合》)一文在博客登出之后,有熱心讀者提出質(zhì)疑,我自己也覺得原文沒有把道理說通說透,這篇文章試圖用一些實例來解答讀者的疑問。我本來打算修改原文,但是考慮到已經(jīng)讀過的讀者不一定會注意到文章的變動,干脆另寫一篇。為方便閱讀,本文以問答體呈現(xiàn)。這篇文章可能會反復修改擴充,請注意上面的版本號。

本文所說的“多線程服務器”的定義與前文一樣,同時參見《多線程服務器的常用編程模型》(以下簡稱《常用模型》)一文的詳細界定,以下“連接、端口”均指 TCP 協(xié)議。

1. Linux 能同時啟動多少個線程?
對于 32-bit Linux,一個進程的地址空間是 4G,其中用戶態(tài)能訪問 3G 左右,而一個線程的默認棧 (stack) 大小是10M,心算可知,一個進程大約最多能同時啟動 300 個線程。如果不改線程的調(diào)用棧大小的話,300左右是上限,因為程序的其他部分(數(shù)據(jù)段、代碼段、堆、動態(tài)庫、等等)同樣要占用內(nèi)存(地址空間)。

對于 64-bit 系統(tǒng),線程數(shù)目可大大增加,具體數(shù)字我沒有測試,因為我實際用不到那么多線程。

以下的關(guān)于線程數(shù)目的討論以 32-bit Linux 為例。

2. 多線程能提高并發(fā)度嗎?
如果指的是“并發(fā)連接數(shù)”,不能。

由問題 1 可知,假如單純采用 thread per connection 的模型,那么并發(fā)連接數(shù)最多300,這遠遠低于基于事件的單線程程序所能輕松達到的并發(fā)連接數(shù)(幾千上萬,甚至幾萬)。所謂“基于事件”,指的是用 IO multiplexingevent loop 的編程模型,又稱 Reactor 模式,在《常用模型》一文中已有介紹。

那么采用《常用模型》一文中推薦的 event loop per thread 呢?至少不遜于單線程程序。

小結(jié):thread per connection 不適合高并發(fā)場合,其 scalability 不佳。event loop per thread 的并發(fā)度不比單線程程序差。

3. 多線程能提高吞吐量嗎?
對于計算密集型服務,不能。

假設(shè)有一個耗時的計算服務,用單線程算需要 0.8s。在一臺 8 核的機器上,我們可以啟動 8 個線程一起對外服務(如果內(nèi)存夠用,啟動 8個進程也一樣)。這樣完成單個計算仍然要 0.8s,但是由于這些進程的計算可以同時進行,理想情況下吞吐量可以從單線程的 1.25cps (calcper second) 上升到 10cps。(實際情況可能要打個八折——如果不是打?qū)φ鄣脑?。?/p>

假如改用并行算法,用 8 個核一起算,理論上如果完全并行,加速比高達 8,那么計算時間是 0.1s,吞吐量還是10cps,但是首次請求的響應時間卻降低了很多。實際上根據(jù) Amdahl's law,即便算法的并行度高達 95%,8 核的加速比也只有6,計算時間為 0.133s,這樣會造成吞吐量下降為 7.5cps。不過以此為代價,換得響應時間的提升,在有些應用場合也是值得的。

這也回答了問題 4。

如果用 thread per request 的模型,每個客戶請求用一個線程去處理,那么當并發(fā)請求數(shù)大于某個臨界值 T’時,吞吐量反而會下降,因為線程多了以后上下文切換的開銷也隨之增加(分析與數(shù)據(jù)請見《A Design Framework for HighlyConcurrent Systems》 by Matt Welsh et al.)。thread per request是最簡單的使用線程的方式,編程最容易,簡單地把多線程程序當成一堆串行程序,用同步的方式順序編程,比如 Java Servlet中,一次頁面請求由一個函數(shù) HttpServlet#service(HttpServletRequest req,HttpServletResponse resp) 同步地完成。

為了在并發(fā)請求數(shù)很高時也能保持穩(wěn)定的吞吐量,我們可以用線程池,線程池的大小應該滿足“阻抗匹配原則”,見問題 7。

線程池也不是萬能的,如果響應一次請求需要做比較多的計算(比如計算的時間占整個 response time 的 1/5強),那么用線程池是合理的,能簡化編程。如果一次請求響應中,thread 主要是在等待 IO,那么為了進一步提高吞吐,往往要用其它編程模型,比如Proactor,見問題 8。

4. 多線程能降低響應時間嗎?
如果設(shè)計合理,充分利用多核資源的話,可以。在突發(fā) (burst) 請求時效果尤為明顯。

例1: 多線程處理輸入。

以 memcached 服務端為例。memcached 一次請求響應大概可以分為 3 步:

1.讀取并解析客戶端輸入
2.操作 hashtable
3.返回客戶端
在單線程模式下,這 3 步是串行執(zhí)行的。在啟用多線程模式時,它會啟用多個輸入線程(默認是 4 個),并在建立連接時按 round-robin法把新連接分派給其中一個輸入線程,這正好是我說的 event loop per thread 模型。這樣一來,第 1步的操作就能多線程并行,在多核機器上提高多用戶的響應速度。第 2 步用了全局鎖,還是單線程的,這可算是一個值得繼續(xù)改進的地方。

比如,有兩個用戶同時發(fā)出了請求,這兩個用戶的連接正好分配在兩個 IO 線程上,那么兩個請求的第 1步操作可以在兩個線程上并行執(zhí)行,然后匯總到第 2步串行執(zhí)行,這樣總的響應時間比完全串行執(zhí)行要短一些(在“讀取并解析”所占的比重較大的時候,效果更為明顯)。請繼續(xù)看下面這個例子。

例2: 多線程分擔負載。

假設(shè)我們要做一個求解 Sudoku 的服務(見《談談數(shù)獨》),這個服務程序在 9981 端口接受請求,輸入為一行 81 個數(shù)字(待填數(shù)字用 0 表示),輸出為填好之后的 81 個數(shù)字 (1 ~ 9),如果無解,輸出 “NO\r\n”。

由于輸入格式很簡單,用單個線程做 IO 就行了。先假設(shè)每次求解的計算用時 10ms,用前面的方法計算,單線程程序能達到的吞吐量上限為100req/s,在 8 核機器上,如果用線程池來做計算,能達到的吞吐量上限為 800req/s。下面我們看看多線程如何降低響應時間。

假設(shè) 1 個用戶在極短的時間內(nèi)發(fā)出了 10 個請求,如果用單線程“來一個處理一個”的模型,這些 reqs會排在隊列里依次處理(這個隊列是操作系統(tǒng)的 TCP 緩沖區(qū),不是程序里自己的任務隊列)。在不考慮網(wǎng)絡(luò)延遲的情況下,第 1 個請求的響應時間是10ms;第 2 個請求要等第 1 個算完了才能獲得 CPU 資源,它等了 10ms,算了 10ms,響應時間是 20ms;依次類推,第 10個請求的響應時間為 100ms;10個請求的平均響應時間為 55ms。

如果 Sudoku 服務在每個請求到達時開始計時,會發(fā)現(xiàn)每個請求都是 10ms 響應時間,而從用戶的觀點,10 個請求的平均響應時間為 55ms,請讀者想想為什么會有這個差異。

下面改用多線程:1 個 IO 線程,8 個計算線程(線程池)。二者之間用 BlockingQueue 溝通。同樣是 10 個并發(fā)請求,第 1個請求被分配到計算線程1,第 2 個請求被分配到計算線程 2,以此類推,直到第 8 個請求被第 8 個計算線程承擔。第 9 和第 10號請求會等在 BlockingQueue 里,直到有計算線程回到空閑狀態(tài)才能被處理。(請注意,這里的分配實際上是由操作系統(tǒng)來做,操作系統(tǒng)會從處于Waiting 狀態(tài)的線程里挑一個,不一定是 round-robin 的。)

這樣一來,前 8 個請求的響應時間差不多都是 10ms,后 2 個請求屬于第二批,其響應時間大約會是 20ms,總的平均響應時間是 12ms??梢钥闯霰葐尉€程快了不少。

由于每道 Sudoku 題目的難度不一,對于簡單的題目,可能 1ms 就能算出來,復雜的題目最多用 10ms。那么線程池方案的優(yōu)勢就更明顯,它能有效地降低“簡單任務被復雜任務壓住”的出現(xiàn)概率。

以上舉的都是計算密集的例子,即線程在響應一次請求時不會等待 IO,下面談談更復雜的情況。

5. 多線程程序如何讓 IO 和“計算”相互重疊,降低 latency?
基本思路是,把 IO 操作(通常是寫操作)通過 BlockingQueue 交給別的線程去做,自己不必等待。

例1: logging

在多線程服務器程序中,日志 (logging) 至關(guān)重要,本例僅考慮寫 log file 的情況,不考慮 log server。

在一次請求響應中,可能要寫多條日志消息,而如果用同步的方式寫文件(fprintf 或 fwrite),多半會降低性能,因為:

文件操作一般比較慢,服務線程會等在 IO 上,讓 CPU 閑置,增加響應時間。
就算有 buffer,還是不靈。多個線程一起寫,為了不至于把 buffer 寫錯亂,往往要加鎖。這會讓服務線程互相等待,降低并發(fā)度。(同時用多個log 文件不是辦法,除非你有多個磁盤,且保證 log files 分散在不同的磁盤上,否則還是受到磁盤 IO 瓶頸制約。)
解決辦法是單獨用一個 logging 線程,負責寫磁盤文件,通過一個或多個 BlockingQueue對外提供接口。別的線程要寫日志的時候,先把消息(字符串)準備好,然后往 queue 里一塞就行,基本不用等待。這樣服務線程的計算就和logging 線程的磁盤 IO 相互重疊,降低了服務線程的響應時間。

盡管 logging 很重要,但它不是程序的主要邏輯,因此對程序的結(jié)構(gòu)影響越小越好,最好能簡單到如同一條 printf語句,且不用擔心其他性能開銷,而一個好的多線程異步 logging 庫能幫我們做到這一點。(Apache 的 log4cxx 和 log4j都支持 AsyncAppender 這種異步 logging 方式。)

例2: memcached 客戶端

假設(shè)我們用 memcached 來保存用戶最后發(fā)帖的時間,那么每次響應用戶發(fā)帖的請求時,程序里要去設(shè)置一下 memcached 里的值。這一步如果用同步 IO,會增加延遲。

對于“設(shè)置一個值”這樣的 write-only idempotent 操作,我們其實不用等 memcached返回操作結(jié)果,這里也不用在乎 set 操作失敗,那么可以借助多線程來降低響應延遲。比方說我們可以寫一個多線程版的 memcached的客戶端,對于 set 操作,調(diào)用方只要把 key 和 value 準備好,調(diào)用一下 asyncSet() 函數(shù),把數(shù)據(jù)往BlockingQueue 上一放就能立即返回,延遲很小。剩下的時就留給 memcached 客戶端的線程去操心,而服務線程不受阻礙。

其實所有的網(wǎng)絡(luò)寫操作都可以這么異步地做,不過這也有一個缺點,那就是每次 asyncWrite 都要在線程間傳遞數(shù)據(jù),其實如果 TCP緩沖區(qū)是空的,我們可以在本線程寫完,不用勞煩專門的 IO 線程。Jboss 的 Netty 就使用了這個辦法來進一步降低延遲。

以上都僅討論了“打一槍就跑”的情況,如果是一問一答,比如從 memcached 取一個值,那么“重疊IO”并不能降低響應時間,因為你無論如何要等 memcached的回復。這時我們可以用別的方式來提高并發(fā)度,見問題8。(雖然不能降低響應時間,但也不要浪費線程在空等上,對吧)

另外以上的例子也說明,BlockingQueue 是構(gòu)建多線程程序的利器。

6. 為什么第三方庫往往要用自己的線程?
往往因為 event loop 模型沒有標準實現(xiàn)。如果自己寫代碼,盡可以按所用 Reactor 的推薦方式來編程,但是第三方庫不一定能很好地適應并融入這個 event loop framework。有時需要用線程來做一些串并轉(zhuǎn)換。

對于 Java,這個問題還好辦一些,因為 thread pool 在 Java 里有標準實現(xiàn),叫ExecutorService。如果第三方庫支持線程池,那么它可以和主程序共享一個 ExecutorService,而不是自己創(chuàng)建一堆線程。(比如在初始化時傳入主程序的 obj。)對于 C++,情況麻煩得多,Reactor 和 Thread pool都沒有標準庫。

例1:libmemcached 只支持同步操作

libmemcached 支持所謂的“非阻塞操作”,但沒有暴露一個能被 select/poll/epoll 的 filedescriber,它的 memcached_fetch 始終會阻塞。它號稱 memcached_set可以是非阻塞的,實際意思是不必等待結(jié)果返回,但實際上這個函數(shù)會同步地調(diào)用 write(),仍可能阻塞在網(wǎng)絡(luò) IO 上。

如果在我們的 reactor event handler 里調(diào)用了 libmemcached 的函數(shù),那么 latency就堪憂了。如果想繼續(xù)用 libmemcached,我們可以為它做一次線程封裝,按問題 5 例 2 的辦法,同額外的線程專門做 memcached的 IO,而程序主體還是 reactor。甚至可以把 memcached “數(shù)據(jù)就緒”作為一個 event,注入到我們的 event loop中,以進一步提高并發(fā)度。(例子留待問題 8 講)

萬幸的是,memcached 的協(xié)議非常簡單,大不了可以自己寫一個基于 reactor 的客戶端,但是數(shù)據(jù)庫客戶端就沒那么幸運了。

例2:MySQL 的官方 C API 不支持異步操作

MySQL 的客戶端只支持同步操作,對于 UPDATE/INSERT/DELETE之類只要行為不管結(jié)果的操作(如果代碼需要得知其執(zhí)行結(jié)果則另當別論),我們可以用一個單獨的線程來做,以降低服務線程的延遲??煞抡涨懊鎚emcached_set 的例子,不再贅言。麻煩的是 SELECT,如果要把它也異步化,就得動用更復雜的模式了,見問題 8。

相比之下,PostgreSQL 的 C 客戶端 libpq 的設(shè)計要好得多,我們可以用 PQsendQuery()來發(fā)起一次查詢,然后用標準的 select/poll/epoll 來等待 PQsocket,如果有數(shù)據(jù)可讀,那么用 PQconsumeInput處理之,并用 PQisBusy 判斷查詢結(jié)果是否已就緒,最后用 PQgetResult 來獲取結(jié)果。借助這套異步 API,我們可以很容易地為libpq 寫一套 wrapper,使之融入到程序所用的 reactor 模型中。

7. 什么是線程池大小的阻抗匹配原則?
我在《常用模型》中提到“阻抗匹配原則”,這里大致講一講。

如果池中線程在執(zhí)行任務時,密集計算所占的時間比重為 P (0 < P <= 1),而系統(tǒng)一共有 C 個 CPU,為了讓這 C 個CPU 跑滿而又不過載,線程池大小的經(jīng)驗公式 T = C/P。(T 是個 hint,考慮到 P 值的估計不是很準確,T 的最佳值可以上下浮動50%。)

以后我再講這個經(jīng)驗公式是怎么來的,先驗證邊界條件的正確性。

假設(shè) C = 8, P = 1.0,線程池的任務完全是密集計算,那么 T = 8。只要 8 個活動線程就能讓 8 個 CPU 飽和,再多也沒用,因為 CPU 資源已經(jīng)耗光了。

假設(shè) C = 8, P = 0.5,線程池的任務有一半是計算,有一半等在 IO 上,那么 T = 16??紤]操作系統(tǒng)能靈活合理地調(diào)度sleeping/writing/running 線程,那么大概 16 個“50% 繁忙的線程”能讓 8 個 CPU忙個不停。啟動更多的線程并不能提高吞吐量,反而因為增加上下文切換的開銷而降低性能。

如果 P < 0.2,這個公式就不適用了,T 可以取一個固定值,比如 5*C。

另外,公式里的 C 不一定是 CPU 總數(shù),可以是“分配給這項任務的 CPU 數(shù)目”,比如在 8 核機器上分出 4 個核來做一項任務,那么 C=4。

8. 除了你推薦的 reactor + thread poll,還有別的 non-trivial 多線程編程模型嗎?
有,Proactor。

如果一次請求響應中要和別的進程打多次交道,那么 proactor 模型往往能做到更高的并發(fā)度。當然,代價是代碼變得支離破碎,難以理解。

這里舉 http proxy 為例,一次 http proxy 的請求如果沒有命中本地 cache,那么它多半會:

1.解析域名 (不要小看這一步,對于一個陌生的域名,解析可能要花半秒鐘)
2.建立連接
3.發(fā)送 HTTP 請求
4.等待對方回應
5.把結(jié)果返回客戶
這 5 步里邊跟 2 個 server 發(fā)生了 3 次 round-trip:

1.向 DNS 問域名,等待回復;
2.向?qū)Ψ?http 服務器發(fā)起連接,等待 TCP 三路握手完成;
3.向?qū)Ψ桨l(fā)送 http request,等待對方 response。
而實際上 http proxy 本身的運算量不大,如果用線程池,池中線程的數(shù)目會很龐大,不利于操作系統(tǒng)管理調(diào)度。

這時我們有兩個解決思路:

1.把“域名已解析”,“連接已建立”,“對方已完成響應”做成 event,繼續(xù)按照 Reactor 的方式來編程。這樣一來,每次客戶請求就不能用一個函數(shù)從頭到尾執(zhí)行完成,而要分成多個階段,并且要管理好請求的狀態(tài)(“目前到了第幾步?”)。
2.用回調(diào)函數(shù),讓系統(tǒng)來把任務串起來。比如收到用戶請求,如果沒有命中本地 cache,立刻發(fā)起異步的 DNS 解析startDNSResolve(),告訴系統(tǒng)在解析完之后調(diào)用 DNSResolved() 函數(shù);在 DNSResolved()中,發(fā)起連接,告訴系統(tǒng)在連接建立之后調(diào)用 connectionEstablished();在 connectionEstablished()中發(fā)送 http request,告訴系統(tǒng)在收到響應之后調(diào)用 httpResponsed();最后,在 httpResponsed()里把結(jié)果返回給客戶。.NET 大量采用的 Begin/End操作也是這個編程模式。當然,對于不熟悉這種編程方式的人,代碼會顯得很難看。Proactor 模式的例子可看 boost::asio的文檔,這里不再多說。
Proactor 模式依賴操作系統(tǒng)或庫來高效地調(diào)度這些子任務,每個子任務都不會阻塞,因此能用比較少的線程達到很高的 IO 并發(fā)度。

Proactor 能提高吞吐,但不能降低延遲,所以我沒有深入研究。

9. 模式 2 和模式 3a 該如何取舍?
這里的“模式”不是 pattern,而是 model,不巧它們的中譯是一樣的?!哆m用場合》中提到,模式 2 是一個多線程的進程,模式 3a 是多個相同的單線程進程。

我認為,在其他條件相同的情況下,可以根據(jù)工作集 (work set) 的大小來取舍。工作集是指服務程序響應一次請求所訪問的內(nèi)存大小。

如果工作集較大,那么就用多線程,避免 CPU cache 換入換出,影響性能;否則,就用單線程多進程,享受單線程編程的便利。

例如,memcached 這個內(nèi)存消耗大戶用多線程服務端就比在同一臺機器上運行多個 memcached instance 要好。(除非你在 16G 內(nèi)存的機器上運行 32-bit memcached,那么多 instance 是必須的。)

又例如,求解 Sudoku 用不了多大內(nèi)存,如果單線程編程更方便的話,可以用單線程多進程來做。再在前面加一個單線程的 load balancer,仿 lighttpd + fastcgi 的成例。

線程不能減少工作量,即不能減少 CPU 時間。如果解決一個問題需要執(zhí)行一億條指令(這個數(shù)字不大,不要被嚇到),那么用多線程只會讓這個數(shù)字增加。但是通過合理調(diào)配這一億條指令在多個核上的執(zhí)行情況,我們能讓工期提早結(jié)束。這聽上去像統(tǒng)籌方法,確實也正是統(tǒng)籌方法。


本站僅提供存儲服務,所有內(nèi)容均由用戶發(fā)布,如發(fā)現(xiàn)有害或侵權(quán)內(nèi)容,請點擊舉報。
打開APP,閱讀全文并永久保存 查看更多類似文章
猜你喜歡
類似文章
Netty原理
那些常見的并發(fā)模型相關(guān)知識
單線程的Redis為什么這么快?
關(guān)于如何提高Web服務端并發(fā)效率的異步編程技術(shù)
Java NIO淺析
Netty精粹之基于EventLoop機制的高效線程模型
更多類似文章 >>
生活服務
分享 收藏 導長圖 關(guān)注 下載文章
綁定賬號成功
后續(xù)可登錄賬號暢享VIP特權(quán)!
如果VIP功能使用有故障,
可點擊這里聯(lián)系客服!

聯(lián)系客服