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

thread_info_base

干什么的

  1. 是一个per-thread(每线程)内存池 / 分配器缓存,核心目的是:减少异步操作中反复 new/delete 的开销

背景

  1. ASIO 里每次调用 async_readasync_write 之类的操作,内部都需要分配一小块内存来保存"这个异步操作的状态"(handler、缓冲区信息等)
  2. 如果每次都走系统的 malloc/free,在高并发、高频率的场景下会成为性能瓶颈
  3. ASIO 设计了一个轻量级、每线程独立的内存复用机制,避免频繁调用系统分配器,同时天然线程安全(因为每个线程只碰自己的缓存,不需要加锁)

标签系统

  1. 这些 tag 把内存缓存分成了不同"用途"的区域,每个 tag 通过 begin_mem_index/end_mem_index 圈定自己在 reusable_memory_ 数组里的一段槽位
    1. 比如协程帧分配(awaitable_frame_tag)和 executor 函数分配(executor_function_tag)各自用不同的槽位,互不干扰

  1. 这样设计是因为不同用途的内存块大小、生命周期模式不一样,分开管理复用效率更高

allocate 核心逻辑

  1. 这是一个很简单的"单槽位缓存"思路,本质是懒惰复用:
    1. 分配时:先看当前线程的缓存槽里有没有大小合适、对齐满足要求的空闲内存块,有就直接复用,不用真的调用系统分配器
    2. 没有合适的,就调 aligned_new 真正分配一块
    3. 释放时:不是真的还给系统,而是先塞回线程自己的缓存槽里,留着下次用;槽位满了才真正 aligned_delete
  2. 它用 chunks(把大小换算成固定粒度的"块数")加上一个 trick
    1. 在内存块末尾多留一个字节存 chunks 数值
    2. 来快速判断某块缓存是否够大,不需要额外的元数据结构

allocate 深入

  1. 先把请求的字节数 size 换算成"块数"(chunk_size 通常是 48 字节)
    1. 这是向上取整的写法,比如 size=10,chunk_size=4,chunks = (10+3)/4 = 3
    2. 用块数而不是精确字节数来比较,是为了让"稍大一点的旧块"也能被复用,不用精确匹配大小

  1. 然后逻辑分三步:
    1. 在缓存里找可复用的
    2. 找不到就顺手清理一个不合适的
    3. 实在没有就真的分配新内存
    4. 详细如下
  2. 扫描复用
    1. 只在这个 Purpose(比如 awaitable_frame_tag)自己的槽位范围内找
    2. 关键在 mem[0]:每个空闲块闲置时,会把自己一共有多少个 chunk 存在第 0 个字节里
    3. 所以这里判断 mem[0] >= chunks,意思是"这块旧内存够不够大,能不能装下这次请求",同时还要检查对齐(pointer % align == 0
      因为不同请求可能要求不同的对齐方式,旧块的起始地址未必满足
    4. 一旦找到合适的块:
      把这个槽位清空(reusable_memory_[mem_index] = 0),表示"取走了,不再闲置"
    5. 关键的一步:mem[size] = mem[0],把"这块内存有多少 chunk"的记录,从索引 0 挪到索引 size(也就是这次请求的字节数末尾)的位置
      这是为了配合 deallocate 时的写法,因为块被再次使用时,未来释放它要基于这次size 才能找到记录

  1. 找不到合适的块时,顺手清理一个
    1. 如果上面扫了一圈都没找到"大小够 + 对齐对"的块,说明缓存里现有的块都不合适(可能太小,或者残留着不常用大小的旧块占着槽位)
    2. 这里会真正释放掉一个槽位里闲置的块(只删一个,break 立刻退出),腾出空间
    3. 这不是为了给这次分配让路(反正马上就要走系统分配器分配新内存了)
      而是一种渐进式的缓存清理:避免槽位一直被"用不上的尺寸"占满,导致真正该被复用的内存进不来

  1. 真正分配新内存
    1. 分配的大小是 chunks * chunk_size + 1——多分配了 1 个字节
    2. 这多出来的 1 字节就是用来存"这块内存有多少个 chunk"的自描述信息,写在 mem[size] 位置
    3. 如果 chunks 超过了 unsigned char 能表示的最大值(255),就存 0,表示"太大了,记不下精确值"
    4. 这样以后这块内存被释放缓存起来时也不会被 deallocate 里那个 size <= chunk_size * UCHAR_MAX 的判断放进缓存(直接会走 aligned_delete,因为太大的块反复缓存意义不大,还占地方)

内存对齐分配

deallocate 核心逻辑

deallocate深入

  1. 这块内存值不值得缓存
    1. UCHAR_MAX255unsigned char 能表示的最大值)
    2. 这个判断的意思是:只有当这次释放的内存大小换算成 chunk 数之后,能用 1 个字节记录下来(即 chunks ≤ 255),才有资格进缓存池
    3. 如果这次释放的内存太大(chunks 超过 255),就直接判定不缓存,走最下面的 aligned_delete,不进入下面的逻辑

  1. 找一个空槽位塞进去
    1. 只在这个 Purpose 对应的槽位区间(begin_mem_indexend_mem_index)里找
    2. 只要找到第一个空槽位(值为 0,即没有缓存任何指针),就把这块要释放的内存塞进去,然后立刻 return,不会真的调用系统释放
    3. 关键的一行是:mem[0] = mem[size];
      回忆一下:这块内存当初被分配(或者上次被复用)时,chunk 数记在 mem[size] 位置
      现在要把它放进"闲置状态",闲置状态下统一约定"标记存在索引 0"的位置

  1. 兜底,真正释放
    1. 这次释放的内存太大(size > chunk_size * UCHAR_MAX),一开始就不进缓存逻辑
    2. this_thread 为空(比如不在受管理的线程上),或者该 Purpose 对应的槽位已经全部被占满,没地方放

