Boost.Asio 线程模型对比:单 io_context + strand vs 多 io_context + 单线程

strand 不是线程池,它是 io_context 上的"逻辑串行视图" —— 同一 strand 内的 handler 串行,不同 strand 之间(以及 strand 与 raw post 之间)仍然并行,共享同一组 worker 线程。
与之相对的另一种模式是 “多个独立 io_context,每个池子只跑一根线程” —— 物理上就把串行单位与线程绑死,牺牲负载均衡换取强隔离、可观测性和心智简单。
本文把两种线程组织方式对照清楚:它们各自擅长什么、什么时候是坑、以及实际项目里怎么混合使用。


1. 先给一个共同的问题:把串行和并行同时表达出来

在使用 boost::asio 写并发代码时,经常会遇到这样的需求:

  • 某一类工作必须串行:写同一份共享状态(例如同一个分片的内存仓库)、按顺序处理同一连接上的消息、对同一资源的访问要互斥。
  • 不同类工作之间要并发:不同分片之间的写入互不相干、不同连接的处理是独立的、收尾任务与主路径不该互相阻塞。

boost::asio 提供两条主流的实现路径:

  • A. 单 io_context + 多线程 + strand:用 strand 给"逻辑串行单位"分组。
  • B. 多 io_context + 每池 1 个线程:每个串行单位有一个独立 io_context,该 io_context 上只跑一根线程。

两者结果语义都能做到"同 key 串行 + 不同 key 并行",但工程属性差别很大。下面一项项对比。


2. 两种模型的结构图

2.1 模式 A:单 io_context + strand

                ┌────────────────────────────┐
                │   boost::asio::io_context  │
                │   (一个共享任务队列)        │
                └────────┬───────────────────┘
                         │
        ┌──────────┬─────┴──────┬──────────┬──────────┐
        ▼          ▼            ▼          ▼          ▼
      worker_0   worker_1    worker_2   worker_3   worker_N
      (谁先空闲谁就抢下一个 ready handler)

      串行化通过 strand 表达:
        strand_A ──► 该 strand 内同一时刻最多一个 handler 在跑
        strand_B ──► 与 A 互相独立,可以与 A 并行
        无 strand ──► 完全并行(任意 worker 抢)

关键性质:

  • 所有任务进同一个队列,由 worker 池抢着执行(work stealing 由 asio 内部处理)。
  • strand 不持有线程,它是一种"调度装饰器":保证投到同一 strand 的 handler 不会并行执行。
  • 不同 strand 之间(以及 strand 与无 strand 的 post 之间)互相不互斥,完全可以并行。

2.2 模式 B:多 io_context + 每池 1 个线程

   ┌──────────────┐    ┌──────────────┐         ┌──────────────┐
   │ io_context 0 │    │ io_context 1 │   ...   │ io_context N │
   │  + thread 0  │    │  + thread 1  │         │  + thread N  │
   └──────────────┘    └──────────────┘         └──────────────┘
        ▲                    ▲                          ▲
        │                    │                          │
        └────────  按 key 路由到对应 io_context  ──────┘

   注意:不同 io_context 之间没有 work stealing。
        每个池子只有 1 个线程,该线程只服务自己池子里的任务。

关键性质:

  • N+1 个独立的 io_context,每个池子启单线程事件循环。
  • 业务侧用一层路由把任务静态分桶到对应池子(例如 key % N)。
  • 单池内只有一根线程 → 天然串行,无锁。不同池之间 → 物理隔离,互不抢线程

3. 两个最小可运行示例

3.1 模式 A:strand 写法

#include <boost/asio.hpp>
#include <iostream>
#include <thread>
#include <vector>

int main() {
    boost::asio::io_context io;
    auto guard = boost::asio::make_work_guard(io);

    std::vector<std::thread> workers;
    for (int i = 0; i < 4; ++i)
        workers.emplace_back([&io] { io.run(); });

    auto strandA = boost::asio::make_strand(io);
    auto strandB = boost::asio::make_strand(io);

    boost::asio::post(strandA, []{ std::cout << "A1\n"; });
    boost::asio::post(strandA, []{ std::cout << "A2\n"; });
    boost::asio::post(strandB, []{ std::cout << "B1\n"; });
    boost::asio::post(io.get_executor(), []{ std::cout << "raw\n"; });

    guard.reset();
    for (auto& t : workers) t.join();
}
  • A1 → A2 一定串行执行(可能在不同 worker 上,但不会并发)。
  • B1 可以与 A 系列并行。
  • raw 任务可由任意空闲 worker 立刻执行。

