1. 程式人生 > >Muduo 多執行緒模型:一個 Sudoku 伺服器演變

Muduo 多執行緒模型:一個 Sudoku 伺服器演變

陳碩 (giantchen AT gmail)

blog.csdn.net/Solstice

本文以一個 Sudoku Solver 為例,回顧了併發網路服務程式的多種設計方案,並介紹了使用 muduo 網路庫編寫多執行緒伺服器的兩種最常用手法。以往的例子展現了 Muduo 在編寫單執行緒併發網路服務程式方面的能力與便捷性,今天我們看一看它在多執行緒方面的表現。

Sudoku Solver

假設有這麼一個網路程式設計任務:寫一個求解數獨的程式 (Sudoku Solver),並把它做成一個網路服務。

寫這麼一個程式在網路程式設計方面的難度不高,跟寫 echo 服務差不多(從網路連線讀入一個 Sudoku 題目,算出答案,再發回給客戶),挑戰在於怎樣做才能發揮現在多核硬體的能力?在談這個問題之前,讓我們先寫一個基本的單執行緒版。

協議

一個簡單的以 /r/n 分隔的文字行協議,使用 TCP 長連線,客戶端在不需要服務時主動斷開連線。

請求:[id:]〈81digits〉/r/n

響應:[id:]〈81digits〉/r/n 或者 [id:]NoSolution/r/n

其中[id:]表示可選的 id,用於區分先後的請求,以支援 Parallel Pipelining,響應中會回顯請求中的 id。Parallel Pipelining 的意義見賴勇浩的《以小見大——那些基於 protobuf 的五花八門的 RPC(2) 》,或者見我寫的《分散式系統的工程化開發方法》第 54 頁關於 out-of-order RPC 的介紹。

〈81digits〉是 Sudoku 的棋盤,9x9 個數字,未知數字以 0 表示。

如果 Sudoku 有解,那麼響應是填滿數字的棋盤;如果無解,則返回 NoSolution。

例子1:

請求:000000010400000000020000000000050407008000300001090000300400200050100000000806000/r/n

響應:693784512487512936125963874932651487568247391741398625319475268856129743274836159/r/n

例子2:

請求:a:000000010400000000020000000000050407008000300001090000300400200050100000000806000/r/n

響應:a:693784512487512936125963874932651487568247391741398625319475268856129743274836159/r/n

例子3:

請求:b:000000010400000000020000000000050407008000300001090000300400200050100000000806005

/r/n

響應:b:NoSolution/r/n

基於這個文字協議,我們可以用 telnet 模擬客戶端來測試 sudoku solver,不需要單獨編寫 sudoku client。SudokuSolver 的預設埠號是 9981,因為它有 9x9=81 個格子。

基本實現

Sudoku 的求解演算法見《談談數獨(Sudoku)》一文,這不是本文的重點。假設我們已經有一個函式能求解 Sudoku,它的原型如下

string solveSudoku(const string& puzzle);

函式的輸入是上文的"〈81digits〉",輸出是"〈81digits〉"或"NoSolution"。這個函式是個 pure function,同時也是執行緒安全的。

 // muduo/examples/sudoku/server_basic.cc

  const int kCells = 81;

  void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp)
  {
    LOG_DEBUG << conn->name();
    size_t len = buf->readableBytes();
    while (len >= kCells + 2)
    {
      const char* crlf = buf->findCRLF();
      if (crlf)
      {
        string request(buf->peek(), crlf);
        string id;
        buf->retrieveUntil(crlf + 2);
        string::iterator colon = find(request.begin(), request.end(), ':');
        if (colon != request.end())
        {
          id.assign(request.begin(), colon);
          request.erase(request.begin(), colon+1);
        }
        if (request.size() == implicit_cast(kCells))
        {
          string result = solveSudoku(request);
          if (id.empty())
          {
            conn->send(result+"/r/n");
          }
          else
          {
            conn->send(id+":"+result+"/r/n");
          }
        }
        else
        {
          conn->send("Bad Request!/r/n");
          conn->shutdown();
        }
      }
      else
      {
        break;
      }
    }
  }

server_basic.cc 是一個併發伺服器,可以同時服務多個客戶連線。但是它是單執行緒的,無法發揮多核硬體的能力。

Sudoku 是一個計算密集型的任務(見《Muduo 設計與實現之一:Buffer 類的設計》中關於其效能的分析),其瓶頸在 CPU。為了讓這個單執行緒 server_basic 程式充分利用 CPU 資源,一個簡單的辦法是在同一臺機器上部署多個 server_basic 程序,讓每個程序佔用不同的埠,比如在一臺 8 核機器上部署 8 個 server_basic 程序,分別佔用 9981、9982、……、9988 埠。這樣做其實是把難題推給了客戶端,因為客戶端(s)要自己做負載均衡。再想得遠一點,在 8 個 server_basic 前面部署一個 load balancer?似乎小題大做了。

