第3章 多线程服务器的适用场合与常用编程模 型

3.1 进程与线程

每个进程有自己独立的地址空间(address space)

线程的特点是共享地址空间,从而可以高效地共享数据

3.2 单线程服务器的常用编程模型

两种高效的事件处理模式:Reactor和Proactor。同步I/O模型通常用于实现Reactor模式,异步I/O模型则用于实现Proactor模式。不过,也可以使用同步I/O方式模拟出Proactor模式。

  • Reactor模式:要求主线程(I/O处理单元)只负责监听文件描述上是否有事件发生,主线程不负责对文件描述符上数据的读写,有的话就立即将该事件通知工作线程(逻辑单元)。如epoll_wait——注册事件,事件发生后通知主线程,主线程将已经发生的事件放入请求队列,并唤醒请求队列上的某个线程去处理这个事件。对于IO密集的应用是个不错的选择。
  • Proactor模式:与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。以aio_read和aio_write为例——注册事件并告诉内核读(写)缓冲区的位置,当socket上的数据被读入用户缓冲区后 或 当用户缓冲区的数据被写入socket之后,向应用程序发送一个信号,以通知应用程序数据处理。Proactor模式就是使用异步IO,将数据的读/写操作都放在内核中运行,当读/写完毕以后,通知工作线程走后续处理(工作线程不做读/写操作)。
  • 同步I/O方式模拟出Proactor模式:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。

muduo库使用的是Reactor模式。在Reactor模式中,程序的基本结构是一个事件循环(event loop),以事件驱动(event-driven)和事件回调的方式实现业务逻辑。
缺点:基于事件驱动的编程模型也有其本质的缺点,它要求事件回调函数必须是非阻塞的。对于涉及网络IO的请求响应式协议,它容易割裂业务逻辑,使其散布于多个回调函数之中,相对不容易理解和维护。现代的语言有一些应对方法(例如coroutine)。

回调函数含义:回调函数 C++

3.3 多线程服务器的常用编程模型

多线程服务器的常用编程模型,大概有这么几种

  • 1.每个请求创建一个线程,使用阻塞式IO操作。
  • 2.使用线程池,同样使用阻塞式IO操作。与第1种相比,这是提高性能的措施。
  • 3.使用non-blocking IO+IO multiplexing。
  • 4.Leader/Follower等高级模式。

3.3.1 one loop per thread

此种模型下,程序里的每个IO线程有一个event loop(或者叫 Reactor),用于处理读写和定时事件(无论周期性的还是单次的)。也就是说一个线程可以将定时任务或者tcp请求注册到其他线程中,并且每个线程可能用于处理多个定时任务或者tcp请求,线程中监听定时任务或者tcp请求(可能多个定时任务和tcp请求)并进行相应的处理。也就是说线程不止可用于处理一个任务,而且可以处理多个任务。这种方式的好处是:

  • 线程数目基本固定,可以在程序启动的时候设置,不会频繁创建与销毁。
  • 可以很方便地在线程间调配负载。
  • IO事件发生的线程是固定的,同一个TCP连接不必考虑事件并发

要允许一个线程往别的线程的loop里塞东西,这个loop必须得是线程安全的。

3.3.2 线程池

使用BlockingQueue作为线程的任务队列,如BlockingQueue<T>

3.3.3 推荐模式

总结起来,我推荐的C++多线程服务端编程模式为:one (event) loop per thread+ thread pool。

  • event loop(也叫IO loop)用作IO multiplexing,配合non-blocking IO和定时器。
  • thread pool用来做计算,具体可以是任务队列或生产者消费者队列。

3.4 进程间通信只用TCP

Linux下进程间通信(IPC)的方式数不胜数,光[UNPv2]列出的就 有:匿名管道(pipe)、具名管道(FIFO)、POSIX消息队列、共享内 存、信号(signals)等等,更不必说Sockets了。同步原语 (synchronization primitives)也很多,如互斥器(mutex)、条件变量 (condition variable)、读写锁(reader-writer lock)、文件锁(record locking)、信号量(semaphore)等等。

进程间通信我首选Sockets(主要指TCP,我没有用过UDP,也不考 虑Unix domain协议),其最大的好处为:

  • 可以跨主机,具有伸缩性:把进程分散到同一局域网的多台机器上,程序改改host:port配置就能继续用。

  • 端口和文件描述符资源由系统自动回收:TCP port由一个进程独占,且操作系统会自动回收(listening port和 已建立连接的TCP socket都是文件描述符,在进程结束时操作系统会关 闭所有文件描述符)。这说明,即使程序意外退出,也不会给系统留 下垃圾,程序重启之后能比较容易地恢复,而不需要重启操作系统 (用跨进程的mutex就有这个风险)。
    防止进程重复启动:还有一个好处,port是由各个进程独占 的,那么可以防止程序重复启动,重复启动的进程抢不到port,自然就没 法初始化了,避免造成意料之外的结果。

  • 两个进程通过TCP通信,如果一个崩溃了,操作系统会关闭连接,另一个进程几乎立刻就能感知,可以快速进行错误处理。

  • 可使用tcpdump和Wireshark分析bug和性能,也可以用tcpcopy14之类的工具进行压力测试。

  • TCP还能跨语言,服务端和客户端不必使用同一种语言。

  • 进程可单独重启:如果网络库带“连接重试”功能的话,我们可以不要求系统里的进程以特定的顺序启动,任何一个进程都能单独重启。换句话说,TCP连接是可再生的,连接的任何一方都可以退出再启动,重建连接之后就能继续工作,这对开发牢靠的分布式系统意义重大。