3.2 模式 B:多 io_context 单线程写法

#include <boost/asio.hpp>
#include <array>
#include <memory>
#include <thread>

struct Pool {
    boost::asio::io_context io;
    std::thread th;
    std::unique_ptr<boost::asio::executor_work_guard<
        boost::asio::io_context::executor_type>> guard;
};

constexpr int N = 4;  // N 个 key 桶 + 1 个兜底桶
std::array<Pool, N + 1> pools;

void start_pools() {
    for (auto& p : pools) {
        p.guard = std::make_unique<boost::asio::executor_work_guard<
            boost::asio::io_context::executor_type>>(
            boost::asio::make_work_guard(p.io));
        p.th = std::thread([&p]{ p.io.run(); });
    }
}

template <typename Fn>
void post_by_key(int key, Fn&& fn) {
    boost::asio::post(pools[key % N].io.get_executor(), std::forward<Fn>(fn));
}

template <typename Fn>
void post_global(Fn&& fn) {
    boost::asio::post(pools[N].io.get_executor(), std::forward<Fn>(fn));
}

int main() {
    start_pools();

    post_by_key(0,  []{ /* 落在 pools[0],只能在 thread 0 上跑 */ });
    post_by_key(1,  []{ /* 落在 pools[1] */ });
    post_by_key(5,  []{ /* 5 % 4 == 1,落在 pools[1] */ });
    post_global(   []{ /* 落在 pools[N],与所有 key 桶物理隔离 */ });

    for (auto& p : pools) p.guard.reset();
    for (auto& p : pools) p.th.join();
}
  • 同 key 永远落在同一线程 → 串行 + cache 友好。
  • 不同 key 桶之间互不抢线程。
  • 没有 work stealing:pools[0] 卡死,pools[1..N] 闲着也帮不上忙。

4. strand 究竟在做什么

很多人对 strand 的第一印象是"它是个 mutex"或者"它是个小线程池",这两个都不准确。准确的描述是:

strand 是 io_context 上的一种 executor 适配器,它把"我"上面收到的所有 handler 排成一个队列,保证任意时刻最多一个 handler 处于执行中

几个推论:

  1. 同一 strand 内 → 串行,handler 之间天然有"happens-before",无需互斥锁。
  2. 不同 strand 之间 → 并行,strand 不提供跨 strand 的同步保证。
  3. strand 与 raw post(同一 io_context) → 并行。
  4. strand 不占线程:它只是个"调度规则",真正干活的还是 io_context 后面的 worker 池。
  5. strand 的执行线程不固定:同一 strand 的两次 handler 可能在不同 worker 上跑,但它们绝不会重叠。

代码层面常见用法:

// 把 handler 绑定到 strand
boost::asio::post(strandA, []{ /* ... */ });

// 给异步操作的完成回调绑定 strand
socket.async_read_some(buf, boost::asio::bind_executor(strandA,
    [](auto ec, std::size_t n) { /* 在 strandA 上跑 */ }));

注意一个常踩的语义差异:

  • boost::asio::post(strand, fn):永远排队,即使当前线程已经在该 strand 上,也会先返回再异步执行。
  • boost::asio::dispatch(strand, fn):如果当前线程已经在该 strand 上,会立即同步调用;否则才排队。

这是 strand 上经典的"重入陷阱"之一 —— dispatch 会绕过你以为存在的"先返回再执行"的排队语义。


5. 详细对比表