能不能在一個埠上提供服務,並且又能發揮多核處理器的計算能力呢?當然可以,辦法不止一種。

常見的併發網路服務程式設計方案

W. Richard Stevens 的 UNP2e 第 27 章 Client-Server Design Alternatives 介紹了十來種當時(90 年代末)流行的編寫併發網路程式的方案。UNP3e 第 30 章,內容未變,還是這幾種。以下簡稱 UNP CSDA 方案。UNP 這本書主要講解阻塞式網路程式設計,在非阻塞方面著墨不多,僅有一章。正確使用 non-blocking IO 需要考慮的問題很多,不適宜直接呼叫 Sockets API,而需要一個功能完善的網路庫支撐。

隨著 2000 年前後第一次網際網路浪潮的興起,業界對高併發 http 伺服器的強烈需求大大推動了這一領域的研究,目前高效能 httpd 普遍採用的是單執行緒 reactor 方式。另外一個說法是 IBM Lotus 使用 TCP 長連線協議,而把 Lotus 服務端移植到 Linux 的過程中 IBM 的工程師們大大提高了 Linux 核心在處理併發連線方面的可伸縮性,因為一個公司可能有上萬人同時上線,連線到同一臺跑著 Lotus server 的 Linux 伺服器。

可伸縮網路程式設計這個領域其實近十年來沒什麼新東西,POSA2 已經作了相當全面的總結,另外以下幾篇文章也值得參考。

下表是陳碩總結的 10 種常見方案。其中“多連線互通”指的是如果開發 chat 服務,多個客戶連線之間是否能方便地交換資料(chat 也是《談一談網路程式設計學習經驗》中舉的三大 TCP 網路程式設計案例之一)。對於 echo/http/sudoku 這類“連線相互獨立”的服務程式,這個功能無足輕重,但是對於 chat 類服務至關重要。“順序性”指的是在 http/sudoku 這類請求-響應服務中,如果客戶連線順序傳送多個請求,那麼計算得到的多個響應是否按相同的順序發還給客戶(這裡指的是在自然條件下,不含刻意同步)。reactor_comparison

UNP CSDA 方案歸入 0~5。5 也是目前用得很多的單執行緒 reactor 方案,muduo 對此提供了很好的支援。6 和 7 其實不是實用的方案,只是作為過渡品。8 和 9 是本文重點介紹的方案,其實這兩個方案已經在《多執行緒伺服器的常用程式設計模型》一文中提到過,只不過當時我還沒有寫 muduo,無法用具體的程式碼示例來說明。

在對比各方案之前,我們先看看基本的 micro benchmark 資料(前三項由 lmbench 測得):

  • fork()+exit(): 160us
  • pthread_create()+pthread_join(): 12us
  • context switch : 1.5us
  • sudoku resolve: 100us (根據題目難度不同,浮動範圍 20~200us)

方案 0:這其實不是併發伺服器,而是 iterative 伺服器,因為它一次只能服務一個客戶。程式碼見 UNP figure 1.9,UNP 以此為對比其他方案的基準點。這個方案不適合長連線,到是很適合 daytime 這種 write-only 服務。

方案 1:這是傳統的 Unix 併發網路程式設計方案,UNP 稱之為 child-per-client 或 fork()-per-client,另外也俗稱 process-per-connection。這種方案適合併發連線數不大的情況。至今仍有一些網路服務程式用這種方式實現,比如 PostgreSQL 和 Perforce 的服務端。這種方案適合“計算響應的工作量遠大於 fork() 的開銷”這種情況,比如資料庫伺服器。這種方案適合長連線,但不太適合短連線,因為 fork() 開銷大於求解 sudoku 的用時。

方案 2:這是傳統的 Java 網路程式設計方案 thread-per-connection,在 Java 1.4 引入 NIO 之前,Java 網路服務程式多采用這種方案。它的初始化開銷比方案 1 要小很多。這種方案的伸縮性受到執行緒數的限制,一兩百個還行,幾千個的話對作業系統的 scheduler 恐怕是個不小的負擔。

方案 3:這是針對方案 1 的優化,UNP 詳細分析了幾種變化,包括對 accept 驚群問題的考慮。

方案 4:這是對方案 2 的優化,UNP 詳細分析了它的幾種變化。

以上幾種方案都是阻塞式網路程式設計,程式(thread-of-control)通常阻塞在 read() 上,等待資料到達。但是 TCP 是個全雙工協議,同時支援 read() 和 write() 操作,當一個執行緒/程序阻塞在 read() 上,但程式又想給這個 TCP 連線發資料,那該怎麼辦?比如說 echo client,既要從 stdin 讀,又要從網路讀,當程式正在阻塞地讀網路的時候,如何處理鍵盤輸入?又比如 proxy,既要把連線 a 收到的資料發給連線 b,又要把從連線 b 收到的資料發給連線 a,那麼到底讀哪個?(proxy 是《談一談網路程式設計學習經驗》中舉的三大 TCP 網路程式設計案例之一。)

