• 本文内容衔接于前一篇文章(进程间通信只用TCP)

一、服务器开发概述

“服务器开发”包罗万象,用一句话形容是:​跑在多核机器上的Linux用户态的没有用户界面的长期运行(例如wget是不长期运行,httpd是长期运行的)的网络应用程序,通常是分布式系统的组成部件


并发处理

  • 开发服务端程序的一个基本任务是处理并发连接,现在服务端网络编程处理并发连接主要有两种方式:
  • 当“线程”很廉价时,一台机器上可以创建远高于CPU数目的“线程”。​这时一个线程只处理一个TCP连接(甚至半个),通常使用阻塞 IO(至少看起来如此)。例如,Python gevent、Go goroutine、Erlang actor。这里的“线程”由语言的runtime自行调度,与操作系统线程不是一 回事
  • 当线程很宝贵时,一台机器上只能创建与CPU数目相当的线程。​这时一个线程要处理多个TCP连接上的IO,通常使用非阻塞IO和IO multiplexing。例如,libevent、muduo、Netty。这是原生线程,能被操 作系统的任务调度器看见
  • 在处理并发连接的同时,也要充分发挥硬件资源的作用,不能让CPU资源闲置:
  • 以上列出的库不是每个都能做到这一点
  • 既然讨论的是C++编程,那么只考虑后一种方式,这是在Linux下使用native语言编写用户态高性能网络程序的最成熟的模式
  • 本节主要讨论的是这些“线程”应该属于一个进程(以下模式2),还是分属多个进程(模式3)
  • 本文的​“进程”指的是fork系统调用的产物。“线程”指的是pthread_create()的产物,​因此是宝贵的那种原生线程。而且我指的Pthreads是NPTL的,每个线程由clone产生,对应一个内核的task_struct



相关模式

  • 首先,一个由多台机器组成的分布式系统必然是多进程的(字面意义上),因为进程不能跨OS边界。在这个前提下,我们把目光集中到 一台机器,一台拥有至少4个核的普通服务器。​如果要在一台多核机器上提供一种服务或执行一个任务,可用的模式有​(这里的“模式”不是pattern,而是model)​
  • 1.运行一个单线程的进程
  • 2.运行一个多线程的进程
  • 3.运行多个单线程的进程
  • 4.运行多个多线程的进程
  • 这些模式之间的比较已经是老生常谈,简单地总结如下:
  • 模式1是不可伸缩的(scalable),​不能发挥多核机器的计算能力
  • 模式3是目前公认的主流模式​。它有以下两种子模式:
  • 3a​​简单地把模式1中的进程运行多份(如果能用多个TCP port对外提供服务的话)
  • 3b​​主进程+woker进程,如果必须绑定到一个TCP port,比如 httpd+fastcgi
  • 模式2是被很多人所鄙视的,​认为多线程程序难写,而且与模式3 相比并没有什么优势
  • 模式4更是千夫所指,​它不但没有结合2和3的优点,反而汇聚了二者的缺点
  • 本文主要想讨论的是模式2和模式3b的优劣,即:​什么时候一个服务器程序应该是多线程的:
  • 从功能上讲,​没有什么是多线程能做到而单 线程做不到的,反之亦然,都是状态机嘛(我很高兴看到反例)
  • 从性能上讲,​无论是IO bound还是CPU bound的服务,多线程都没有什么优势


  • Paul E. McKenney在《Is Parallel Programming Hard, And, If So, What Can You Do About It?》第3.5节指出,“As a rough rule of thumb, use the simplest tool that will get the job done.”。比方说,使用速率为50MB/s的数据压缩库、在进程创建销毁的开销是800μs、线程创建销毁的开销是 50μs的前提下,考虑如何执行压缩任务:
  • 如果要偶尔压缩1GB的文本文件,预计运行时间是20s,那么起一个进程去做是合理的,​因为进程启动和销毁的开销远远小于实际任务的 耗时
  • 如果要经常压缩500kB的文本数据,预计运行时间是10ms,那么每次都起进程似乎有点浪费了,​可以每次单独起一个线程去做
  • 如果要频繁压缩10kB的文本数据,预计运行时间是200μs,那么每次起线程似乎也很浪费,不如直接在当前线程搞定​。也可以用一个线程池,每次把压缩任务交给线程池,避免阻塞当前线程(特别要避免阻塞IO线程)
  • 由此可见,​多线程并不是万灵丹,它有适用的场合​。那么究竟什么时候该用多线程?在回答这个问题之前,我先谈谈必须用单线程的场合