总结

  1. thread_info_base就存了当前线程的复用内存,以及异常信息
  2. io_context::run()跑在哪个线程,当前线程就是哪个线程

thread_context

  1. win_iocp_io_context继承了thread_context

  1. thread_call_stack类型
    1. 定义用thread_contextthread_info_basecall_stack的物化类型

call_stack

tss_ptr<context>

  1. ASIO 自己封装的线程本地存储指针
    1. 每个线程看到的 top_ 都是独立的一份,这是整个类线程安全、不用加锁的根本原因

  1. tss_key_ 是一个 DWORD,本质上是一个"索引/句柄",不是真正的数据
    1. 它的作用类似于"一个槽位编号"
    2. 操作系统会为每个线程维护一份独立的 TLS 数据区,tss_key_ 就是"到这个数据区里第几号槽位去存/取值"的编号,所有线程共用同一个编号,但各自槽位里的内容互不干扰

  1. 读取值(隐式转换运算符)
    1. ::TlsGetValue(tss_key_)Win32 API,语义是"取出当前调用线程在这个槽位编号下存的值"
    2. 虽然 tss_key_ 这个编号是所有线程共享的,但 TlsGetValue 返回的内容是每个线程各自独立存的那一份,操作系统在背后帮你按"当前是哪个线程在调用"做了区分

call_stack/context

  1. 在当前函数的栈上创建一个 win_iocp_thread_info 对象
    1. thread_info_base,装着这个线程自己的内存复用缓存、异常暂存等信息
    2. 注意它是局部变量,生命周期跟着这个函数走
  2. this 作为 Key*(这里的 this 是外层调用这段代码的对象,是当前的 win_iocp_io_context 实例本身),this_thread 作为 Value&。这行代码执行完,效果是:
    1. 把当前正在跑的 io_context 和这个线程自己的 win_iocp_thread_info 关联到了一起
    2. 这个关联被压进了 call_stack<io_context, win_iocp_thread_info>::top_ 这个线程本地栈的栈顶
    3. ctx 这个局部变量的生命周期结束时(比如这个函数返回,或者这个作用域结束),析构函数自动把 top_ 还原,等于把这个关联从栈里弹出去

  1. 总结来说
    1. 从现在开始,直到这个作用域结束,当前线程正在为这个 io_contextthis)工作,需要用的线程私有数据是 this_thread 这份
    2. 之后在这条调用链的任何更深层代码里,只要想知道"我现在是不是在为某个 io_context 服务?用的是哪份线程私有数据?",都可以通过 call_stack::top()call_stack::contains(key) 查出来,不需要把 this_thread 这个指针一层层地当参数往下传
  2. 防止同一个 io_context 在同一线程里递归调用自己导致死锁

整体设计思路

  1. 每个线程维护一个"调用链栈"
  2. 这个类要解决的问题是:
    1. 在当前这个线程的调用链条上,能不能找到某个特定"身份标识"(Key)关联的数据?
  3. 举个具体场景:
    1. ASIO 里有个经典用法是防止同一个 io_context 在同一线程里被递归调用导致死锁或重复执行
    2. 比如线程正在执行 io_context::run() 内部的某个 handler,这个 handler 里又不小心间接调用了同一个 io_context 的东西
    3. call_stack<io_context> 就能在运行时检测出"当前线程的调用链上,是不是已经有这个 io_context 了",从而做出正确处理(比如直接内联执行而不是重新排队,或是避免死锁)

内联执行 vs 排队执行

