• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2026-08-01 16:19 Aet 隐藏边栏 |   抢沙发  2 
文章评分 1 次,平均分 5.0

同步 I/O:服务端

code

同步 I/O:客户端

code

异步I/O:服务端

code

异步I/O:客户端

code

定时器

code

线程池

code

strand

概述

  1. io_context::run() 可以被多个线程同时调用(线程池模式)。这时候多个 handler(回调)可能在不同线程上并发执行
  2. 如果这些 handler 访问了共享数据(比如一个 std::map、一个连接列表、一个计数器),就会有数据竞争

最直觉的解法

  1. "每个共享数据配一把 mutex"
  2. 但这样做有几个隐藏代价:
    1. 锁的粒度难控制:细粒度锁容易死锁(尤其是异步回调里又发起新的异步操作,持锁跨越异步边界几乎必然出问题);粗粒度锁又退化成单线程性能
    2. 异步回调里加锁本身很危险:如果在持锁期间调用了另一个可能同步触发同一把锁的操作,就会死锁。而 ASIOhandler 经常是链式调用的
    3. 锁不解决"逻辑顺序"问题:即使数据不竞争了,你可能仍然需要"这几个操作必须按顺序发生",普通 mutex 只保证互斥,不保证顺序性

strand的思路

  1. 现在叫 asio::strand<Executor>
    1. 它不是"保护数据",而是保证所有绑定到同一个 strandhandler 永远不会并发执行、且按 post 的顺序执行(不是抢锁,是排队执行)
    2. 这样你回到了单线程编程模型,不需要锁,也不会死锁

code:不用 strand

  1. 有数据竞争

code:无脑加锁

  1. 能用,但有代价

  1. 这个能解决问题,但想象一下:
    1. 如果 shared_counter 的更新逻辑里,还要根据结果发起另一个异步操作(比如往 socket 写数据),你就得在持锁期间调用 async_write,或者解锁后再调用
    2. 这时候"操作的顺序性"谁来保证?
    3. 两个线程可能都读到"该发送"的状态,各自解锁后都发起了写操作,顺序全乱了
    4. mutex 只管"同一时刻只有一个人在改数据",不管"事情发生的先后逻辑"

