一、服务器开发概述

“服务器开发”包罗万象,用一句话形容是:跑在多核机器上的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
五、附加
  • 本专题未完结,参阅下一篇文章(“多线程服务器的适用场合”的例释与答疑):javascript:void(0)