蔻蔻姆的h道具店 修改:《多线程服务器的适用场合》-- 例释与答疑

来源:百度文库 编辑:九乡新闻网 时间:2024/04/30 22:09:08

《多线程服务器的适用场合》-- 例释与答疑

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

《多线程服务器的适用场合》(以下简称《适用场合》)一文在博客登出之后,有热心读者提出质疑,我自己也觉得原文没有把道理说通说透,这篇文章试图用一些实例来解答读者的疑问。我本来打算修改原文,但是考虑到已经读过的读者不一定会注意到文章的变动,干脆另写一篇。为方便阅读,本文以问答体呈现。这篇文章可能会反复修改扩充,请注意上面的版本号。

本文所说的“多线程服务器”的定义与前文一样,同时参见《多线程服务器的常用编程模型》(以下简称《常用模型》)一文的详细界定,以下“连接、端口”均指 TCP 协议。

1. Linux 能同时启动多少个线程?
对于 32-bit Linux,一个进程的地址空间是 4G,其中用户态能访问 3G 左右,而一个线程的默认栈 (stack) 大小是10M,心算可知,一个进程大约最多能同时启动 300 个线程。如果不改线程的调用栈大小的话,300左右是上限,因为程序的其他部分(数据段、代码段、堆、动态库、等等)同样要占用内存(地址空间)。

对于 64-bit 系统,线程数目可大大增加,具体数字我没有测试,因为我实际用不到那么多线程。

以下的关于线程数目的讨论以 32-bit Linux 为例。

2. 多线程能提高并发度吗?
如果指的是“并发连接数”,不能。

由问题 1 可知,假如单纯采用 thread per connection 的模型,那么并发连接数最多300,这远远低于基于事件的单线程程序所能轻松达到的并发连接数(几千上万,甚至几万)。所谓“基于事件”,指的是用 IO multiplexingevent loop 的编程模型,又称 Reactor 模式,在《常用模型》一文中已有介绍。

那么采用《常用模型》一文中推荐的 event loop per thread 呢?至少不逊于单线程程序。

小结:thread per connection 不适合高并发场合,其 scalability 不佳。event loop per thread 的并发度不比单线程程序差。

3. 多线程能提高吞吐量吗?
对于计算密集型服务,不能。

假设有一个耗时的计算服务,用单线程算需要 0.8s。在一台 8 核的机器上,我们可以启动 8 个线程一起对外服务(如果内存够用,启动 8个进程也一样)。这样完成单个计算仍然要 0.8s,但是由于这些进程的计算可以同时进行,理想情况下吞吐量可以从单线程的 1.25cps (calcper second) 上升到 10cps。(实际情况可能要打个八折——如果不是打对折的话。)

假如改用并行算法,用 8 个核一起算,理论上如果完全并行,加速比高达 8,那么计算时间是 0.1s,吞吐量还是10cps,但是首次请求的响应时间却降低了很多。实际上根据 Amdahl's law,即便算法的并行度高达 95%,8 核的加速比也只有6,计算时间为 0.133s,这样会造成吞吐量下降为 7.5cps。不过以此为代价,换得响应时间的提升,在有些应用场合也是值得的。

这也回答了问题 4。

如果用 thread per request 的模型,每个客户请求用一个线程去处理,那么当并发请求数大于某个临界值 T’时,吞吐量反而会下降,因为线程多了以后上下文切换的开销也随之增加(分析与数据请见《A Design Framework for HighlyConcurrent Systems》 by Matt Welsh et al.)。thread per request是最简单的使用线程的方式,编程最容易,简单地把多线程程序当成一堆串行程序,用同步的方式顺序编程,比如 Java Servlet中,一次页面请求由一个函数 HttpServlet#service(HttpServletRequest req,HttpServletResponse resp) 同步地完成。

为了在并发请求数很高时也能保持稳定的吞吐量,我们可以用线程池,线程池的大小应该满足“阻抗匹配原则”,见问题 7。

线程池也不是万能的,如果响应一次请求需要做比较多的计算(比如计算的时间占整个 response time 的 1/5强),那么用线程池是合理的,能简化编程。如果一次请求响应中,thread 主要是在等待 IO,那么为了进一步提高吞吐,往往要用其它编程模型,比如Proactor,见问题 8。

4. 多线程能降低响应时间吗?
如果设计合理,充分利用多核资源的话,可以。在突发 (burst) 请求时效果尤为明显。

例1: 多线程处理输入。

以 memcached 服务端为例。memcached 一次请求响应大概可以分为 3 步:

1.读取并解析客户端输入
2.操作 hashtable
3.返回客户端
在单线程模式下,这 3 步是串行执行的。在启用多线程模式时,它会启用多个输入线程(默认是 4 个),并在建立连接时按 round-robin法把新连接分派给其中一个输入线程,这正好是我说的 event loop per thread 模型。这样一来,第 1步的操作就能多线程并行,在多核机器上提高多用户的响应速度。第 2 步用了全局锁,还是单线程的,这可算是一个值得继续改进的地方。

