多线程服务器的适用场合与常用编程模型 —— Muduo 网络库笔记(3)
2025-03-26

多线程服务器的适用场合与常用编程模型 —— Muduo 网络库笔记(3)

声明:本文为个人阅读《Linux 多线程服务端编程:使用 muduo C++ 网络库》的读书笔记,文中部分标题可能有些更改,但主体内容和书中一致。如有侵权,告知即删。

3.1 进程与线程

每个进程都有自己独立的地址空间,Erlang 程序设计中把“进程”比喻为人。每个人都有自己的记忆(memory),人与人通过谈话来交流(message passing)。

线程的特点是共享进程的地址空间,从而高效地共享数据,可以更好地发挥多核处理器的实力。

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

如果只有一块 CPU、一个执行单元,写程序最高效的方式是按照状态机的思路去写。

最经典属于 Reactor 模式和 Proactor 模式。

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

作者推荐的是 one (event) loop per thread + thread pool,我读下来感觉和多 Reactor 模式挺像的。

3.4 进程间通信只用 TCP

Linux 进程间通信方式数不胜数:匿名管道、具名管道、POSIX 消息队列、共享内存、信号,Sockets 等。书中作者只推荐 TCP,他认为有以下几个好处:

  1. 可以跨主机、具有伸缩性,程序改改 host:port 配置就能继续用。
  2. TCP 双向,如果用管道还是单向的,进程间双向通信还得开两个文件描述符,不方便。
  3. TCP 的 port 由一个进程独占,且操作系统会自动回收。即使程序意外退出,也不会给系统留下垃圾。
  4. TCP 的 port 独占了还可以防止重复启动相同进程。
  5. 两个进程 TCP 通信,如果一个崩溃了,另一个进程立刻就能感知到。
  6. “可记录、可重现”,tcpdump 和 Wireshark 分析 TCP 都非常好用。
  7. TCP 还能跨语言,服务端和客户端不必使用同一种语言。

使用 TCP 这种字节流方式通信,会有 marshal/unmarshal 的开销,这要求我们选用合适的消息格式。作者推荐使用 Google Protocol Buffers。

有的人认为,如果两个进程在同一台机器,就用共享内存,否则就用 TCP。作者觉得不应该为了那么一点点性能提升让整个代码的复杂程度提升那么多。

我个人认为还是得取舍,如果某些服务 TCP 成为了瓶颈,就需要另找出路。

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

作者指出,分布式系统中使用 TCP 长连接好处有两点:

  1. 通过 netstat 和 losf 命令,很容易定位分布式系统中服务之间的依赖关系。
  2. 通过 netstat 查看接受队列(Recv-Q)和发送队列(Send-Q)的长度,也比较容易定位网络或程序故障。

下面是服务端线程阻塞造成 Recv-Q 和客户端 Send-Q 激增的例子:

1
2
3
4
5
$ netstat -tn
Proto Recv-Q Send-Q Local Address Foreign Address
tcp 78393 0 10.0.0.10:2000 10.0.0.10:39748 # 服务端连接
tcp 0 132608 10.0.0.10:39748 10.0.0.10:2000 # 客户端连接
tcp 0 52 10.0.0.10:22 10.0.0.4:55572

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

现在服务端网络编程处理并发连接主要有两种方式:

  1. 当线程很廉价时,一台机器上可以创建远高于 CPU 数目的“线程”。这是一个线程只处理一个 TCP 连接,通常使用阻塞 IO。Go 中的协程时一个“廉价”的例子。(这里的“线程”由语言的 runtime 自行调度,与操作系统的线程不同)
  2. 当线程很宝贵时,一台机器上只能创建于 CPU 数目相当的线程。这时一个线程要处理多个 TCP 连接上的 IO。通常使用非阻塞 IO 与 IO 多路复用。(这里的线程只原生线程,能被操作系统的任务调度器看到)

如果要在一台多核机器户提供一种服务或执行一个任务,可用的模式有:

  1. 运行一个单线程的进程:不可伸缩,不能发挥多核机器的计算能力。
  2. 运行一个多线程的进程:被很多人鄙视,被认为多线程程序难写原因之一。
  3. 运行多个单线程的进程:
    1. 简单把模式 1 中的进程运行多份。
    2. 主进程 + Worker 进程。
  4. 运行多个多线程的进程:没有优点,汇聚了 2 和 3 的缺点。

本节讨论的是模式 2 和模式 3b 的优劣,即:什么时候一个服务器程序应该是多线程的?

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 线程)。

3.5.1 必须用单线程的场合

程序可能会调用 fork()

书中指出,只有单线程程序能 fork()。一个程序 fork() 之后一般有两种行为:

  1. 立刻执行 exec() 变身为另一个程序,例如 shell,或者集群中运行在计算节点上负责启动 job 的守护进程。(看门狗进程)
  2. 不调用 exec(),要么通过共享文件操作符与父进程通信,协调完成任务,要么接过父进程传过来的文件操作符,独立完成工作。