code:strand

  1. ASIO 方式
  2. 关键点:
    1. 所有对 shared_counter 的修改都被 post 到同一个 strand_

  1. ASIO 保证:
    1. 绑定同一个 strand 的多个 handler,不会被两个线程同时执行(哪怕你有 8worker 线程在 io.run()
    2. 它们会严格按照 post 的顺序依次执行(不是加锁抢占,是排队)

strand<Executor>

strand的原理

  1. strand 内部确实用了一把 mutex,但这把锁只保护"一个链表"的几次指针操作,从不跨越 handler 的执行过程

  1. impl 是从哪来的
    1. use_serviceASIOservice 机制
    2. 每个 execution_context(比如 io_context)内部维护一个全局唯一的 strand_executor_service 实例(单例,按 context 生命周期管理)

示例:TCP 连接管理器

  1. 这是 strand 真正大放异彩的场景——异步操作链中间夹杂着共享状态:

协程封装

C++20 awaitable + co_spawn

  1. awaitable<void> 是一个返回类型
    1. 本质是编译器根据 co_await/co_return 关键字生成的一个状态机对象
  2. use_awaitable 是一个 completion token
    1. 它告诉 async_read_some 这类函数:"不要走回调风格,返回一个 awaitable 给我"
  3. co_spawn(io, coroutine, detached) 才是真正"点火"的地方
    1. 它把协程包装成一个 awaitable,然后postio_context 上执行,第一次 resumeio_context 的某个线程发起

绑定 strand 的协程

  1. 如果多个协程之间要共享状态,直接把协程绑定到同一个 strand 上,比手写锁简单得多
  2. co_await asio::this_coro::executor
    1. 拿到的是当前协程所绑定的 executor(这里是 strand_ex),所以协程内部发起的每一个异步操作、每一次 resume,都会被约束在同一个 strand 上,逻辑上等价于我们之前讲的"接力棒"机制
    2. 只是这次接力棒传递的是协程的挂起点,而不是普通 handler

Stackful 协程:asio::spawn

  1. 这是 C++20 之前(甚至现在很多老代码库仍在用)的写法,基于 Boost.Context 实现真正的"可切换栈",代码看起来完全同步,没有 co_await
  2. (旧式写法,yield_context

awaitablespawn

awaitable (C++20) stackful (spawn)
挂起机制 编译器生成状态机,函数栈帧被"拆解"存到堆上 真实的栈切换(类似线程切换,但是用户态),整个调用栈被完整保留
能否在深层嵌套函数里挂起 只有标记为 awaitable<T> 的函数能 co_await,不能在普通函数里挂起 可以在任意深度的普通函数调用链里挂起(只要 yield_context 一路传下去或者存在协程里)
开销 更轻量(无独立栈,编译期优化空间大) 每个协程要分配一个栈(默认几十 KB1MB 级别),量大时内存开销明显
依赖 纯语言特性(C++20 编译器支持即可) 依赖 Boost.Context(汇编级别的上下文切换),或者 ucontext
现状 ASIO 现在主推的方式 较老,仍在维护,但新项目一般不再首选

echo server

单线程

  1. 特点
    1. 只有一个线程调用 io_context.run(),事件循环全在这个线程里跑
    2. 所有并发连接靠“回调套回调”实现,没有数据竞争问题(因为根本没有并发执行)
    3. 每个 Sessionshared_from_this 延长生命周期,防止对象在异步操作完成前被销毁

多线程

  1. 和单线程版相比的关键变化
    1. main() 里起了多个线程共同调用 io_context.run() —— 真正的并发执行
    2. 一旦多线程共享 io_context,同一个 socket 上的读/写回调可能被不同线程调度,必须用 strand 把同一个 Session 的操作串行化,否则会有数据竞争
    3. 回调风格本身没变,只是多包了一层 bind_executor(strand_, ...)

协程版本

  1. 和前两版相比的关键变化
    1. 不再有“回调套回调”,每个连接的读写逻辑写成一个线性的协程函数 echo(),看起来就像同步代码,但 co_await 处会挂起,不阻塞线程
    2. co_spawnecho() 这个协程“扔”给 executor 去跑,detached 表示不关心它的结果
    3. 生命周期管理也简化了:不再需要 shared_from_thissocket 的所有权直接被协程帧持有,协程退出(正常结束或抛异常)时自动清理
    4. 如果想要多线程版协程,只需要像 echo_server_2 一样多起几个线程调用io_context.run(),并把 co_spawn 的第一个参数换成 asio::make_strand(io_context),以保证同一个连接的协程不会被并发调度

模板

模板类型参数的默认值

  1. ReadToken 这个模板参数如果调用者没显式指定,就用 default_completion_token_t<executor_type> 顶上
  2. 注意这里默认值依赖前一个模板参数不存在的东西
    1. 实际上它依赖的是类内已知的 executor_typesocket 类自己的成员类型,不是模板参数),这在类模板的成员函数里是合法的

  1. ASIO_COMPLETION_TOKEN_FOR 只是 typename 的马甲
    1. sig 完全没被使用,纯粹是文档作用
    2. 告诉阅读者"这个模板参数必须是能配合 void(error_code, size_t) 这个签名使用的 completion token"
    3. 在启用 C++20 concepts 的构建里,ASIO 会把这个宏换成真正的 concept 约束(比如 completion_token_for<sig>),此时 sig 才真正被用上做编译期检查

别名模板(alias template)+ 继承取内嵌类型

  1. 这是标准的 type traits 手法,和 std::enable_if_t / std::remove_reference_t 一模一样
  2. default_completion_token_impl<T> 是真正干活的模板
    1. 它针对不同的 T(不同的执行器类型)做特化,内部定义一个 type
  3. default_completion_token<T> 通过继承拿到这个 type
  4. default_completion_token_t<T>C++11 风格的 _t 别名模板,等价于 typename default_completion_token<T>::type
    1. 省去调用方每次写 typename ... ::type 的麻烦
  5. 也就是说:
    1. default_completion_token_t<executor_type>
    2. 这行代码本质是在问:"这个执行器类型对应的默认 completion token 是什么?
    3. 具体答案取决于 default_completion_token_impl 针对该执行器的特化

函数参数默认值引用模板参数构造对象

  1. ReadToken&& 是万能引用(forwarding reference
    1. 因为 ReadToken 是函数模板自己的类型参数(不是被 const/其他修饰过的具体类型)
    2. 所以 ReadToken&& 在模板实参推导时既能绑定左值也能绑定右值

  1. 默认值 default_completion_token_t<executor_type>()
    1. 构造了一个该类型的临时对象
    2. 前面那行只是拿到"类型",这里多了一对 (),是在用这个类型的默认构造函数生成一个实际的值

尾置返回类型 + decltype + SFINAE

  1. 为什么不直接写返回类型,而要这么绕一圈?
    1. 因为这个函数的真实返回类型是不确定的,它取决于 ReadToken 是什么
    2. 如果 token 是一个回调 lambda,返回类型可能是 void
    3. 如果 tokenasio::use_future,返回类型是 std::future<size_t>
    4. 如果 tokenasio::use_awaitable,返回类型是一个可 co_await 的对象
  2. 这个返回类型只有靠调用 async_initiate<...>(...) 这个表达式,让编译器真正做一次类型推导才能知道
    1. 所以用 decltype(表达式) 把返回类型"借用"过来,而不是自己手写

  1. 另外这种写法还有一个副作用:SFINAE
    1. 如果对某个 ReadToken 类型,async_initiate<...>(...) 这个表达式根本不合法(编译不通过),那么整个函数在重载决议阶段会被"悄悄丢弃"而不是报错,让编译器去尝试其他重载/特化
    2. 这是模板元编程里常见的"用表达式合法性做开关"的技巧

declval<T>()

  1. 这里是 asio 自己实现的一份
  2. 可以在不需要真正构造对象的前提下,在 decltype 这种"只做类型推导、不生成代码"的上下文里,假装拿到一个 T 类型的右值
  3. 它只能出现在 decltype/sizeof 等不求值语境(unevaluated context)里,因为它其实没有函数体,只有声明
  4. 用它是因为
    1. initiate_async_receive 这个类型可能没有默认构造函数,或者构造代价不明确
    2. declval 就不用真的造一个对象出来,只是为了让 decltype 能推出 async_initiate(...) 调用后的返回类型

Completion Token实现原理

核心思想是

  1. 同一个异步函数,通过 completion token 这个参数,决定它到底"以什么方式完成"(回调 / future / 协程 / 阻塞等待...),而不用给每种方式写一个重载
  2. 具体拆开看三个角色:

default_completion_token_impl<T>

  1. 给执行器绑定默认的 token 类型
    1. 不同的执行器(executor_type)可能有不同的"默认异步风格"
    2. 比如:
    3. 普通的 io_context::executor_type 默认 token 通常是 deferred_t 或类似的东西
    4. 如果这个执行器被 asio::use_awaitable 之类的东西"打了标记"(关联了一个默认 completion token),那 default_completion_token_impl 的对应特化会返回 use_awaitable_t,这样你在协程里写 co_await socket.async_read_some(buf) 就不用每次手写 asio::use_awaitable
  2. 这本质是编译期的"策略选择表"T -> 该T对应的默认token类型,通过模板特化实现,没有运行时开销

ReadToken token参数

  1. 这次调用具体用什么方式完成
  2. 调用者传进来的可以是
    1. 一个回调函数/lambda:void(error_code, size_t)
    2. asio::use_future
    3. asio::use_awaitable
    4. asio::yield_context(Stackful coroutine)
    5. 甚至自定义的 token
  3. 这些类型五花八门,但函数体完全不用关心它们的差异,因为这些差异全部被丢给了下一步

async_initiate<ReadToken, Signature>(initiate_obj, token, args...)

  1. 真正的分发中枢
    1. 这是整个机制的核心

  1. 它做两件事:
  2. 通过 async_result<decay_t<ReadToken>, Signature> 这个 trait,决定
    1. 这次调用该返回什么类型给用户(voidfuture<size_t>?协程 awaitable?)
    2. 需要不需要把 token 转换成一个真正的、符合 Signature 的回调(比如把 use_future 转换成一个内部回调,这个回调负责 set_value/set_exceptionpromise 上)
  3. 调用 initiate_obj(这里是 initiate_async_receive(this)
    1. 把转换后的真正回调、以及 buffersflags 等参数传给它,由它去调用最底层平台相关的异步 I/O 实现
  4. initiate_async_receive 之所以被封装成一个函数对象(initiation object)而不是直接内联调用底层函数,是因为
    1. async_result 的某些特化(比如协程、future)需要"延后"或者"包一层"调用时机
    2. 比如协程场景要先把 awaitable 的状态机搭好,再决定什么时候真正触发底层 I/O;直接写成普通函数调用没法插入这种中间层

整体串联

声明:本文为原创文章,版权归所有,欢迎分享本文,转载请保留出处!

bingliaolong
Bingliaolong 关注:0    粉丝:0
Everything will be better.

发表评论

表情 格式 链接 私密 签到
扫一扫二维码分享