多线程并发系统设计六问
title: 高性能消息处理系统设计七问 date: 2025-08-19 16:30:31 tags: [编程心得, 后台]
一问:如何设计多线程消息处理系统(结合线程池、任务队列)
后台系统的主要作用就是消费来自网络中的请求,这些请求可以被视为消息,被网络框架解析处理的消息。高性能的消息处理系统,首先要保证网络框架需要保证这几个目标:
- 并发处理消息,避免消息阻塞的问题
- 消息存在阻塞(IO操作),具备睡眠能力
- 避免为每个消息的处理都开启一个线程
- 消息负载均衡策略
想要并发的处理消息,需要启用多个线程处理消息;避免为每个消息的处理开启线程,需要使用线程池,固定开启几个工作线程,避免频繁开启工作线程带来的性能开销;消息需要一个暂时存放的地方供线程池消费,所以需要任务队列。为了某个消息队列中的消息过多,导致某个线程压力大过其他线程,需要制定合理的消息负载均衡策略。
明白这些解决需求策略之后,一个初步的消息处理框架就搭建完成。接下来我们将深入设计各个模块,设计个具备生产能力的消息处理框架。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
+---------------------+
| 生产者线程 |
| (网络I/O线程/主线程) |
+----------+----------+
| submit(task, key)
v
+------------------------------+
| StickyTaskDispatcher | ← 路由分发器
+------------------------------+
↓ 分发
+----------------+------------------+
| | |
v v v
+-----------+ +-----------+ +-----------+
| Worker-0 | | Worker-1 | ... | Worker-N |
| [Thread] | | [Thread] | | [Thread] |
+-----------+ +-----------+ +-----------+
↑ ↑ ↑
| | |
+----+-----+ +-----+-----+ +-----+-----+
| TaskQueue| | TaskQueue | | TaskQueue |
| (MPSC) | | (MPSC) | | (MPSC) |
+----------+ +-----------+ +-----------+ 网络IO作为消息的生产者,向消息路由提交消息,由消息路由进行路由分发到不同的工作线程中的任务队列。
问题:如何确定工作线程 / Worker的数量
worker数量设置非常重要,设置少了无法利用多核资源,设置多了导致worker竞争多核资源,造成不必要的上下文切换。针对任务类型不同,设置的线程数量也不同。cpu任务基本可以划分为两种类型:计算密集型和IO密集型。计算密集型通常设置核心数一致的数量,IO密集型通常设置核心数的两倍的数量。
但是也有任务是混合型的,就是计算和io并存,比如需要查询数据库并使用数据库信息进行业务计算。这类任务就无法简单的通过类型划分来确定线程数量。
二问:如何设计无锁的并发安全的多线程消息处理系统
消息的处理被分发到不同的线程中去,但是消息的处理可能需要访问或读写共享内存,这在有状态的服务端中颇为常见。数据访问的保护也就是必要的,通常采用的手段是锁保护,但是有没有什么方法能够保证数据竞争不会发生呢。
只需要保证访问相同数据的消息被分入到相同的工作线程中即可。消息路由就是我们的下一个目标,实现消息粘性路由。所有访问相同数据(如用户、会话、账户)的消息,必须被分发到同一个工作线程中处理,以保证数据访问的顺序性,避免并发修改导致的数据竞争,减少锁竞争,提升性能。下面提供1个方案:
key 哈希:为消息设计上下文信息,比如会话id等等,使用上下文信息设计key进行哈希取余。
1
thread_index = hash(sticky_key) % thread_count
但是现在存在问题,就是这样的方案只适用于worker固定的情况,worker数量一旦发生改变,就会导致哈希失效,消息会被重新分配,进而导致数据竞争的发生。同时worker数量固定同样存在问题:服务伸缩性不强,阻塞任务会占用工作线程导致性能下降等等。
如何解决这个情况,就提出下面的问题:
三问:如何设计多线程多协程的无锁的并发安全的消息处理系统
这个架构是目前最为先进的服务器框架架构,其设计思想甚至指导某些语言的设计(Go),当然也有框架基于此设计,比如大名鼎鼎的workflow等。我们不再依赖线程作为唯一的并发处理单元,而是引入协程作为最小的执行单元调度到固定数量的线程上去运行。提出如下新的架构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
+------------------+
| 网络线程池 |
| (接收消息 & 解析) |
+--------+---------+
|
| 消息分发
v
+------------------------------------------+
| 协程调度器(Dispatcher) |
| - 根据 stickyKey 映射到固定 "逻辑处理器" |
| - 逻辑处理器 = 协程 + 本地状态 + 队列 |
+------------------------------------------+
|
+------------------+------------------+
| | |
v v v
+-------+--------+ +-------+--------+ +-------+--------+
| Processor-0 | | Processor-1 | | Processor-N |
| (Coroutine) | | (Coroutine) | | (Coroutine) |
| - state | | - state | | - state |
| - task queue | | - task queue | | - task queue |
+-------+--------+ +-------+--------+ +-------+--------+
| | |
| (挂载到) | (挂载到) | (挂载到)
v v v
+-------+--------+ +-------+--------+ +-------+--------+
| Worker-0 | | Worker-1 | | Worker-M |
| (Thread) | | (Thread) | | (Thread) |
| - 运行多个协程 | | - 运行多个协程 | | - 运行多个协程 |
+----------------+ +----------------+ +----------------+
最小的逻辑单元表现为 协程 + 协程所属状态 + 任务队列。
和上面的架构相比,此架构的区别在于,网络IO中解析出来的消息通过协程负责消费处理,协程挂载在固定数量的工作线程中去处理。协程如果处于阻塞状态(IO下睡眠)会被挂起,等待重新进入运行态时再重新添加进任务队列。
难点在于协程调度机制如何实现,这部分非常复杂,这里不做介绍。这里提供几个好用的开源库可以实现这部分需求:workflow。
四问:为什么多线程多协程优于多线程
第一种架构和第二种架构的对比,实际上是对内核重量级并发和用户态轻量级并发进行对比。下面从几个部分进行比较:
- 性能开销:线程启动的开销在于线程栈,每个线程的线程栈大约1-8M,内存开销大,开启一千个线程需要8G的内存;而协程的开销非常低,协程栈的大小通常为4-64kb,所以线程开销是协程的千倍。16G配置下,协程可启用十万数量级,而线程只能启用千级。
- 上下文切换:线程上下文切换需要进入内核态,而协程上下文切换是用户态中完成的,无需调用系统调用。
- IO阻塞处理:这是重点区别,也是性能差异的来源。在纯多线程模式中,阻塞式的IO操作会直接被系统挂起,整个线程被挂起处于阻塞态,无法处理其他的消息。如果所有线程都有阻塞IO操作,甚至会出现所有线程都被挂起,cpu处于空转的状态;而在多线程多协程模式中,I/O 阻塞时,协程挂起,但线程继续运行其他协程。1 个线程可服务成千上万协程。CPU 利用率接近 100%。这才是多线程多协程在现代框架中被经常使用的根本原因。
五问:有状态和无状态的环境对消息处理系统的设计有什么影响
从消息路由策略的角度来看,有状态系统与无状态系统存在根本性差异。在无状态系统中,每个处理节点不保存任何与消息相关的上下文信息,所有状态都存储在外部共享存储(如数据库、缓存)中。因此,任何消息可以被任意分发到任一工作节点进行处理,系统可以采用轮询、随机或基于负载的调度策略,实现简单且天然支持负载均衡。而在有状态系统中,处理节点本地维护着与特定消息键(如用户ID、会话ID)相关的状态,这就要求系统必须实现“粘性路由”(Sticky Routing),即确保相同键的消息始终被路由到同一个处理节点。否则,状态将不一致,导致数据错误。这种需求迫使系统引入一致性哈希、分区(Partitioning)或亲和性调度等复杂机制,增加了架构的复杂性。
其次,在可伸缩性(Scalability) 方面,无状态系统具有天然优势。由于没有本地状态的束缚,系统可以随时增加或减少工作节点,新节点加入后立即可以参与负载,无需迁移状态,扩展过程平滑且高效。这种特性使得无状态系统非常适合需要快速弹性伸缩的云原生环境。相比之下,有状态系统的扩展则复杂得多。增加节点时,通常需要重新分配数据分区(re-sharding),将部分状态从现有节点迁移到新节点。这个过程不仅消耗大量网络和计算资源,还可能导致短暂的性能下降或数据竞争。虽然一致性哈希等技术可以将重分配的影响控制在合理范围内(仅影响约1/N的键,N为节点数),但其复杂性和潜在风险仍远高于无状态系统。
在容错性与高可用方面,两者的权衡同样显著。无状态系统的容错机制相对简单:当某个节点发生故障时,负载均衡器可以立即将流量重定向到其他健康节点,由于状态不依赖于本地内存,故障节点上的“工作”可以被无缝接管,系统几乎不受影响。而有状态系统在节点故障时面临状态丢失的风险,必须依赖额外的机制来保证数据不丢失和系统可恢复。常见的方案包括主从复制(Replication)、检查点(Checkpointing)和状态持久化(如Flink的Savepoint、Kafka Streams的Changelog Topic)。这些机制虽然能提高系统的可靠性,但也带来了额外的延迟、存储开销和实现复杂度。例如,Flink通过定期将状态快照写入分布式存储来实现故障恢复,这虽然保证了“至少一次”或“精确一次”的语义,但快照过程本身会对性能产生影响。
从性能与延迟的角度分析,有状态系统通常具有压倒性优势。由于状态存储在本地内存中,访问速度极快(纳秒级),避免了访问外部存储(如Redis、数据库)所需的网络I/O(微秒到毫秒级)。对于高频更新或低延迟要求的场景(如实时游戏中的玩家状态更新、金融交易中的账户余额变更),有状态系统能够提供更优的响应性能。而无状态系统虽然架构简单,但每次操作都需要与外部存储交互,网络延迟和外部系统的负载可能成为性能瓶颈。尽管可以通过本地缓存来缓解,但这又引入了缓存一致性问题,使得系统复杂度上升。
在数据一致性与并发控制方面,有状态系统也展现出独特优势。由于粘性路由确保了同一键的消息由单一节点处理,系统天然避免了多节点并发修改同一数据的问题,无需引入分布式锁、乐观锁或复杂的事务协调机制。消息的处理顺序也更容易保证,适合实现精确的事件序列处理。而在无状态系统中,多个节点可能同时尝试修改同一份外部状态,必须依赖外部一致性机制(如Redis的分布式锁、数据库的行锁或CAS操作)来避免冲突,这不仅增加了延迟,也提高了系统出错的概率。
最后,从系统复杂度与运维成本来看,无状态系统显然更胜一筹。其部署、升级、监控都相对简单,支持滚动更新和蓝绿部署等现代运维实践。而有状态系统由于涉及状态管理、故障恢复、数据迁移等复杂逻辑,其实现和运维难度显著增加。开发人员需要深入理解状态生命周期、故障转移协议和一致性模型,运维团队也需要更精细的监控手段来跟踪状态分布和迁移进度。
综上所述,有状态与无状态的选择并非简单的技术偏好,而是一系列权衡的结果。无状态系统以其高可扩展性、高可用性和低运维复杂度,成为大多数Web服务和微服务架构的首选,尤其适合处理独立、无上下文依赖的请求。而有状态系统则凭借其卓越的性能和天然的强一致性,在实时流处理、在线游戏、即时通讯等需要维护复杂会话状态的场景中占据主导地位。在实践中,越来越多的系统采用混合架构,即在无状态的框架下,通过本地缓存或轻量级状态管理来吸收高频访问的“热数据”,实现性能与可维护性的最佳平衡。例如,Flink的RocksDB State Backend、Kafka Streams的本地状态存储,都是这种思想的体现。最终的选择应基于具体的业务需求、性能目标和团队技术能力进行综合评估。
六问:网络消息的处理可以完全在一个线程中解决吗
网络消息的处理完全可以在一个线程中高效完成,这并非理论上的设想,而是已被众多高性能系统广泛验证的工程实践。其核心在于摒弃传统的“每连接一线程”阻塞模型,转而采用“单线程事件循环 + 非阻塞 I/O + I/O 多路复用”的现代架构。在这种模式下,一个线程通过 epoll(Linux)、kqueue(BSD)或 IOCP(Windows)等机制,能够同时监控成千上万个网络连接的读写事件,仅在数据就绪时才进行处理,避免了线程因等待 I/O 而陷入阻塞。这种事件驱动的范式使得单个线程可以像高效的调度员一样,快速响应大量并发连接的请求,从而实现极高的吞吐量和极低的延迟。Redis、Node.js 和 Nginx 等知名系统正是这一模型的杰出代表:Redis 在单线程模式下即可轻松达到十万级别的 QPS,证明了其在 I/O 密集型任务上的卓越性能;Nginx 则采用多进程架构,每个工作进程内部运行一个独立的单线程事件循环,既充分利用了多核能力,又保留了单线程的高效与简洁。
为了在单线程中处理复杂的业务逻辑(如数据库查询、远程调用),系统通常会引入协程(Coroutine)或状态机(State Machine)机制。协程允许开发者以同步的代码风格编写异步逻辑,当遇到 I/O 操作时,协程会主动挂起并让出控制权,事件循环则继续处理其他就绪的连接,待 I/O 完成后再恢复该协程的执行。这种方式在保持代码可读性的同时,实现了极高的并发效率。状态机则将一个请求的生命周期拆解为多个离散的状态(如接收头部、接收正文、处理业务、发送响应),每次 I/O 事件触发时推进状态,避免了长时间阻塞。这两种技术都确保了事件循环的持续高效运转,使单线程能够承载海量的并发会话。
然而,单线程模型也存在明显的局限性,主要体现在对 CPU 密集型任务的处理上。任何耗时的计算(如复杂算法、大数据解析)都会阻塞事件循环,导致其他所有连接的请求被延迟,形成“卡顿”。此外,单线程只能利用一个 CPU 核心,无法充分发挥现代多核处理器的性能,并且存在单点故障的风险。为了解决这些问题,现代高性能系统普遍采用“单线程 per core”的混合架构。这种模式启动多个工作线程(或进程),每个线程绑定到一个独立的 CPU 核心,并运行自己的事件循环和 I/O 多路复用器。连接通过负载均衡策略(如 SO_REUSEPORT 或外部代理)被分发到不同的线程上,从而实现多核并行处理。这种设计既继承了单线程模型无锁、低开销、高效率的优点,又克服了其扩展性和容错性的短板,成为构建高并发网络服务的黄金标准。因此,答案是明确的:网络消息处理不仅可以在一个线程中解决,而且以“每个核心一个事件循环”为代表的单线程衍生架构,正是当前高性能网络编程的最优实践路径。
七问:网络消息的处理可以在多个线程中解决吗?又如何在多个线程中解决呢?
当然可以,多线程模型是为了解决单线程模型中出现复杂业务逻辑导致阻塞的问题(可以用非阻塞的协程或状态机解决)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
+------------------+
| Load Balancer |
+--------+---------+
|
+--------------------+--------------------+
| | |
v v v
+-------+--------+ +-------+--------+ +-------+--------+
| I/O Thread-0 | | I/O Thread-1 | | I/O Thread-N |
| (Reactor) | | (Reactor) | | (Reactor) |
| - epoll/kqueue | | - epoll/kqueue | | - epoll/kqueue |
| - 非阻塞读写 | | - 非阻塞读写 | | - 非阻塞读写 |
+-------+--------+ +-------+--------+ +-------+--------+
| | |
| (消息入队) | (消息入队) | (消息入队)
v v v
+----------------------------------------------------------+
| Shared Task Queue |
+----------------------------------------------------------+
|
v (任务出队)
+--------------------+--------------------+
| | |
v v v
+-------+--------+ +-------+--------+ +-------+--------+
| Worker-0 | | Worker-1 | | Worker-N |
| (Logic Thread) | | (Logic Thread) | | (Logic Thread) |
| - 业务逻辑 | | - 业务逻辑 | | - 业务逻辑 |
| - DB/Cache | | - DB/Cache | | - DB/Cache |
+----------------+ +----------------+ +----------------+
网络消息的处理不仅可以在多个线程中解决,而且在追求高吞吐、低延迟和多核利用率的现代系统中,多线程架构已成为主流选择。单线程事件循环虽然在 I/O 密集型场景下表现出色,但其局限性也十分明显:它只能利用一个 CPU 核心,且任何耗时的业务逻辑都会阻塞整个事件循环,导致所有连接的响应延迟。为了解决这一瓶颈,系统必须采用多线程模型,而其中最成熟、最高效的架构便是将 I/O 线程与业务逻辑线程分离的设计模式。
在这种架构中,系统明确划分职责:一组专门的 I/O 线程(也称为 Reactor 线程)负责监听和处理网络事件。每个 I/O 线程运行一个独立的事件循环,使用 epoll、kqueue 等 I/O 多路复用技术,高效地管理成千上万个连接的读写操作。当某个连接有数据到达时,I/O 线程负责将其完整读取并解析为一条消息,然后将该消息封装成一个任务,提交到一个共享的 任务队列 中。关键在于,I/O 线程在此过程中绝不执行任何耗时的业务逻辑,确保其始终轻量、快速,能够及时响应新的 I/O 事件,避免因阻塞而导致整个系统“卡顿”。
与此同时,一个独立的 业务线程池(Worker Thread Pool)作为任务消费者,持续从共享队列中取出消息并执行复杂的业务处理。这些操作可能包括数据库查询、远程服务调用、数据计算或规则引擎执行等,往往涉及同步阻塞调用或高 CPU 消耗。由于这些操作在独立的线程中进行,它们的耗时不会影响 I/O 线程的正常运转。当业务处理完成后,结果(如响应数据)需要交还给最初处理该连接的 I/O 线程,以便将数据写回客户端。这通常通过回调机制或响应队列实现:业务线程触发一个回调函数,该回调被提交到原始 I/O 线程的事件循环中,由其在下一个事件周期安全地执行发送操作。
这种架构的优势极为显著。首先,它实现了 关注点分离,I/O 和计算两大瓶颈被分配到不同的线程组,各司其职,互不干扰。其次,它充分利用了多核 CPU 的并行能力,I/O 线程和业务线程可以同时在不同核心上运行,显著提升系统整体吞吐量。再者,系统的 可扩展性 得到增强,可以根据负载独立调整 I/O 线程数和业务线程池大小。此外,由于 I/O 线程永不阻塞,系统的 响应性和稳定性 也更有保障。这一模式已被 Nginx、Netty、Kafka 等众多高性能系统广泛采用,成为构建大规模网络服务的事实标准。通过将 I/O 与逻辑解耦,多线程架构不仅克服了单线程的性能天花板,还为构建复杂、健壮、可伸缩的分布式系统提供了坚实的基础。