排队执行(正常异步流程)

  1. handler(回调函数)包装一下,塞进 io_context 的任务队列里,然后当前函数直接返回,不等 handler 真的执行完
  2. 等到某个线程(可能是当前线程,也可能是另一个线程)调用 run()/poll() 从队列里取出这个任务时,才会真正调用它

内联执行(inline execution

  1. 不经过队列,直接在当前这个函数调用的位置,同步地、立刻把 handler 调用一遍,跟普通函数调用没有区别
  2. handler 执行完,some_internal_function 才继续往下走或返回

为什么要区分这两种情况

  1. 假设你在一个正在被 io_context::run() 调用的 handler 内部,又调用了 io_context::dispatch(some_handler)

  1. 如果这里排队(像 post 那样),handler B 会被塞进队列,要等 handler A 彻底执行完、run() 的循环再转一圈才会被取出执行。输出顺序是:

  1. 但如果 dispatch 检测到"当前线程已经在这个 io_context 的调用链里了",就会内联执行——直接在原地把 handler B 调用掉,不排队。输出顺序变成:

  1. 这就是 dispatch 语义上的承诺
    1. 如果当前线程已经在为这个 io_context 服务,就"尽快"执行(能立刻跑就立刻跑,不用等排队轮到)
    2. 如果当前线程还没参与,就老老实实排队,转交给合适的线程去跑

代码层面怎么实现"内联执行"

post vs dispatch

post

  1. 无条件排队,保证异步
    1. 调用它的这次函数调用一定在 handler 真正执行之前就返回
  2. 无条件地把任务包装一下,塞进 io_context 内部维护的一个任务队列(准确说是 operation 队列,里面存的不是原始的 handler,而是包装过的、统一接口的"待执行操作"对象),然后由某个调用了 run()/poll() 的线程从队列里取出来执行

dispatch

  1. 条件允许内联
    1. 如果条件满足,直接在当前调用栈里同步执行
    2. 不满足则退化为跟 post 一样的排队行为
  2. dispatch 不总是走队列
    1. dispatch 在满足"内联条件"(当前线程已经身处这个执行上下文的调用链)时,是完全绕过队列的

run() 循环之外调用

  1. 这里 dispatch 也没有内联执行
    1. 因为调用 dispatch 的时候,当前线程(main 线程)还没有进入 io_context 的执行上下文(run() 还没开始跑),call_stack::contains 检测不到,所以 dispatch 退化成了排队

handler 内部调用

什么时候必须用 post 而不是 dispatch

  1. 当你需要严格保证"顺序"或者"不被打乱"时,用 post
    1. 因为 dispatch 的内联执行会打乱你原本以为的执行顺序

如果想主动"让出"当前的调用栈,避免栈溢出

strand 场景下的区别

  1. strand(用来保证一组 handler 不会被并发执行,替代加锁)包装出来的 executor,对 dispatch 的"是否内联"判断会更严格一些
    1. 不仅要看"当前线程是否在这个 io_context 的调用链上",还要看"当前是否已经在这个 strand 内部执行"(即没有其他 handler 正通过这个 strand 在跑)
    2. 如果满足,同样可以内联
    3. 否则即便当前线程正在跑别的 io_context 任务,也会乖乖排队,以保证 strand "同一时刻只有一个 handler 在跑"这个核心承诺不被破坏

executor

概述

  1. 现代 ASIOexecutor 是"执行的目标/载体"这个抽象概念,post/dispatch 提交任务时都是提交给某个 executor,而不是绑死在 io_context 身上

任务队列

  1. 现代 ASIOExecutor 模型)里,"任务队列"不一定专属于 io_context
    1. post/dispatch 严格来说不是 io_context 的专属操作,而是任意一个 executor 都要支持的通用操作(asio::post(executor, handler)asio::dispatch(executor, handler),是自由函数,不只是成员函数
  2. io_context
    1. 是最常见的一种 executor,它维护了那个任务队列
  3. thread_pool
    1. 也是一种 executor,也有自己的任务队列
  4. strand 包装出来的 executor
    1. 比如 asio::strand<io_context::executor_type>
    2. 它自己并不维护一个独立的任务队列,而是包装、代理一个底层的 executor(通常就是某个 io_context
    3. 任务提交给 strand 时,strand 内部会做一层"排他性调度"的逻辑(保证同一时刻只有一个任务在跑、保证提交顺序被遵守),但最终这些任务还是会被转交给它包装的那个底层 executor 的队列去真正排队、执行
      可以理解成 strand 是一层"中间调度逻辑",不是"独立的队列容器"
  5. system_executor
    1. 这是一个更轻量的 executor,通常直接用系统线程池风格的方式立刻派发任务,语义上更接近"没有排队,来了就跑"
    2. 不像 io_context 那样有个显式的、需要你调用 run() 才会被处理的队列

整条链路

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

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

发表评论

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