进程间使用TCP进行通信,推荐使用Google Protocol Buffers作为消息格式。

TCP sockets和pipe:在编程上,TCP sockets和pipe都是操作文件描述符,用来收发字节 流,都可以read/write/fcntl/select/poll等。不同的是:

  • TCP是双向的, Linux的pipe是单向的,进程间双向通信还得开两个文件描述符,不方 便11;
  • 而且进程要有父子关系才能用pipe,这些都限制了pipe的使用。

在收发字节流这一通信模型下,没有比Sockets/TCP更自然的IPC了。当 然,pipe也有一个经典应用场景,那就是写Reactor/event loop时用来异 步唤醒select(或等价的poll/epoll_wait)调用。

TCP与共享内存:
作者说本地机器上的进程之间的TCP通信的吞吐量并不低(见§6.5.1的测试结果),所以在本地机器上也没有必要使用共享内存。

分布式系统中使用TCP长连接通信

使用TCP长连接的好处有两点:

  • 一是容易定位分布式系统中的服务 之间的依赖关系。只要在服务器运行netstat -tpna | grep :port就能立刻列 出连接到到port端口的客户端地址(Foreign列),然后在客户端的机器上用 netstat或lsof命令找出是哪个进程发起的连接。这样在迁移服务的时候 能有效地防止出现outage。
  • 二是通过接收和发送队列的长度也较容易定位网络或程序故障。在正常运行的时候,netstat打印的Recv-Q和Send-Q都应该接近0,或者在0附近摆动。
    如果Recv-Q保持不变或持续增加,则通常意味着服务进程的处理速度变慢,可能发生了死锁或阻塞。
    如果Send-Q保持不变或持续增加,有可能是对方服务器太忙、来不及处理,也有可能是网络中间某个路由器或交换机故障造成丢包,甚至对方服务器掉线,这些因素都可能表现为数据发送不出去。通过持续监控Recv-Q和Send-Q就能及早预警性能或可用性故障。

netstat -tpna | grep :port:这个命令可以用于查找在指定端口上正在运行的网络连接。

  • netstat 是一个网络工具命令,可以用于检查当前系统的网络连接状态。
  • tpna 是命令的参数选项。其中,-t 表示显示 TCP 协议的连接状态,-p 表示显示哪个进程占用了该连接,-n 表示以数字形式显示网络地址和端口号,而不是使用主机名和服务名称。最后的 a 表示显示所有连接,包括监听状态和未建立的连接。

3.5 多线程服务器的适用场合

开启线程还是进程,这里有一个例子:使用速率为50MB/s的数据压缩库、在进程创建销毁的开销是800μs、线程创建销毁的开销是50μs的前提下,考虑如何执行压缩任务:

  • 如果要偶尔压缩1GB的文本文件,预计运行时间是20s,那么起一个进程去做是合理的,因为进程启动和销毁的开销远远小于实际任务
    的耗时。

  • 如果要经常压缩500kB的文本数据,预计运行时间是10ms,那么每次都起进程似乎有点浪费了,可以每次单独起一个线程去做。

  • 如果要频繁压缩10kB的文本数据,预计运行时间是200μs,那么每次起线程似乎也很浪费,不如直接在当前线程搞定。也可以用一个
    线程池,每次把压缩任务交给线程池,避免阻塞当前线程(特别要避免阻塞IO线程)。

由此可见,多线程并不是万灵丹(silver bullet),它有适用的场合。那么究竟什么时候该用多线程?下面会逐步介绍。

3.5.1 必须用单线程的场合

据我所知,有两种场合必须使用单线程:

  • 1.程序可能会fork();
  • 2.限制程序的CPU占用率。

多线程程序不是不能调用fork(),而是这么做会遇到很多麻烦,我想不出做的理由。一个程序fork()之后一般有两种行为:

  • 1.立刻执行exec(),变身为另一个程序。例如shell和inetd;又比如lighttpd fork()出子进程,然后运行fastcgi程序。或者集群中运行在计算节点上的负责启动job的守护进程(即我所谓的“看门狗进程”)。

  • 2.不调用exec(),继续运行当前程序。要么通过共享的文件描述符与父进程通信,协同完成任务;要么接过父进程传来的文件描述符,独立完成工作,例如20世纪80年代的Web服务器NCSA httpd。