一種方法是用兩個執行緒/程序,一個負責讀,一個負責寫。UNP 也在實現 echo client 時介紹了這種方案。另外見 Python Pinhole 的程式碼:http://code.activestate.com/recipes/114642/

另一種方法是使用 IO multiplexing,也就是 select/poll/epoll/kqueue 這一系列的“多路選擇器”,讓一個 thread-of-control 能處理多個連線。“IO 複用”其實複用的不是 IO 連線,而是複用執行緒。使用 select/poll 幾乎肯定要配合 non-blocking IO,而使用 non-blocking IO 肯定要使用應用層 buffer,原因見《Muduo 設計與實現之一:Buffer 類的設計》。這就不是一件輕鬆的事兒了,如果每個程式都去搞一套自己的 IO multiplexing 機制(本質是 event-driven 事件驅動),這是一種很大的浪費。感謝 Doug Schmidt 為我們總結出了 Reactor 模式,讓 event-driven 網路程式設計有章可循。繼而出現了一些通用的 reactor 框架/庫,比如 libevent、muduo、Netty、twisted、POE 等等,有了這些庫,我想基本不用去編寫阻塞式的網路程式了(特殊情況除外,比如 proxy 流量限制)。

單執行緒 reactor 的程式結構是(圖片取自 Doug Lea 的演講):

reactor_basic

方案 5:基本的單執行緒 reactor 方案,即前面的 server_basic.cc 程式。本文以它作為對比其他方案的基準點。這種方案的優點是由網路庫搞定資料收發,程式只關心業務邏輯;缺點在前面已經談了:適合 IO 密集的應用,不太適合 CPU 密集的應用,因為較難發揮多核的威力。

方案 6:這是一個過渡方案,收到 Sudoku 請求之後,不在 reactor 執行緒計算,而是建立一個新執行緒去計算,以充分利用多核 CPU。這是非常初級的多執行緒應用,因為它為每個請求(而不是每個連線)建立了一個新執行緒。這個開銷可以用執行緒池來避免,即方案 8。這個方案還有一個特點是 out-of-order,即同時建立多個執行緒去計算同一個連線上收到的多個請求,那麼算出結果的次序是不確定的,可能第 2 個 Sudoku 比較簡單,比第 1 個先算出結果。這也是為什麼我們在一開始設計協議的時候使用了 id,以便客戶端區分 response 對應的是哪個 request。

方案 7:為了讓返回結果的順序確定,我們可以為每個連線建立一個計算執行緒,每個連線上的請求固定發給同一個執行緒去算,先到先得。這也是一個過渡方案,因為併發連線數受限於執行緒數目,這個方案或許還不如直接使用阻塞 IO 的 thread-per-connection 方案2。方案 7 與方案 6 的另外一個區別是一個 client 的最大 CPU 佔用率,在方案 6 中,一個 connection 上發來的一長串突發請求(burst requests) 可以佔滿全部 8 個 core;而在方案 7 中,由於每個連線上的請求固定由同一個執行緒處理,那麼它最多佔用 12.5% 的 CPU 資源。這兩種方案各有優劣,取決於應用場景的需要,到底是公平性重要還是突發性能重要。這個區別在方案 8 和方案 9 中同樣存在,需要根據應用來取捨。

方案 8:為了彌補方案 6 中為每個請求建立執行緒的缺陷,我們使用固定大小執行緒池,程式結構如下圖。全部的 IO 工作都在一個 reactor 執行緒完成,而計算任務交給 thread pool。如果計算任務彼此獨立,而且 IO 的壓力不大,那麼這種方案是非常適用的。Sudoku Solver 正好符合。程式碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_threadpool.cc 後文給出了它與方案 9 的區別。

reactor_threadpool

如果 IO 的壓力比較大,一個 reactor 忙不過來,可以試試 multiple reactors 的方案 9。

方案 9:這是 muduo 內建的多執行緒方案,也是 Netty 內建的多執行緒方案。這種方案的特點是 one loop per thread,有一個 main reactor 負責 accept 連線,然後把連線掛在某個 sub reactor 中(muduo 採用 round-robin 的方式來選擇 sub reactor),這樣該連線的所有操作都在那個 sub reactor 所處的執行緒中完成。多個連線可能被分派到多個執行緒中,以充分利用 CPU。Muduo 採用的是固定大小的 reactor pool,池子的大小通常根據 CPU 核數確定,也就是說執行緒數是固定的,這樣程式的總體處理能力不會隨連線數增加而下降。另外,由於一個連線完全由一個執行緒管理,那麼請求的順序性有保證,突發請求也不會佔滿全部 8 個核(如果需要優化突發請求,可以考慮方案 10)。這種方案把 IO 分派給多個執行緒,防止出現一個 reactor 的處理能力飽和。與方案 8 的執行緒池相比,方案 9 減少了進出 thread pool 的兩次上下文切換。我認為這是一個適應性很強的多執行緒 IO 模型,因此把它作為 muduo 的預設執行緒模型。