维度 A. 单 io_context + strand B. 多 io_context + 单线程
任务调度 共享队列,worker 抢任务,自带 work stealing 任务按 key 静态路由,不同池之间无 work stealing
串行化粒度 strand 定义任意多"逻辑串行通道" 一个池 = 一个"物理串行通道"
同串行单位内 strand 内串行,handler 之间无锁 池内只有 1 线程,自然串行,无锁
不同串行单位之间 并行,但抢同一组 worker,可能相互拖累 并行,worker 隔离,完全互不影响
冷热 key 不均 worker 池能把空闲线程让给热 key 热 key 拖死自己那池,其它池闲着帮不上
CPU 缓存 / 亲和性 handler 在哪个 worker 跑由 asio 调度,可能跨核迁移 同 key 永远同线程,cache 局部性好,可做 CPU pin
资源隔离 弱:strand 共用线程池,排队/优先级相互影响 强:故障/延迟被框死在单个池内
可观测性 主池整体的 backlog / 利用率;细粒度需自己埋点 每池可独立命名线程、独立 supervision、独立统计
shutdown 粒度 只能整池 stop(),分级 quiesce 需自己写协议 可逐池 stop(),分级 shutdown 直接做
代码心智模型 需要理解 stranddispatch vs postbind_executor “一个池一个线程,各干各的活”,直观
常见 bug strand 重入死锁、dispatch 绕过排队、误投到错的 strand 池间循环依赖死锁;但 strand 类的坑不存在
资源开销 1 个 reactor、1 组 timer fd、1 组任务队列 N 个 reactor、N 组 timer fd、N 组任务队列;N 大时不可忽视
线程数 vs 串行单位数 解耦:几个 worker 撑起几千个 strand 强绑定:串行单位数 = 线程数

6. 性能直觉:不同负载下两者怎么表现

场景 A 更好 B 更好
任务量在所有 key 上分布均匀 相当 相当(B 更直观)
少数 key 突发热点(典型:某个连接突然狂发数据) worker 池可以挤过去帮忙 热点池堵塞,别人闲着
串行单位数巨大(数千 / 数万) 几个 worker 撑得起 起几千个线程不现实
关键实时路径,不能被无关任务抢线程 strand 之间共用 worker,可能优先级反转 独立池,完全隔离
需要细粒度可观测、单独 supervise 某条流水线 难以细分 池子 = 监控单位
需要 cache 友好 + 可 CPU pin 调度不确定 线程绑死任务,天然亲和
资源紧张 + 串行单位很少 一份 reactor 开销 浪费
对"最慢那一小部分请求"敏感(尾延迟) work stealing 能让空闲线程帮忙,最慢的请求也不会拖太久 热点桶里任务只能排队等自己那根线程,少数请求会被拖得明显比其他慢

7. 案例:一个实时遥测流水线

下面给一个综合两种模式的设计示例 —— 一个实时遥测(telemetry)数据采集流水线,需要把来自上游的事件按数据归属(shard key)写到对应分片的内存仓库,同时还要响应"控制面事件"。这个场景里同时用到了"多 io_context + 单线程"和 strand 两种思路。

7.1 写入路径:按 shard key 路由的多池架构

// 概念示意,非生产代码
class KeyShardedPool {
public:
    void start() {
        for (int i = 0; i <= kNumShards; ++i) {
            ios_[i] = std::make_shared<boost::asio::io_context>();
            guards_[i] = std::make_shared<boost::asio::executor_work_guard<
                boost::asio::io_context::executor_type>>(
                boost::asio::make_work_guard(*ios_[i]));
            threads_[i] = std::thread([this, i] {
                pthread_setname_np(pthread_self(),
                    ("Shard_" + std::to_string(i)).c_str());
                ios_[i]->run();
            });
        }
    }

    // 按 shard key 路由:同 key 永远落同一线程
    template <typename Fn>
    void post(const std::string& keyBytes, Fn&& fn) {
        const int shardKey = parseShardKey(keyBytes);
        const int idx = (shardKey == kNoKey)
            ? kNumShards                         // 兜底桶
            : shardKey % kNumShards;             // 工作桶
        boost::asio::post(ios_[idx]->get_executor(), std::forward<Fn>(fn));
    }

    // 无 key 路由:统一落兜底线程
    template <typename Fn>
    void post(Fn&& fn) {
        boost::asio::post(ios_[kNumShards]->get_executor(),
                          std::forward<Fn>(fn));
    }

private:
    static constexpr int kNumShards = 16;        // 16 个工作桶 + 1 个兜底
    static constexpr int kNoKey     = -1;