比如,有两个用户同时发出了请求,这两个用户的连接正好分配在两个 IO 线程上,那么两个请求的第 1步操作可以在两个线程上并行执行,然后汇总到第 2步串行执行,这样总的响应时间比完全串行执行要短一些(在“读取并解析”所占的比重较大的时候,效果更为明显)。请继续看下面这个例子。

例2: 多线程分担负载。

假设我们要做一个求解 Sudoku 的服务(见《谈谈数独》),这个服务程序在 9981 端口接受请求,输入为一行 81 个数字(待填数字用 0 表示),输出为填好之后的 81 个数字 (1 ~ 9),如果无解,输出 “NO\r\n”。

由于输入格式很简单,用单个线程做 IO 就行了。先假设每次求解的计算用时 10ms,用前面的方法计算,单线程程序能达到的吞吐量上限为100req/s,在 8 核机器上,如果用线程池来做计算,能达到的吞吐量上限为 800req/s。下面我们看看多线程如何降低响应时间。

假设 1 个用户在极短的时间内发出了 10 个请求,如果用单线程“来一个处理一个”的模型,这些 reqs会排在队列里依次处理(这个队列是操作系统的 TCP 缓冲区,不是程序里自己的任务队列)。在不考虑网络延迟的情况下,第 1 个请求的响应时间是10ms;第 2 个请求要等第 1 个算完了才能获得 CPU 资源,它等了 10ms,算了 10ms,响应时间是 20ms;依次类推,第 10个请求的响应时间为 100ms;10个请求的平均响应时间为 55ms。

如果 Sudoku 服务在每个请求到达时开始计时,会发现每个请求都是 10ms 响应时间,而从用户的观点,10 个请求的平均响应时间为 55ms,请读者想想为什么会有这个差异。

下面改用多线程:1 个 IO 线程,8 个计算线程(线程池)。二者之间用 BlockingQueue 沟通。同样是 10 个并发请求,第 1个请求被分配到计算线程1,第 2 个请求被分配到计算线程 2,以此类推,直到第 8 个请求被第 8 个计算线程承担。第 9 和第 10号请求会等在 BlockingQueue 里,直到有计算线程回到空闲状态才能被处理。(请注意,这里的分配实际上是由操作系统来做,操作系统会从处于Waiting 状态的线程里挑一个,不一定是 round-robin 的。)

这样一来,前 8 个请求的响应时间差不多都是 10ms,后 2 个请求属于第二批,其响应时间大约会是 20ms,总的平均响应时间是 12ms。可以看出比单线程快了不少。

由于每道 Sudoku 题目的难度不一,对于简单的题目,可能 1ms 就能算出来,复杂的题目最多用 10ms。那么线程池方案的优势就更明显,它能有效地降低“简单任务被复杂任务压住”的出现概率。

以上举的都是计算密集的例子,即线程在响应一次请求时不会等待 IO,下面谈谈更复杂的情况。

5. 多线程程序如何让 IO 和“计算”相互重叠,降低 latency?
基本思路是,把 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 是构建多线程程序的利器。

6. 为什么第三方库往往要用自己的线程?
往往因为 event loop 模型没有标准实现。如果自己写代码,尽可以按所用 Reactor 的推荐方式来编程,但是第三方库不一定能很好地适应并融入这个 event loop framework。有时需要用线程来做一些串并转换。

对于 Java,这个问题还好办一些,因为 thread pool 在 Java 里有标准实现,叫ExecutorService。如果第三方库支持线程池,那么它可以和主程序共享一个 ExecutorService,而不是自己创建一堆线程。(比如在初始化时传入主程序的 obj。)对于 C++,情况麻烦得多,Reactor 和 Thread pool都没有标准库。

例1:libmemcached 只支持同步操作

libmemcached 支持所谓的“非阻塞操作”,但没有暴露一个能被 select/poll/epoll 的 filedescriber,它的 memcached_fetch 始终会阻塞。它号称 memcached_set可以是非阻塞的,实际意思是不必等待结果返回,但实际上这个函数会同步地调用 write(),仍可能阻塞在网络 IO 上。

如果在我们的 reactor event handler 里调用了 libmemcached 的函数,那么 latency就堪忧了。如果想继续用 libmemcached,我们可以为它做一次线程封装,按问题 5 例 2 的办法,同额外的线程专门做 memcached的 IO,而程序主体还是 reactor。甚至可以把 memcached “数据就绪”作为一个 event,注入到我们的 event loop中,以进一步提高并发度。(例子留待问题 8 讲)

万幸的是,memcached 的协议非常简单,大不了可以自己写一个基于 reactor 的客户端,但是数据库客户端就没那么幸运了。

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

MySQL 的客户端只支持同步操作,对于 UPDATE/INSERT/DELETE之类只要行为不管结果的操作(如果代码需要得知其执行结果则另当别论),我们可以用一个单独的线程来做,以降低服务线程的延迟。可仿照前面memcached_set 的例子,不再赘言。麻烦的是 SELECT,如果要把它也异步化,就得动用更复杂的模式了,见问题 8。