作者认为只有“看门狗进程”必须坚持单线程,其他的均可替换为多线程。

限制程序的 CPU 占用率

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

因此对于一些辅助性的程序,如果它必须和主要服务进程运行在同一台机器的话,那么做成单线程能避免过分的抢夺系统的计算资源。

3.5.2 单线程程序的优缺点

优点:简单。

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

3.5.3 适用多线程程序的场景

多线程的适用场景是,提高响应速度,让 IO 和“计算”相互重叠,降低 latency。一个程序要做成多线程,需要满足:

  1. 有多个 CPU 可用。
  2. 线程间有共享数据,有且应该把共享数据降至最低。
  3. 共享数据可修改。
  4. 提供服务优先级有差异。可以用专门的线程处理优先级高的事件,防止优先级反转。
  5. latency 和 throughput 都重要。
  6. 多用异步操作,无论写磁盘或是网络 IO,都不应该阻塞关键线程。
  7. 应该能随着 CPU 数目增加二扩展性能。
  8. 能有效的划分责任与功能。

线程的分类可分为 3 类:

  1. IO 线程,主循环是 IO 复用,阻塞在 epoll_wait 系统调用上。
  2. 计算线程:这类线程的主循环是 blocking queue,阻塞地等在 condition variable 上,一般位于 thread pool 中。它一般不涉及 IO,要尽量避免任何阻塞操作。
  3. 第三方库用的线程:比如 logging,又比如 database connection。

服务器程序一般不会频繁地启动或终止线程,线程应该只在程序启动的时候调用,在服务运行期间都不应该创建额外线程。

3.6 难疑解答

3.6.1 Linux 能同时启动多少个线程?

进程的地址空间大小等于内存的空间大小,用户态能访问的空闲也会小一些。一个线程的默认栈大小是 10MB,做一个除法即可知道理论上限。

3.6.2 多线程能提高并发度吗?

如果采用 thread per connection 模型,并发连接数受限于内存可容纳的线程数量。

若是采用 one loop per thread,单个 event loop 能处理 1 万个并发长连接,一个 multi-loop 的多线程程序应该能轻松支持 5 万并发连接。此时并发度与 CPU 数目成正比。

3.6.3 多线程能提高吞吐量吗?

对于计算密集型服务,不能。

假设有一个计算任务,单线程需要 0.8s,在一台 8 核的机器上,我们可以启动 8 个线程一起对外服务。此时完成单个计算仍要 0.8s,但是系统整体吞吐量可以从单线程的 1.25 qps 上升到 10 qps。

如果改用并行算法,8 个核心并行计算,理论上若完全并行,那么完成单个计算耗时 0.1s。虽然单个计算耗时下降了,但整体吞吐依然是 10 qps。不过首个任务延时从 0.8s 变成了 0.1s

3.6.4 多线程能降低响应时间吗?

完全可以,参考前一行,“首个任务延时从 0.8s 变成了 0.1s”。

3.6.5 多线程程序如何让 IO 和“计算”相互重叠,降低 latency?

基本思路:把 IO 操作通过 BlockingQueue 交给别的线程去做,解放关键线程。

3.6.6 为什么第三方库往往要用自己的线程?

例 1:libmemcached 只支持同步操作,需要自行额外适配进自己的 event loop 中。

例 2:MySQL 的官方 C API 不支持异步操作,只能单独用一个线程去做。

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

假设密集计算所占的事件比重为 \(P\),系统一共有 \(C\) 个 CPU,线程池大小为 \(T\)

  • \(C = 8\)\(P = 1.0\),线程池任务完全是密集计算,那么 \(T = 8\)。线程池只需要 \(8\) 个活动线程就能让 \(8\) 个 CPU 跑满,再多也没用。
  • \(C = 8\)\(P = 0.5\),线程池任务有一半是计算,有一半等在 IO 上,那么 \(T = 16\)。大概 \(16\)\(50%\) 繁忙的线程就能让 \(8\) 个 CPU 跑满。启动更多的线程并不能提高吞吐量,反而因为增加上下文开销而降低性能。

3.6.8 除了推荐的 React + thread poll,还有别的多线程编程模型吗?

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

Proactor 模式每个字任务都不会阻塞,因此能用比较少的线程达到很高的 IO 并发度。它能提高吞吐,但不能降低延时。书中推荐 Proactor 最好在线程廉价的语言中使用。

3.7 总结

多线程不能减少工作量,即不能减少 CPU 时间。如果解决一个问题需要执行一亿条指令,那么用多线程只会让这个数字增加。但是通过合理调配这一亿条指令在多个核心上的执行情况,能让该问题提早解决。

Prev
2025-03-26