    std::map<int, std::shared_ptr<boost::asio::io_context>>  ios_;
    std::map<int, std::shared_ptr<boost::asio::executor_work_guard<
        boost::asio::io_context::executor_type>>>            guards_;
    std::map<int, std::thread>                               threads_;
};

为什么这样设计:

  1. 数据局部性:同一 shard 的内存仓库分片由同一线程独占,cache 命中率高。
  2. 强故障隔离:任何一桶卡住绝不会蔓延到其它桶。
  3. 可监控:每个 Shard_X 是独立命名的线程,top -H / perf 一眼看出哪个桶在 burn CPU。
  4. 延迟敏感:这条路径有严格的实时窗口要求,strand 模式下 worker 池被无关任务抢线程的风险不可接受。

7.2 消息分发层:同一 io_context 上 strand + raw post 并存

同一个系统的事件分发器又用了 strand,把"必须按序处理的事件"与"互相独立可并行的数据消息"分流:

class EventDispatcher {
public:
    EventDispatcher(std::shared_ptr<boost::asio::io_context> io,
                    /* handlers ... */)
        : ioPool_(std::move(io))
        , orderedStrand_(boost::asio::make_strand(*ioPool_))
    {}

    // 顺序事件:必须按到达顺序处理 → 投到 strand
    void onOrderedEvent(EventA evt) {
        boost::asio::post(orderedStrand_,
            [this, evt = std::move(evt)]() mutable {
                orderedHandler_->process(std::move(evt));
            });
    }

    // 数据消息:互相独立,可以并行解析 → 直接投 io_context
    void onDataMsg(EventB msg) {
        boost::asio::post(ioPool_->get_executor(),
            [this, msg = std::move(msg)]() mutable {
                dataHandler_->process(std::move(msg));
            });
    }

private:
    std::shared_ptr<boost::asio::io_context> ioPool_;
    boost::asio::strand<boost::asio::io_context::executor_type> orderedStrand_;
    std::shared_ptr<IOrderedHandler> orderedHandler_;
    std::shared_ptr<IDataHandler>    dataHandler_;
};

注意这里同一个 io_context 同时承载了 strand 和 raw post:strand 内串行,raw post 并行,两者跑在同一组 worker 线程上。这是 asio 的标准用法。

7.3 系统视角下两个模式如何分工

[网络订阅线程]
        │ 跨线程:boost::asio::post(io.get_executor(), ...)
        ▼
[ioPool: 多线程 + strand 模式 A]
   ├─ orderedStrand   ── 顺序事件按到达顺序处理
   └─ raw post        ── 数据消息并发解析
        │ 跨线程:keyShardedPool.post(key, fn)
        ▼
[KeyShardedPool: 多 io_context + 单线程 模式 B]
   ├─ ios[0..N-1]     ── 同 shard 永远在同一线程上写仓库
   └─ ios[N]          ── 全局收尾(无 key 路由),与 shard 桶物理隔离
        │ 跨线程:loopback().defer(...)
        ▼
[主事件循环线程]
   触发批次完成回调、生成结果文件、外部存储持久化

要点:

  • **第一层(事件分发)**负载是"高并发、不可预测"的,用 strand 模式吞吐高、节约线程。
  • **第二层(分片写入)**是"强实时、要 cache 局部性、要可监控"的,用多 io_context 单线程模式更可控。
  • **第三层(全局收尾)**显式 defer 到主线程,做需要全局一致性的工作。

8. 选型决策树

                ┌─────────────────────────────────────┐
                │ 串行单位很多(成千上万)?           │
                └──────┬───────────────────┬──────────┘
                       │ 是                │ 否
                       ▼                   ▼
              A. strand 模式         ┌──────────────────────────────┐
                                     │ 需要强资源隔离/优先级保证?  │
                                     └──┬───────────────────┬───────┘
                                        │ 是                │ 否
                                        ▼                   ▼
                              B. 多 io_context 模式   ┌──────────────────────────────┐
                                                      │ 负载是否有显著热点?         │
                                                      └──┬────────────────┬─────────┘
                                                         │ 是             │ 否
                                                         ▼                ▼
                                              A. strand                看个人偏好
                                              (有 work stealing)      B 更直观,A 更省资源