这些行为中,我认为只有“看门狗进程”必须坚持单线程,其他的均可替换为多线程程序(从功能上讲)。

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

3.5.2 单线程程序的优缺点

缺点:非抢占式,假设事件a的优先级高于事件b,处理事件a需要1ms,处理事件b需要10ms。如果事件b稍早于a发生,那么当事件a到来时,程序已经离开了poll(2)调用,并开始处理事件b。事件a要等上10ms才有机会被处理,总的响应时间为11ms。这等于发生了优先级反转。这个缺点可以用多线程来克服,这也是多线程的主要优势。

如果用很少的CPU负载就能让IO跑满,或者用很少的IO流量就能让CPU跑满,那么多线程没啥用处。举例来说:

  • 很少的CPU负载就能让IO跑满:对于静态Web服务器,或者FTP服务器,CPU的负载较轻,主要瓶颈在磁盘IO和网络IO方面。这时候往往一个单线程的程序(模式1)就能撑满IO。用多线程并不能提高吞吐量,因为IO硬件容量已经饱和了。同理,这时增加CPU数目也不能提高吞吐量。
  • 很少的IO流量就能让CPU跑满:CPU跑满的情况比较少见,这里我只好虚构一个例子。假设有一 个服务,它的输入是n个整数,问能否从中选出m个整数,使其和为 0(这里n<100, m>0)。这是著名的subset sum问题,是NP-Complete 的。对于这样一个“服务”,哪怕很小的n值也会让CPU算死。比如n= 30,一次的输入不过200字节(32-bit整数),CPU的运算时间却能长达 几分钟。对于这种应用,模式3a是最适合的,能发挥多核的优势,程 序也简单。
    CPU密集型任务:如果任务需要大量的CPU计算时间而不涉及I/O操作,那么多进程可能比多线程更有效,因为多个进程可以同时使用多个CPU核心。在这种情况下,使用多线程可能会导致线程间竞争和资源争用,从而影响性能。

3.5.3 适用多线程程序的场景

我认为多线程的适用场景是:提高响应速度,让IO和“计算”相互重叠,降低latency(等待时间)。虽然多线程不能提高绝对性能,但能提高平均响应性能。一个程序要做成多线程的,大致要满足:有多个CPU可用、线程间有共享数据、事件的响应有优先级差异、用户请求多等

一个多线程服务程序中的线程大致可分为3类:

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

3.6 “多线程服务器的适用场合”例释与答疑

1.Linux能同时启动多少个线程?
对于32-bit Linux,一个进程的地址空间是4GiB,其中用户态能访 问3GiB左右,而一个线程的默认栈(stack)大小是10MB,心算可知, 一个进程大约最多能同时启动300个线程。如果不改线程的调用栈大小 的话,300左右是上限,因为程序的其他部分(数据段、代码段、堆、 动态库等等)同样要占用内存(地址空间)。
对于64-bit系统,一个进程的地址空间是\(4*2^{32}\)GiB,线程数目可大大增加,具体数字我没有测试过, 因为我在实际项目中一台机器上最多只用到过几十个用户线程,其中 大部分还是空闲的。

3.多线程能提高吞吐量吗?

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

  • 8个线程,每个请求都使用一个线程来处理。
  • 8个线程,每个请求都同时使用8个线程进行处理。吞吐量不变,但是首次响应的延时更小。

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

5.执行计算和io同时执行

日志(logging):将日志放入BlockingQueue,然后使用一个线程将BlockingQueue中的日志写入到磁盘中。将日志写入到磁盘的过程放到单独的线程中。
其实所有的网络写操作都可以这么异步地做,不过这也有一个缺点,那就是每次asyncWrite()都要在线程间传递数据。其实如果TCP缓冲区是空的,我们就可以在本线程写完,不用劳烦专门的IO线程。

7.什么是线程池大小的阻抗匹配原则?

如果池中线程在执行任务时,密集计算所占的时间比重为P(0< P≤1),而系统一共有C个CPU,为了让这C个CPU跑满而又不过载,线 程池大小的经验公式T=C/P。T是个hint,考虑到P值的估计不是很准 确,T的最佳值可以上下浮动50%.这个经验公式的原理很简单,T个线 程,每个线程占用P的CPU时间,如果刚好占满C个CPU,那么必有T×P =C。
如果P<0.2,这个公式就不适用了,T可以取一个固定值,比如5×C。另外,公式里的C不一定是CPU总数,可以是“分配给这项任务的CPU数目”,比如在8核机器上分出4个核来做一项任务,那么C=4。

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

没看懂

9.使用多线程还是使用多进程:如果需要访问的内存比较大,那么就使用多线程共享内存,不然使用多进程还要每个都拷贝一份。

posted @ 2023-04-07 22:39  好人~  阅读(80)  评论(0编辑  收藏  举报