二、必须用单线程的场合

  • 据我所知,有两种场合必须使用单线程:
  • 1.程序可能会fork
  • 2.限制程序的CPU占用率


①只有单线程程序能fork

  • 根据后面“多线程与fork()”文章的分析,​一个设计为可能调用fork的程序必须是单线程的,​比如后面“多线程与fork()”文章中提到的“看门狗进程”
  • 多线程程序不是不能调用fork,而是这么做会遇到很多麻烦, ​我想不出做的理由
  • 一个程序fork之后一般有两种行为:
  • 1.立刻执行exec(),变身为另一个程序。​例如shell和inetd;又比如 lighttpd fork()出子进程,然后运行fastcgi程序。或者集群中运行在计算 节点上的负责启动job的守护进程(即我所谓的“看门狗进程”)
  • 2.不调用exec(),继续运行当前程序。​要么通过共享的文件描述符与父进程通信,协同完成任务;要么接过父进程传来的文件描述符,独 立完成工作,例如20世纪80年代的Web服务器NCSA httpd
  • 这些行为中,我认为只有“看门狗进程”必须坚持单线程,其他的均可替换为多线程程序(从功能上讲)



②单线程程序能限制程序的CPU占用率

  • 单线程程序​能限制程序的CPU占用率这个很容易理解
  • 比如在一个8核的服务器上,一个单线程程序即便发生busy-wait(无论是因为bug,还是因为overload),占满1个core,其CPU使用率也只有12.5%。在这种最坏的情况下,系统还是有87.5%的计算资源可供其他服务进程使用


  • 因此对于一些辅助性的程序,​如果它必须和主要服务进程运行在同一台机器的话​(比如它要监控其他服务进程的状态),那么​做成单线程的能避免过分抢夺系统的计算资源,比方说:
  • 如果要把生产服务器上的日志文件压缩后备份到NFS上,那么应该使用普通单线程压缩工具 (gzip/bzip2)。它们对系统造成的影响较小,在8核服务器上最多占满1个core
  • 如果有人为了“提高速度”,开启了多线程压缩或者同时起多个进程来压缩多个日志文件,有可能造成的结果是非关键任务耗尽了CPU资源,正常客户的请求响应变慢。这是我们不愿意看到的

三、单线程程序的优缺点

  • 从编程的角度,​单线程程序的优势无须赘言:简单
  • 单线程程序的结构:
  • Event loop有一个明显的缺点,它是非抢占的:
  • 假设事件a的优先级高于事件b,处理事件a需要1ms,处理事件b需要 10ms。如果事件b稍早于a发生,那么当事件a到来时,程序已经离开了 poll(2)调用,并开始处理事件b。事件a要等上10ms才有机会被处理,总的响应时间为11ms
  • 这等于发生了优先级反转。这个缺点可以用多线程来克服,这也是多线程的主要优势


多线程程序有性能优势吗

  • 前面说过,无论是IO bound还是CPU bound的服务,多线程都没有什么绝对意义上的性能优势。这句话是说,​如果用很少的CPU负载就能让IO跑满,或者用很少的IO流量就能让CPU跑满,那么多线程没啥用处
  • 举例来说:
  • 对于静态Web服务器,或者FTP服务器,​CPU的负载较轻,主要瓶颈在磁盘IO和网络IO方面​。这时候往往一个单线程的程序(模式1)就能撑满IO。用多线程并不能提高吞吐量,因为IO硬件容量已经饱和了。 同理,这时增加CPU数目也不能提高吞吐量
  • CPU跑满的情况比较少见,这里我只好虚构一个例子。​假设有一个服务,它的输入是n个整数,问能否从中选出m个整数,使其和为 0(这里n<100, m>0)。这是著名的subset sum问题,是NP-Complete 的。对于这样一个“服务”,哪怕很小的n值也会让CPU算死。比如n= 30,一次的输入不过200字节(32-bit整数),CPU的运算时间却能长达几分钟。对于这种应用,模式3a是最适合的,能发挥多核的优势,程序也简单
  • 也就是说,无论任何一方早早地先到达瓶颈,多线程程序都没啥优势