补几条经验:

  • 关键实时路径 + 串行单位有限 + 负载稳定 → 倾向 B。
  • 网络服务器 / 大量短连接 / 每连接 1 个串行单位 → A 几乎是唯一选择(几千个 io_context 不现实)。
  • 混合系统:不同子模块往往采用不同模式,关键路径用 B、控制面用 A 是常见混合策略(见上一节)。

9. 常见陷阱

9.1 strand 的"重入死锁"

boost::asio::post(strand, []{
    std::promise<int> p;
    boost::asio::post(strand, [&p]{ p.set_value(42); });
    p.get_future().wait();  // ❌ 死锁:strand 已被占用,内层 post 永远跑不了
});

修复方式是不要在 strand 上同步等同 strand 的工作,或者改用 dispatch(但要清楚 dispatch 的语义差异)。

9.2 dispatch 绕过排队

boost::asio::post(strand, []{
    std::cout << "A\n";
    boost::asio::dispatch(strand, []{ std::cout << "B\n"; });  // B 立刻同步执行
    std::cout << "C\n";
});
// 输出顺序:A B C(不是 A C B!)

如果你以为 dispatch 总会"排队再说",这里就会出现意想不到的执行顺序。新手期建议统一用 post

9.3 多 io_context 模式下的"池间循环依赖"

// pools[0] 的 handler:
post_by_key(0, [&]{
    std::promise<int> p;
    post_by_key(1, [&]{ p.set_value(42); });
    p.get_future().wait();  // ❌ 风险:若 pools[1] 也在等 pools[0],死锁
});

多 io_context 模式没有 strand 重入这种陷阱,但池间互相等任务就要小心。建议:池间只做单向投递,不做同步等待

9.4 strand 共用 worker 导致优先级反转

strand_low_priority  收到一批 100ms 的慢任务
strand_critical      收到一个实时性要求高、需要立刻处理的紧急任务

如果 worker 池只有 4 个线程,全被 strand_low_priority 的慢任务占住,strand_critical 也得排队 —— 这是 strand 模式下经典的优先级反转。要避免就得:

  • 给关键路径开独立的 io_context(回到 B 模式),或
  • 给每条 strand 配单独的 thread group(asio 不直接支持,要自己写),或
  • 在业务层做超时与降级。

9.5 多 io_context 模式下的"热点桶"

所有写入都集中在 shardKey == 7 这一桶,7 % N 永远定位到同一个 io_context。
其它 N-1 个池的线程闲着,这一桶的 backlog 不断累积。

asio 不提供 io_context 之间的 work stealing,缓解办法:

  • 增加桶数 N,降低每桶平均负载。
  • 换更分散的哈希(避免 id % N 在连号 ID 上的退化)。
  • 把已知热点 key 单独分一桶。
  • 引入背压:backlog 超阈值时丢旧数据 + 告警,而不是无界堆积。

10. 一句话各自总结

  • 模式 A(单 io_context + strand):
    “少量线程 + 任意多虚拟串行通道”,胜在 效率、灵活、API 标准、有 work stealing,输在 隔离弱、心智模型重、调度不确定
    适合:串行单位多、负载分布动态、追求并发吞吐的系统。典型场景:网络服务器、消息分发层。

  • 模式 B(多 io_context + 每池 1 线程):
    “线程数 = 关键串行单位数,各自跑各自的”,胜在 强隔离、易监控、cache 友好、心智简单,输在 没有 work stealing、资源开销大、扩展性差
    适合:实时关键路径、负载分布稳定、需要可观测和分级 supervise 的关键服务。典型场景:实时数据流水线、独立 IO 通道隔离。

实际工程里很少二选一 —— 关键路径用 B、控制面 / 消息层用 A 是最常见的混合策略。理解两者的取舍点后,再看真实代码里那些"按 key 路由的池"、“按事件类型分通道的分发器"这种"自定义线程模型”,逻辑就会清晰很多。


11. 延伸阅读

一句话:strand 是逻辑串行视图,多 io_context 是物理隔离单元 —— 同样能做到"按 key 串行 + 跨 key 并行",但工程属性走两条路。 看清自己系统对吞吐 / 隔离 / 可观测 / 心智简单的偏好,选型自然就清楚了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