相比之下,PostgreSQL 的 C 客户端 libpq 的设计要好得多,我们可以用 PQsendQuery()来发起一次查询,然后用标准的 select/poll/epoll 来等待 PQsocket,如果有数据可读,那么用 PQconsumeInput处理之,并用 PQisBusy 判断查询结果是否已就绪,最后用 PQgetResult 来获取结果。借助这套异步 API,我们可以很容易地为libpq 写一套 wrapper,使之融入到程序所用的 reactor 模型中。

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

如果池中线程在执行任务时,密集计算所占的时间比重为 P (0 < P <= 1),而系统一共有 C 个 CPU,为了让这 C 个CPU 跑满而又不过载,线程池大小的经验公式 T = C/P。(T 是个 hint,考虑到 P 值的估计不是很准确,T 的最佳值可以上下浮动50%。)

以后我再讲这个经验公式是怎么来的,先验证边界条件的正确性。

假设 C = 8, P = 1.0,线程池的任务完全是密集计算,那么 T = 8。只要 8 个活动线程就能让 8 个 CPU 饱和,再多也没用,因为 CPU 资源已经耗光了。

假设 C = 8, P = 0.5,线程池的任务有一半是计算,有一半等在 IO 上,那么 T = 16。考虑操作系统能灵活合理地调度sleeping/writing/running 线程,那么大概 16 个“50% 繁忙的线程”能让 8 个 CPU忙个不停。启动更多的线程并不能提高吞吐量,反而因为增加上下文切换的开销而降低性能。

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

另外,公式里的 C 不一定是 CPU 总数,可以是“分配给这项任务的 CPU 数目”,比如在 8 核机器上分出 4 个核来做一项任务,那么 C=4。

8. 除了你推荐的 reactor + thread poll,还有别的 non-trivial 多线程编程模型吗?
有,Proactor。

如果一次请求响应中要和别的进程打多次交道,那么 proactor 模型往往能做到更高的并发度。当然,代价是代码变得支离破碎,难以理解。

这里举 http proxy 为例,一次 http proxy 的请求如果没有命中本地 cache,那么它多半会:

1.解析域名 (不要小看这一步,对于一个陌生的域名,解析可能要花半秒钟)
2.建立连接
3.发送 HTTP 请求
4.等待对方回应
5.把结果返回客户
这 5 步里边跟 2 个 server 发生了 3 次 round-trip:

1.向 DNS 问域名,等待回复;
2.向对方 http 服务器发起连接,等待 TCP 三路握手完成;
3.向对方发送 http request,等待对方 response。
而实际上 http proxy 本身的运算量不大,如果用线程池,池中线程的数目会很庞大,不利于操作系统管理调度。

这时我们有两个解决思路:

1.把“域名已解析”,“连接已建立”,“对方已完成响应”做成 event,继续按照 Reactor 的方式来编程。这样一来,每次客户请求就不能用一个函数从头到尾执行完成,而要分成多个阶段,并且要管理好请求的状态(“目前到了第几步?”)。
2.用回调函数,让系统来把任务串起来。比如收到用户请求,如果没有命中本地 cache,立刻发起异步的 DNS 解析startDNSResolve(),告诉系统在解析完之后调用 DNSResolved() 函数;在 DNSResolved()中,发起连接,告诉系统在连接建立之后调用 connectionEstablished();在 connectionEstablished()中发送 http request,告诉系统在收到响应之后调用 httpResponsed();最后,在 httpResponsed()里把结果返回给客户。.NET 大量采用的 Begin/End操作也是这个编程模式。当然,对于不熟悉这种编程方式的人,代码会显得很难看。Proactor 模式的例子可看 boost::asio的文档,这里不再多说。
Proactor 模式依赖操作系统或库来高效地调度这些子任务,每个子任务都不会阻塞,因此能用比较少的线程达到很高的 IO 并发度。

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

9. 模式 2 和模式 3a 该如何取舍?
这里的“模式”不是 pattern,而是 model,不巧它们的中译是一样的。《适用场合》中提到,模式 2 是一个多线程的进程,模式 3a 是多个相同的单线程进程。

我认为,在其他条件相同的情况下,可以根据工作集 (work set) 的大小来取舍。工作集是指服务程序响应一次请求所访问的内存大小。

如果工作集较大,那么就用多线程,避免 CPU cache 换入换出,影响性能;否则,就用单线程多进程,享受单线程编程的便利。

例如,memcached 这个内存消耗大户用多线程服务端就比在同一台机器上运行多个 memcached instance 要好。(除非你在 16G 内存的机器上运行 32-bit memcached,那么多 instance 是必须的。)

又例如,求解 Sudoku 用不了多大内存,如果单线程编程更方便的话,可以用单线程多进程来做。再在前面加一个单线程的 load balancer,仿 lighttpd + fastcgi 的成例。

线程不能减少工作量,即不能减少 CPU 时间。如果解决一个问题需要执行一亿条指令(这个数字不大,不要被吓到),那么用多线程只会让这个数字增加。但是通过合理调配这一亿条指令在多个核上的执行情况,我们能让工期提早结束。这听上去像统筹方法,确实也正是统筹方法。