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<size_t>(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 这类请求-响应服务中,如果客户连接顺序发送多个请求,那么计算得到的多个响应是否按相同的顺序发还给客户(这里指的是在自然条件下,不含刻意同步)。
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 的演讲):
方案 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 的区别。
如果 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 的默认线程模型。
代码见: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<size_t>(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),又有突发计算的应用(利用线程池把一个连接上的计算任务分配给多个线程去做)。
这种其实方案看起来复杂,其实写起来很简单,只要把方案 8 的代码加一行 server_.setThreadNum(numThreads); 就行,这里就不举例了。
结语
我在《多线程服务器的常用编程模型》一文中说
总结起来,我推荐的多线程服务端编程模式为:event loop per thread + thread pool。
- event loop 用作 non-blocking IO 和定时器。
- thread pool 用来做计算,具体可以是任务队列或消费者-生产者队列。
当时(2010年2月)我还说“以这种方式写服务器程序,需要一个优质的基于 Reactor 模式的网络库来支撑,我只用过in-house的产品,无从比较并推荐市面上常见的 C++ 网络库,抱歉。”
现在有了 muduo 网络库,我终于能够用具体的代码示例把思想完整地表达出来。
Muduo 多线程模型:一个 Sudoku 服务器演变的更多相关文章
- Muduo 多线程模型对比
本文主要对比Muduo多线程模型方案8 和方案9 . 方案8:reactor + thread pool ,有一个线程来充当reactor 接受连接分发事件,将要处理的事件分配给thread pool ...
- Oracle12c(12.1)中性能优化&功能增强之通过参数THREADED_EXECTION使用多线程模型
1. 后台 UNIX/Linux系统上,oracle用多进程模型.例如:linux上一个常规安装的数据库会有如下进程列: $ ps -ef | grep [o]ra_ oracle 15356 ...
- 【netty】(2)---搭建一个简单服务器
netty(2)---搭建一个简单服务器 说明:本篇博客是基于学习慕课网有关视频教学.效果:当用户访问:localhost:8088 后 服务器返回 "hello netty"; ...
- 第13章 TCP编程(4)_基于自定义协议的多线程模型
7. 基于自定义协议的多线程模型 (1)服务端编程 ①主线程负责调用accept与客户端连接 ②当接受客户端连接后,创建子线程来服务客户端,以处理多客户端的并发访问. ③服务端接到的客户端信息后,回显 ...
- 【 Linux 】I/O工作模型及Web服务器原理
一.进程.线程 进程是具有一定独立功能的,在计算机中已经运行的程序的实体.在早期系统中(如linux 2.4以前),进程是基本运作单位,在支持线程的系统中(如windows,linux2.6) ...
- 追求性能极致:Redis6.0的多线程模型
Redis系列1:深刻理解高性能Redis的本质 Redis系列2:数据持久化提高可用性 Redis系列3:高可用之主从架构 Redis系列4:高可用之Sentinel(哨兵模式) Redis系列5: ...
- 【转载】COM的多线程模型
原文:COM的多线程模型 COM的多线程模型是COM技术里头最难以理解的部分之一,很多书都有涉及但是都没有很好的讲清楚.很多新人都会在这里觉得很迷惑,google大神能搜到一篇vckbase上的文章, ...
- Chrome多线程模型
为什么使用多线程? Chrome的多线程模型主要解决什么问题? 如何实现该问题的解决? 1. 解决问题 Chrome有很多线程,这是为了保持UI线程(主线程)的高响应度,防止被其他费时的操作阻碍从而影 ...
- C#多线程下载一个文件
这里只是说明多线程下载的理论基础,嘿嘿,并没有写多线程下载的代码,标题党了,但是我相信,看完这个代码就应该能够多线程的方式去下载一个文件了. 多线程下载是需要服务器支持的,这里并没有判断服务器不支持的 ...
随机推荐
- [postfix]添加黑名单
最近公司员工的邮箱总是收到twoomail.com的邀请邮件,邮箱服务器还没有添加过黑名单呢,就拿它开刀吧. 在主配置文件中添加如下配置 #vi /etc/postfix/main.cf #black ...
- can not find java.util.map java.lang.Double问题
mybatis @Param注解和ParamType属性不能共存
- 我的Android进阶之旅------>对Android开发者有益的40条优化建议
下面是开始Android编程的好方法: 找一些与你想做事情类似的代码 调整它,尝试让它做你像做的事情 经历问题 使用StackOverflow解决问题 对每个你像添加的特征重复上述过程.这种方法能够激 ...
- go语言之并发编程一
Go语言最大的优势就在于并发编程.Go语言的关键字go就是开启并发编程也就是goroutine的唯一途径.一条go语句以为着一个函数或方法的并发执行.Go语句是由go关键字和表达式组成.比如下面的这种 ...
- 创建第一个SpringBoot的demo程序
在这里,我只介绍手动创建的其中一种方式. 默认,你已经安装了IntelliJ IDEA和JDK1.8,如果没有,请先安装. 第一步:选择新建一个项目 File-->New-->Proj ...
- ABAP服务器文件操作
转自http://blog.itpub.net/547380/viewspace-876667/ 在程序设计开发过程中,很多要对文件进行操作,这又分为对本地文件操作和服务器文件操作.对本地文件操作使用 ...
- Apache Shiro 使用手册(二)Shiro 认证(转发:http://kdboy.iteye.com/blog/1154652)
认证就是验证用户身份的过程.在认证过程中,用户需要提交实体信息(Principals)和凭据信息(Credentials)以检验用户是否合法.最常见的“实体/凭证”组合便是“用户名/密码”组合. 一. ...
- Linux命令:grep,报错Binary file (standard input) matches
在Linux使用grep命令,从文件中抓取显示特定的信息,如下: cat 文件名 | grep 特定条件 ---> cat xxxx | grep 12345 结果报错:Binary fil ...
- 如何在官网下载java JDK的历史版本
如何在官网下载java JDK的历史版本 http://jingyan.baidu.com/article/9989c746064d46f648ecfe9a.html 如何在官网下载java JDK的 ...
- 查询某个字段为null并且某个字段不为null的数据
查询代码为null且ggid不为null的公司名 select name_of_invested_company from dwtz WHERE code is NULL and ggid is no ...