5. 多執行緒程式如何讓 IO 和“計算”相互重疊,降低 latency?

劍西樓發表於2017-03-01

基本思路是,把 IO 操作(通常是寫操作)通過 BlockingQueue 交給別的執行緒去做,自己不必等待。

例1: logging

在多執行緒伺服器程式中,日誌 (logging) 至關重要,本例僅考慮寫 log file 的情況,不考慮 log server。

在一次請求響應中,可能要寫多條日誌訊息,而如果用同步的方式寫檔案(fprintf 或 fwrite),多半會降低效能,因為:

  • 檔案操作一般比較慢,服務執行緒會等在 IO 上,讓 CPU 閒置,增加響應時間。
  • 就算有 buffer,還是不靈。多個執行緒一起寫,為了不至於把 buffer 寫錯亂,往往要加鎖。這會讓服務執行緒互相等待,降低併發度。(同時用多個 log 檔案不是辦法,除非你有多個磁碟,且保證 log files 分散在不同的磁碟上,否則還是受到磁碟 IO 瓶頸制約。)

解決辦法是單獨用一個 logging 執行緒,負責寫磁碟檔案,通過一個或多個 BlockingQueue 對外提供介面。別的執行緒要寫日誌的時候,先把訊息(字串)準備好,然後往 queue 裡一塞就行,基本不用等待。這樣服務執行緒的計算就和 logging 執行緒的磁碟 IO 相互重疊,降低了服務執行緒的響應時間。

儘管 logging 很重要,但它不是程式的主要邏輯,因此對程式的結構影響越小越好,最好能簡單到如同一條 printf 語句,且不用擔心其他效能開銷,而一個好的多執行緒非同步 logging 庫能幫我們做到這一點。(Apache 的 log4cxx 和 log4j 都支援 AsyncAppender 這種非同步 logging 方式。)

例2: memcached 客戶端

假設我們用 memcached 來儲存使用者最後發帖的時間,那麼每次響應使用者發帖的請求時,程式裡要去設定一下 memcached 裡的值。這一步如果用同步 IO,會增加延遲。

對於“設定一個值”這樣的 write-only idempotent 操作,我們其實不用等 memcached 返回操作結果,這裡也不用在乎 set 操作失敗,那麼可以藉助多執行緒來降低響應延遲。比方說我們可以寫一個多執行緒版的 memcached 的客戶端,對於 set 操作,呼叫方只要把 key 和 value 準備好,呼叫一下 asyncSet() 函式,把資料往 BlockingQueue 上一放就能立即返回,延遲很小。剩下的時就留給 memcached 客戶端的執行緒去操心,而服務執行緒不受阻礙。

其實所有的網路寫操作都可以這麼非同步地做,不過這也有一個缺點,那就是每次 asyncWrite 都要線上程間傳遞資料,其實如果 TCP 緩衝區是空的,我們可以在本執行緒寫完,不用勞煩專門的 IO 執行緒。Jboss 的 Netty 就使用了這個辦法來進一步降低延遲。

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

另外以上的例子也說明,BlockingQueue 是構建多執行緒程式的利器。

相關文章