Muduo 多線程模型:一個 Sudoku 服務器演變

陳碩 (giantchen AT gmail)

blog.csdn.net/Solstice

Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

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

本文代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/

下載:http://muduo.googlecode.com/files/muduo-0.2.5-alpha.tar.gz

Sudoku Solver

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

Sudoku Solver 是我喜愛的網絡編程例子,它曾經出現在《分佈式系統部署、監控與進程管理的幾重境界》、《Muduo 設計與實現之一:Buffer 類的設計》、《〈多線程服務器的適用場合〉例釋與答疑》等文中,它也可以看成是 echo 服務的一個變種(《談一談網絡編程學習經驗》把 echo 列爲三大 TCP 網絡編程案例之一)。

寫這麼一個程序在網絡編程方面的難度不高,跟寫 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 網絡編程示例之零:前言》中的 EchoServer 爲藍本,稍作修改就能得到 SudokuServer。這裏只列出最關鍵的 onMessage() 函數,完整的代碼見http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_basic.cc 。onMessage() 的主要功能是處理協議格式,並調用 solveSudoku() 求解問題。

 // 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 已經作了相當全面的總結,另外以下幾篇文章也值得參考。

http://bulk.fefe.de/scalable-networking.pdf

http://www.kegel.com/c10k.html

http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf

下表是陳碩總結的 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

代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_multiloop.cc

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_;
 };

完整代碼見:http://code.google.com/p/muduo/source/browse/trunk/examples/sudoku/server_threadpool.cc

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

reactor_hybrid

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章