四、适合多线程程序的场景

  • 我认为多线程的适用场景是:​提高响应速度,让IO和“计算”相互重叠,降低latency(延迟)​。虽然多线程不能提高绝对性能,但能提高平均响应性能
  • 一个程序要做成多线程的,大致要满足:
  • 有多个CPU可用。​单核机器上多线程没有性能优势(但或许能简 化并发业务逻辑的实现)
  • 线程间有共享数据,即内存中的全局状态。​如果没有共享数据, 用模型3b就行。虽然我们应该把线程间的共享数据降到最低,但不代表没有
  • 共享的数据是可以修改的,而不是静态的常量表。​如果数据不能修改,那么可以在进程间用shared memory,模式3就能胜任
  • 提供非均质的服务。​即,事件的响应有优先级差异,我们可以用专门的线程来处理优先级高的事件。防止优先级反转
  • latency和throughput同样重要,​不是逻辑简单的IO bound或CPU bound程序。换言之,程序要有相当的计算量
  • 利用异步操作。​比如logging。无论往磁盘写log file,还是往log server发送消息都不应该阻塞critical path
  • 能scale up(按比例增加)。​一个好的多线程程序应该能享受增加CPU数目带来的 好处,目前主流是8核,很快就会用到16核的机器了
  • 具有可预测的性能。​随着负载增加,性能缓慢下降,超过某个临界点之后会急速下降。线程数目一般不随负载变化
  • 多线程能有效地划分责任与功能,让每个线程的逻辑比较简单, 任务单一,便于编码​。而不是把所有逻辑都塞到一个event loop里,不同类别的事件之间相互影响


  • 这些条件比较抽象,下面举两个具体的(虽然是虚构的)例子

例子①

  • 假设要管理一个Linux服务器机群,这个机群里有8个计算节点,1 个控制节点​。机器的配置都是一样的,双路四核CPU,千兆网互联
  • 现在需要​编写一个简单的机群管理软件(参考LLNL的SLURM20),这个软件由3个程序组成:
  • 1.运行在​控制节点上的master,​这个程序​监视并控制整个机群的状态
  • 2.运行在​每个计算节点上的slave,​负责​启动和终止job,并监控本机的资源
  • 3.供最终用户使用的​client命令行工具,用于提交job
  • 根据前面的分析:
  • slave是个“看门狗进程”​,它会启动别的job进程,​因此必须是个单线程程序​。另外它不应该占用太多的CPU资源,这也适合单线程模型
  • master应该是个模式2的多线程程序:
  • 它独占一台8核的机器,如果用模型1,等于浪费了87.5%的CPU资源
  • 整个机群的状态应该能完全放在内存中,这些状态是共享且可变 的。如果用模式3,那么进程之间的状态同步会成大问题。而如果大量 使用共享内存,则等于是掩耳盗铃,是披着多进程外衣的多线程程序。 因为一个进程一旦在临界区内阻塞或crash,其他进程会全部死锁
  • master的主要性能指标不是throughput,而是latency,即尽快地响 应各种事件。它几乎不会出现把IO或CPU跑满的情况
  • master监控的事件有优先级区别,一个程序正常运行结束和异常崩 溃的处理优先级不同,计算节点的磁盘满了和机箱温度过高这两种报警 条件的优先级也不同。如果用单线程,则可能会出现优先级反转
  • 假设master和每个slave之间用一个TCP连接,那么master采用2个或 4个IO线程来处理8个TCP connections能有效地降低延迟
  • master要异步地往本地硬盘写log,这要求logging library有自己的 IO线程
  • master有可能要读写数据库,那么数据库连接这个第三方library可 能有自己的线程,并回调master的代码
  • master要服务于多个clients,用多线程也能降低客户响应时间。也 就是说它可以再用2个IO线程专门处理和clients的通信
  • master还可以提供一个monitor接口,用来广播推送(pushing)机 群的状态,这样用户不用主动轮询(polling)。这个功能如果用单独的 线程来做,会比较容易实现,不会搞乱其他主要功能
  • master一共开了10个线程:
  • ▶4个用于和slaves通信的IO线程
  • ▶1个logging线程
  • ▶1个数据库IO线程
  • ▶2个和clients通信的IO线程
  • ▶1个主线程,用于做些背景工作,比如job调度
  • ▶1个pushing线程,用于主动广播机群的状态
  • 虽然线程数目略多于core数目,但是这些线程很多时候都是空闲 的,可以依赖OS的进程调度来保证可控的延迟
  • 综上所述,​master用多线程方式编写是自然且高效的