reactor_multiple

server_multiloop.cc 與 server_basic.cc 的區別很小,關鍵只有一行程式碼:server_.setThreadNum(numThreads);

$ diff server_basic.cc server_multiloop.cc -up
--- server_basic.cc     2011-06-15 13:40:59.000000000 +0800
+++ server_multiloop.cc 2011-06-15 13:39:53.000000000 +0800
@@ -21,19 +21,22 @@ using namespace muduo::net;
 class SudokuServer
 {
  public:
-  SudokuServer(EventLoop* loop, const InetAddress& listenAddr)
+  SudokuServer(EventLoop* loop, const InetAddress& listenAddr, int numThreads)
     : loop_(loop),
       server_(loop, listenAddr, "SudokuServer"),
+      numThreads_(numThreads),
       startTime_(Timestamp::now())
   {
     server_.setConnectionCallback(
         boost::bind(&SudokuServer::onConnection, this, _1));
     server_.setMessageCallback(
         boost::bind(&SudokuServer::onMessage, this, _1, _2, _3));
+    server_.setThreadNum(numThreads);
   }

方案 8 使用 thread pool 的程式碼與使用多 reactors 的方案 9 相比變化不大,只是把原來 onMessage() 中涉及計算和發回響應的部分抽出來做成一個函式,然後交給 ThreadPool 去計算。記住方案 8 有 out-of-order 的可能,客戶端要根據 id 來匹配響應。

$ diff server_multiloop.cc server_threadpool.cc -up
--- server_multiloop.cc 2011-06-15 13:39:53.000000000 +0800
+++ server_threadpool.cc        2011-06-15 14:07:52.000000000 +0800
@@ -31,12 +32,12 @@ class SudokuServer
         boost::bind(&SudokuServer::onConnection, this, _1));
     server_.setMessageCallback(
         boost::bind(&SudokuServer::onMessage, this, _1, _2, _3));
-    server_.setThreadNum(numThreads);
   }

   void start()
   {
     LOG_INFO << "starting " << numThreads_ << " threads.";
+    threadPool_.start(numThreads_);
     server_.start();
   }

@@ -68,15 +69,7 @@ class SudokuServer
         }
         if (request.size() == implicit_cast(kCells))
         {
-          string result = solveSudoku(request);
-          if (id.empty())
-          {
-            conn->send(result+"/r/n");
-          }
-          else
-          {
-            conn->send(id+":"+result+"/r/n");
-          }
+          threadPool_.run(boost::bind(solve, conn, request, id));
         }
         else
         {
@@ -91,8 +84,23 @@ class SudokuServer
     }
   }

+  static void solve(const TcpConnectionPtr& conn, const string& request, const string& id)
+  {
+    LOG_DEBUG << conn->name();
+    string result = solveSudoku(request);
+    if (id.empty())
+    {
+      conn->send(result+"/r/n");
+    }
+    else
+    {
+      conn->send(id+":"+result+"/r/n");
+    }
+  }
+
   EventLoop* loop_;
   TcpServer server_;
+  ThreadPool threadPool_;
   int numThreads_;
   Timestamp startTime_;
 };

方案 10:把方案 8 和方案 9 混合,既使用多個 reactors 來處理 IO,又使用執行緒池來處理計算。這種方案適合既有突發 IO (利用多執行緒處理多個連線上的 IO),又有突發計算的應用(利用執行緒池把一個連線上的計算任務分配給多個執行緒去做)。

reactor_hybrid

這種其實方案看起來複雜,其實寫起來很簡單,只要把方案 8 的程式碼加一行 server_.setThreadNum(numThreads); 就行,這裡就不舉例了。

結語

總結起來,我推薦的多執行緒服務端程式設計模式為:event loop per thread + thread pool。

  • event loop 用作 non-blocking IO 和定時器。
  • thread pool 用來做計算,具體可以是任務佇列或消費者-生產者佇列。

當時(2010年2月)我還說“以這種方式寫伺服器程式,需要一個優質的基於 Reactor 模式的網路庫來支撐,我只用過in-house的產品,無從比較並推薦市面上常見的 C++ 網路庫,抱歉。

現在有了 muduo 網路庫,我終於能夠用具體的程式碼示例把思想完整地表達出來。