例子②

  • 再举一个TCP聊天服务器的例子,​这里的“聊天”不完全指人与人聊 天,也可能是机器与机器“聊天”
  • 这种服务的特点是:并发连接之间有数据交换,从一个连接收到的数据要转发给其他多个连接
  • 因此我们不能按模式3的做法,把多个连接分到多个进程中分别处理(这会带来复杂的进程间通信),而​只能用模式1或者模式2:
  • 如果纯粹只有数据交换, 那么我想模式1也能工作得很好​,因为现在的CPU足够快,单线程应付几百个连接不在话下
  • 如果功能进一步复杂化:​加上关键字过滤、黑名单、防灌水等等功能,甚至要给聊天内容自动加上相关连接,每一项功能都会占用CPU资源
  • 这时就要考虑模式2了,​因为单个CPU的处理能力显得捉襟见肘, 顺序处理导致消息转发的延迟增加。这时我们考虑把空闲的多个CPU利用起来,自然的做法是把连接分散到多个线程上,例如按round-robin的 方式把1000个客户连接分配到4个IO线程上。这样充分利用多核加速。
  • 具体的例子见“muduo库简介之详解muduo多线程模型”中的方案9,以及“muduo编程实例之“串并转换”连接服务器机器自动化测试”文章



多线程中线程的分类

  • 据我的经验,​一个多线程服务程序中的线程大致可分为3类:
  • 1.IO线程,​这类线程的主循环是IO multiplexing,阻塞地等在select/poll/epoll_wait系统调用上。这类线程也处理定时事件。当然它的功能不止IO,有些简单计算也可以放入其中,比如消息的编码或解码
  • 2.计算线程,​这类线程的主循环是blocking queue,阻塞地等在 conditionvariable上。这类线程一般位于thread pool中。这种线程通常不 涉及IO,一般要避免任何阻塞操作
  • 3.第三方库所用的线程,​比如logging,又比如database connection


  • 服务器程序一般不会频繁地启动和终止线程。​甚至,在我写过的程序里,create thread只在程序启动的时候调用,在服务运行期间是不调用的
  • 总结:
  • 在多核时代,要想充分发挥CPU性能,多线程编程是不可避免的,​“鸵鸟算法”不是办法
  • 在学会多线程编程之前,我也一直认为单线程服务程序才是王道。在接触多线程编程之后,经过一段时间的训练和适应,我已能比较自如地编写正确且足够高效的多线程程序
  • 学习多线 程编程还有一个好处,即训练异步思维,提高分析并发事件的能力​。​这对设计分布式系统帮助巨大​,因为运行在多台机器上的服务进程本质上是异步的。熟悉多线程编程的话,很容易就能发现分布式系统在消息和 事件处理方面的race condition

五、附加

  • 本专题未完结,参阅下一篇文章(“多线程服务器的适用场合”的例释与答疑):​​