使用共享内存优化
1 | /** |
1 | /** |
内存模型(Memory Model)是定义数据一致性与执行顺序规则的一组规范。
关键概念:
所有原子变量都满足原子性。但是其他两者不一定满足,由内存序定义。
比如原子自增 fetch_add(relaxed) 是线程安全的,但是无法使用 load 指令读到最新的结果。
状态 | 含义 |
---|---|
M (Modified) | 缓存行已被修改,只有当前 CPU 拥有,主内存未更新 |
E (Exclusive) | 缓存行未被修改,只有当前 CPU 拥有,与主内存一致 |
S (Shared) | 缓存行未被修改,多个 CPU 拥有,与主内存一致 |
I (Invalid) | 缓存行无效,必须重新从主内存加载 |
术语:
说人话:
层级 | 机制 | 作用 |
---|---|---|
指令级 | 原子指令(如 LOCK XADD) | 保证操作不可打断 |
缓存级 | MESI 协议 | 控制缓存行访问,避免冲突 |
编译器级 | 编译器屏障 | 防止指令重排 |
CPU级 | 内存屏障 | 保证执行顺序 |
高级机制 | 事务性内存 | 实现复杂原子逻辑(可选) |
原子指令(如 LOCK XADD):保证操作不可打断
PIC
):程序可以在内存中的任意位置运行,不需要修改代码中的绝对地址。在 x86-64 架构中,传统的绝对地址寻址方式不再适用于位置无关代码。于是引入了 RIP(指令指针)相对寻址:
假设你有一个全局变量 int x = 42;,在汇编中访问它可能会变成:
1 | asm |
这里的 offset_to_x 是编译器计算出来的 x 相对于当前指令的偏移量。
寻址方式 | 描述 | 是否位置无关 |
---|---|---|
绝对地址寻址 | 使用固定地址,如 [0x400123] | ❌ 否 |
寄存器间接寻址 | 如 [rax],地址由寄存器决定 | ✅ 是 |
RIP 相对寻址 | 如 [rip + offset],相对当前指令位置 | ✅ 是 |
但并不是所有 PIC 都用 RIP 相对寻址,PIC 的实现方式取决于:
架构 | 是否使用 RIP 相对寻址 | 是否支持位置无关代码 |
---|---|---|
x86-64 | ✅ 常用,尤其访问数据段 | ✅ 强力支持(默认启用) |
x86 (32位) | ❌ 无 RIP,用其他方式实现 | ✅ 但需要特殊技巧 |
举个 gdb 调试的例子:
1 | (gdb) x/i $rip |
cmpl $0x0, 0x2d939d(%rip)
是一条比较指令(cmp
),用于将某个内存地址中的值与立即数 0
进行比较。(%rip)
表示使用 RIP 相对寻址,这是 x86-64 架构中常见的一种寻址方式。0x2ac084e37fb4
处的值,也就是 __libc_multiple_threads
这个变量。__libc_multiple_threads
是什么?
所以这条指令的作用是:判断当前进程是否是多线程环境,可能用于决定是否启用线程安全的行为。
这就允许编译器生成位置无关代码,即使程序被加载到不同的内存地址,偏移量仍然有效。
在 x86-64 架构中,RIP 相对寻址的偏移量被设计为一个有符号的 32 位整数,也就是一个 displacement(位移)字段,它在机器码中只占用 4 个字节。
RIP 是 64 位的指令指针,表示当前指令的地址。
RIP 相对寻址的目标地址是通过:
目标地址 = 下一条指令地址(RIP) + 32 位偏移量
这个偏移量是一个 有符号整数,所以它的范围是:
从 −2³¹ 到 +2³¹−1,即 ±2GB 的寻址范围。
这意味着,当前指令附近 ±2GB 范围内的任何数据都可以通过 RIP 相对寻址访问。
优点 | 说明 |
---|---|
✅ 节省空间 | 只用 4 字节表示偏移,比使用完整 64 位地址节省指令长度 |
✅ 支持位置无关代码 | 编译器只需计算偏移,不依赖绝对地址 |
✅ 高效 | CPU 执行时只需加法运算,无需查表或重定位 |
✅ 安全 | 支持地址空间布局随机化(ASLR),提高安全性 |
因为代码中不再硬编码具体地址,多个进程可以:
这大大节省了内存,提高了系统效率。
举个例子:
进程 | 加载地址 | 使用的代码段 |
---|---|---|
A | 0x400000 | 使用共享代码段 |
B | 0x500000 | 使用共享代码段 |
两者的代码段内容完全一样,因为里面的寻址是相对 RIP 的,不依赖于加载地址。
为什么绝对寻址不可以被多进程共享?
所以,绝对地址在一个进程中是有效的,在另一个进程中可能就指向错误的地方或根本不存在。
需要重定位,无法直接共享物理页
违反共享库的设计原则
.so
或 .dll
)的核心优势就是可以被多个进程共享。区域 | 是否可共享 | 原因说明 |
---|---|---|
代码段 | ✅ 是 | 只读 + 位置无关,多个进程可映射同一物理页 |
数据段 | ❌ 否 | 每个进程的数据不同,需独立副本 |
堆 | ❌ 否 | 动态分配,地址空间不同 |
栈 | ❌ 否 | 私有调用栈,不能混用 |
共享内存段 | ✅ 是 | 显式创建,专门用于共享 |
如果你想深入了解某个进程的内存布局,可以分析 /proc/[pid]/maps
或用工具如 pmap
、vmmap
。
我们在调试 release 版本的程序时,由于缺乏符号信息,所以需要通过寄存器来查看函数的参数、返回值等。
寄存器名 | 英文名称 | 作用 |
---|---|---|
rax | Accumulator | 累加器,通常用于算术运算和函数返回值存储。 |
rbx | Base | 基址寄存器,常用于存储数据或指针。 |
rsi | Source Index | 源索引寄存器,常用于字符串操作中的源地址指针(函数第一个参数)。 |
rdi | Destination Index | 目标索引寄存器,常用于字符串操作中的目标地址指针或结构体指针(函数第二个参数)。 |
rdx | Data | 数据寄存器,常用于 I/O 操作或乘除法运算中的扩展数据存储(函数第三个参数)。 |
rcx | Counter | 计数器寄存器,常用于循环计数或字符串操作中的计数(函数第四个参数)。 |
rsp | Stack Pointer | 栈指针寄存器,指向当前栈顶。 |
rbp | Base Pointer | 基址指针寄存器,指向当前栈帧的基址。 |
r8~r15 | General Purpose | 通用寄存器,扩展的 64 位寄存器之一,用于存储数据或指针(r8 r9 常用于保存函数第五 |
寄存器名 | 英文名称 | 作用 |
---|---|---|
rip | Instruction Pointer | 指令指针寄存器,存储当前执行指令的地址。 |
rflags | Flags | 标志寄存器,存储状态标志位(如进位、溢出、零标志等)。 |
寄存器名 | 英文名称 | 作用 |
---|---|---|
cs | Code Segment | 代码段寄存器,指向当前代码段的基址。 |
ds | Data Segment | 数据段寄存器,指向当前数据段的基址。 |
es | Extra Segment | 额外段寄存器,指向额外数据段的基址。 |
fs | FS Segment | 特殊用途段寄存器,常用于线程本地存储等。 |
gs | GS Segment | 特殊用途段寄存器,常用于线程本地存储等。 |
ss | Stack Segment | 栈段寄存器,指向当前栈段的基址。 |
寄存器名 | 英文名称 | 作用 |
---|---|---|
xmm0-xmm15 | SIMD Registers | 用于 SSE 指令集的 128 位向量运算。 |
ymm0-ymm15 | AVX Registers | 用于 AVX 指令集的 256 位向量运算。 |
zmm0-zmm31 | AVX-512 Registers | 用于 AVX-512 指令集的 512 位向量运算。 |
在 x86_64 架构中,函数调用时的参数传递遵循 System V AMD64 ABI(Linux/Unix 系统的标准调用约定)。
前六个整数或指针类型的参数依次存储在以下寄存器中:
对于浮点类型的参数(如 float
或 double
),前八个参数存储在以下 SSE 寄存器 中:
溢出参数(超过寄存器数量)会依次存储在 栈 中:
在函数调用时,栈的布局通常如下(从高地址到低地址):
在 x86_64 架构中,函数调用时会涉及到栈的操作,包括压栈和出栈。这些操作主要用于保存调用者的上下文(如
返回地址、寄存器值)以及为被调用函数分配栈帧。
压入返回地址 当调用者使用 call
指令调用函数时,CPU 会自动将返回地址(下一条指令的地址)压入
栈中。此时,rsp
(栈指针)会减少 8 字节(64 位系统)。
1 | call function |
压入溢出参数(如果有) 如果函数的参数超过了寄存器数量(整数参数超过 6 个,浮点参数超过 8 个)
,多余的参数会从右到左依次压入栈中。rsp
会随着每个参数的压入减少。
对齐栈 为了满足 16 字节对齐 的要求,调用者可能会插入额外的填充字节,使得 rsp
在调用函数
前保持 16 字节对齐。
保存调用者的栈帧基址 被调用者通常会保存调用者的栈帧基址(rbp
),以便在函数返回时恢复调用者
的栈帧。
1 | push rbp ; 保存调用者的 rbp |
分配栈空间 被调用者会根据函数内部局部变量的需求,在栈上分配空间。rsp
会减少相应的字节数。
1 | sub rsp, <size> ; 为局部变量分配栈空间 |
释放局部变量的栈空间 被调用者在返回前会释放为局部变量分配的栈空间。
1 | add rsp, <size> ; 恢复 rsp |
恢复调用者的栈帧基址 被调用者会恢复调用者的 rbp
,以确保调用者的栈帧完整。
1 | pop rbp ; 恢复调用者的 rbp |
返回到调用者 被调用者使用 ret
指令从栈中弹出返回地址,并跳转到该地址。
1 | ret ; 等价于:pop rip |
cdecl
调用约定),调用rsp
。1 | add rsp, <size> ; 清理栈上的参数 |
rsp
)和基址指针(rbp
)的变化以下是一个函数调用的栈布局示例:
C 代码
1 | void example(int a, int b) { |
汇编代码(简化版)
1 | # main 函数 |
栈布局变化 | 操作 | rsp
变化 | 栈内容(从高地址到低地址) | | ————– | ———– |
————————– | | call example
| rsp -= 8
| 返回地址 | | push rbp
| rsp -= 8
|
保存调用者的 rbp
| | sub rsp, 16
| rsp -= 16
| 为局部变量分配空间 | | leave
| rsp += 16
|
释放局部变量空间 | | ret
| rsp += 8
| 弹出返回地址 |
rbp
和分配局部变量空间。rsp
和 rbp
的变化:rsp
指向栈顶,动态变化。rbp
指向栈帧基址,通常固定不变。1 | # 查看所有寄存器 |
其中,info registers
会打印三列:
0x
开头)info registers rdi
与 p $rdi
效果相同。
从寄存器查到的内存地址,可以用 x
(examinze)命令来查看内存的值:
1 | # 查看指令 |
x
命令的说明:
1 | x/FMT ADDRESS |
其中:
x
:表示“examine memory”(查看内存)2
:数字,表示要查看的单元数g
:表示每个单元的 size,有 b(byte), h(halfword), w(word), g(giant, 8 bytes)x
:表示值的格式,有 o(octal), x(hex), d(decimal), u(unsigned decimal), t(binary), f(float),在 gdb 命令行中使用 help
命令,可以查看命令的说明。
1 | (gdb) help x |
TDODO
frame
与寄存器的值$rax
, $rdi
, $rsp
等)是当前 CPU 执行上下文的快照。frame 0
(最内层栈帧)时,寄存器值是最真实的,因为这是程序当前正在执行的地方。寄存器值可能出现的情况
情况 | 表现 |
---|---|
寄存器是 caller-saved(如 rdi, rsi, rax) | 可能显示 |
寄存器是 callee-saved(如 rbx, rbp, r12~r15) | 通常能正确还原 |
没有调试信息或优化严重 | GDB 无法还原,显示当前值或 |
建议
1 | # 查看所有线程 ID 和当前线程 ID(gdb 中会使用 * 标注当前线程) |
默认情况下,GDB 会让所有线程一起运行(比如你执行 continue 时)。如果你只想让当前线程运行,其它线程保
持暂停,可以使用:
1 | (gdb) set scheduler-locking on |
这表示:只有当前线程会执行,其他线程全部暂停。
其中模式还有:
模式 | 说明 |
---|---|
off | 默认值,所有线程都可以运行 |
on | 只有当前线程运行,其他线程暂停 |
step | 单步调试时只运行当前线程,continue 时其他线程也会运行 |
你可以随时切换:
1 | (gdb) set scheduler-locking step |
1 | # 查看汇编代码,其中 "=>" 标记的是当前执行位置 |
shared libraries
)中,通常需要使用 PIC,以便库可以被加载到任意内存地址。访问全局变量 在 PIC 模式下,代码通过 全局偏移表(GOT, Global Offset Table) 和 过程链接
表(PLT, Procedure Linkage Table) 访问全局变量和函数地址。
寄存器 rip
的使用 x86_64 支持基于 rip
(指令指针)的寻址方式,PIC 会利用 rip
相对寻址来
访问全局变量或函数地址,而不是使用绝对地址。
在 x86-64 架构中,传统的绝对地址寻址方式不再适用于位置无关代码。于是引入了 RIP(指令指针)相对寻址:
假设你有一个全局变量 int x = 42;,在汇编中访问它可能会变成:
1 | asm |
这里的 offset_to_x 是编译器计算出来的 x 相对于当前指令的偏移量。
寻址方式 | 描述 | 是否位置无关 |
---|---|---|
绝对地址寻址 | 使用固定地址,如 [0x400123] | ❌ 否 |
寄存器间接寻址 | 如 [rax],地址由寄存器决定 | ✅ 是 |
RIP 相对寻址 | 如 [rip + offset],相对当前指令位置 | ✅ 是 |
rip
相对寻址,避免了加载时的重定位操作,提高了加载速度。1 | mov rax, [rip + global_var@GOTPCREL] ; 通过 GOT 表访问全局变量 |
举个 gdb 调试的例子:
1 | (gdb) x/i $rip |
cmpl $0x0, 0x2d939d(%rip)
是一条比较指令(cmp
),用于将某个内存地址中的值与立即数 0
进行比较(%rip)
表示使用 RIP 相对寻址,这是 x86-64 架构中常见的一种寻址方式。0x2ac084e37fb4
处的值,也就是 __libc_multiple_threads
这个变量。__libc_multiple_threads
是什么?
所以这条指令的作用是:判断当前进程是否是多线程环境,可能用于决定是否启用线程安全的行为。
这就允许编译器生成位置无关代码,即使程序被加载到不同的内存地址,偏移量仍然有效。
在 x86-64 架构中,RIP 相对寻址的偏移量被设计为一个有符号的 32 位整数,也就是一个 displacement(位移
)字段,它在机器码中只占用 4 个字节。
RIP 是 64 位的指令指针,表示当前指令的地址。
RIP 相对寻址的目标地址是通过:
目标地址 = 下一条指令地址(RIP) + 32 位偏移量
这个偏移量是一个 有符号整数,所以它的范围是:
从 −2³¹ 到 +2³¹−1,即 ±2GB 的寻址范围。
这意味着,当前指令附近 ±2GB 范围内的任何数据都可以通过 RIP 相对寻址访问。
优点 | 说明 |
---|---|
✅ 节省空间 | 只用 4 字节表示偏移,比使用完整 64 位地址节省指令长度 |
✅ 支持位置无关代码 | 编译器只需计算偏移,不依赖绝对地址 |
✅ 高效 | CPU 执行时只需加法运算,无需查表或重定位 |
✅ 安全 | 支持地址空间布局随机化(ASLR),提高安全性 |
因为代码中不再硬编码具体地址,多个进程可以:
这大大节省了内存,提高了系统效率。
举个例子:
进程 | 加载地址 | 使用的代码段 |
---|---|---|
A | 0x400000 | 使用共享代码段 |
B | 0x500000 | 使用共享代码段 |
两者的代码段内容完全一样,因为里面的寻址是相对 RIP 的,不依赖于加载地址。
为什么绝对寻址不可以被多进程共享?
所以,绝对地址在一个进程中是有效的,在另一个进程中可能就指向错误的地方或根本不存在。
需要重定位,无法直接共享物理页
违反共享库的设计原则
.so
或 .dll
)的核心优势就是可以被多个进程共享。区域 | 是否可共享 | 原因说明 |
---|---|---|
代码段 | ✅ 是 | 只读 + 位置无关,多个进程可映射同一物理页 |
数据段 | ❌ 否 | 每个进程的数据不同,需独立副本 |
堆 | ❌ 否 | 动态分配,地址空间不同 |
栈 | ❌ 否 | 私有调用栈,不能混用 |
共享内存段 | ✅ 是 | 显式创建,专门用于共享 |
如果你想深入了解某个进程的内存布局,可以分析 /proc/[pid]/maps
或用工具如 pmap
、vmmap
。
先复习下 poll
函数:
1 | int poll(struct pollfd *fds, nfds_t nfds, int timeout); |
FIXME:这种在汇编代码 ret 前断点,并依据 rax
、rdi
设置条件断点的方式不可靠,因为可能进入了
libc 层。
1 | # 查看 polll 的汇编代码 |
日志:
1 | Thread 0 (crashed) 0 libexample.so + 0x36406 |
1 | addr2line -e libexample.so 0x36406 |
或用 gdb :
1 | gdb libexample.so |
https://github.com/dmtcp/dmtcp/tree/main/jalib
malloc
/free
是操作系统(或 C 库)提供的通用堆分配器。
malloc
的性能和碎片控制未必理想。malloc
很难让你:
malloc 的确在其实现内部也维护着自己的 “内存池”,并且会对小块内存(small bins/tcache/fast bins 等)做优化和分组管理。比如在 glibc 的 malloc(ptmalloc)中,就有针对小块内存的快速分配机制。
malloc 是 “通用分配器”
malloc 需要支持所有应用场景,包括大 / 小 / 奇异尺寸的分配、跨多线程、兼容各种系统调用和 ABI。
为了兼容性和健壮性,malloc 实现复杂,包含很多额外的元数据和检查,导致分配 / 释放开销更大。
malloc 的小块管理是 “全局的”
malloc 管理的小块是全进程共享的,所有线程 / 模块都会竞争同一套管理结构(如 fastbin、tcache、small bin)。
在高并发、频繁小块分配 / 释放的场景下,锁竞争和同步成本变高,可能成为性能瓶颈。
自定义分配器(如 jalib)“更窄、更专用”
jalib 只服务 DMTCP 内部的特殊内存分配需求,只关注固定几种典型的小块尺寸(如 64/256/1024…)。
可以用更简单、更高效的 “无锁链表 + 内存对齐块” 来管理池,分配和释放几乎都是 O(1)的原子操作。
不需要兼容所有 malloc 的场景(如 realloc、跨模块释放等),所以能极致优化。
控制权和可观测性
jalib 可以完全掌控池的生命周期、分配区域、分配策略(如预扩展、定制回收),还可以追踪统计、调试。
malloc 的内部状态你无法直接控制或感知,也无法方便地和 DMTCP 的 checkpoint、回滚等功能集成。
内存碎片和确定性
专用分配器能保证分配块 “定长、对齐”,几乎无碎片,分配和回收都是确定性的。
malloc 需要兼容各种尺寸,碎片和内存抖动不可避免。
虽然 malloc 也是内存池管理,但它是为通用用途设计的,不能满足 DMTCP 这类高性能、高可控性、特殊内存管理需求场景。自定义 jalib 分配器可以更高效地管理小块内存,优化多线程性能,便于调试和适配特定需求。
可以归纳为三点:
_alloc_raw
(通常是 mmap
)。优点:
每个层级对应一个 JFixedAllocStack<N>
,其核心是无锁栈式管理:
核心技术点
原子双字比较交换(128 位 CAS)
为了线程安全,栈顶指针 _top 需要原子更新。这里用到了 128 位 CAS(Compare-And-Swap),保证 node 指针和计数器同时更新,避免 ABA 问题。
CAS 不可用时的降级方案
对于不支持 128 位原子操作的平台,采用 futex+memcpy 的方式手动实现互斥和原子性。
分配和释放都用原子操作保护,无需锁,性能高。
多线程环境下不会出现竞争条件或内存破坏。
分配的内存区域(arena)可以记录到全局数组中,方便调试和统计。
通过 JAlloc::getAllocArenas()
可获得分配区域列表。
如果定义了 OVERRIDE_GLOBAL_ALLOCATOR
,会重载 operator new
和 operator delete
,让全局 new
/delete
也用这个分配器。
可以通过宏 JALIB_ALLOCATOR
切换:
本内存分配器的设计核心在于:
这种设计非常适合像 DMTCP 这样对性能和内存管理有特殊要求的系统级软件。
Memory = 存储 + 访问逻辑
存储
uint8_t mem[1024];
表示。访问逻辑
时序和延迟
功能说明
1 | #include <iostream> |
输出的 memory_power.csv 文件内容示例:
1 | Cycle,DynamicPower,StaticPower,TotalPower |
每列含义:
功耗分析
1 | import pandas as pd |
在多端口系统中,尤其是在使用总线结构的系统中,总线冲突(Bus contention)是一个常见的问题。总线冲突通常发生在多个设备尝试同时访问总线上的同一资源时。这种情况可能会导致数据损坏、系统性能下降或甚至系统崩溃。下面是一些解决和缓解总线冲突的策略:
优先级仲裁:根据预先设定的优先级顺序决定哪个设备可以访问总线。
轮询仲裁:轮流让每个设备访问总线。
基于请求的仲裁(如请求共享(Request-for-Shared, RFS)和请求独占(Request-for-Exclusive, RFE)):设备首先请求对资源的访问,然后根据请求的类型(共享或独占)来决定访问权限。
分时复用
通过时间分割(Time Division Multiplexing, TDM)或频率分割(Frequency Division Multiplexing, FDM),可以允许多个设备在不同的时间或频率上使用总线,从而减少冲突。例如,可以使用时分多路复用将总线的不同时间段分配给不同的设备。
编码和解码技术
使用特殊的编码和解码技术,如霍纳编码(Hornar code)或格雷码(Gray code),可以减少在总线上传输数据时的错误,并帮助检测和纠正数据冲突。
总线锁定
在访问总线期间,通过总线锁定机制确保没有其他设备可以访问总线。这可以通过在总线上设置一个锁定信号来实现,该信号在访问期间保持激活状态。
缓存和缓冲
为每个设备提供局部缓存或缓冲机制,可以减少对总线的直接访问次数,从而降低冲突的可能性。当一个设备需要与总线上的另一个设备通信时,它可以先将数据写入自己的缓存,然后再由缓存同步到总线上。
使用更宽的总线
增加总线的宽度可以允许在同一时间内传输更多的数据,从而减少对总线的需求,降低冲突的可能性。
实施步骤
评估系统需求:确定哪些类型的设备将使用总线,以及它们对带宽的需求。
选择仲裁策略:根据设备的优先级和带宽需求选择合适的仲裁策略。
设计硬件:根据选定的策略设计硬件,包括添加仲裁器、缓存和适当的控制逻辑。
测试和优化:实施后进行系统测试,根据测试结果调整策略或硬件设计。
通过上述方法,可以有效管理和减少多端口系统中的总线冲突问题,提高系统的稳定性和性能。
Cache Tag(缓存标记)是高速缓存(Cache)中的关键组成部分,用于存储数据在主存中的地址信息,以便快速定位数据位置。
核心功能
Tag字段存储了主存中数据的地址信息,当CPU访问主存时,首先通过Tag字段判断数据是否存在于Cache中。若存在,则直接从Cache读取;若不存在,则访问主存。
结构组成
应用场景
现代处理器通常采用多级Cache结构(如L1、L2、L3),其中Tag与Data共同构成Cache Line,用于快速访问和存储数据。例如,ARMv8-A架构的处理器包含独立的I-Cache和D-Cache,分别存储指令和数据。
Cache Tag 仿真代码
FIXME: 该代码会 coredump 。
1 | #ifndef CACHE_SIMULATOR_H |
1 | #include "cache_simulator.h" |
1 | #include "cache_simulator.h" |
1 | # 编译器配置 |
tasks
1 | /** |
方案一
1 | /** |
方案二
1 | /** |
方案三
1 | /** |
IO 模型通常按两条维度划分:
阻塞 vs 非阻塞
同步 vs 异步
同步(Synchronous):调用者要等待操作完成才能继续。
异步(Asynchronous):调用者发起操作后,不需要等待,操作完成时通过回调、信号、事件通知等告知结果。
⚡ 关键:异步 IO 的核心是不阻塞当前线程,而结果通知是通过事件或回调完成的。
Linux 下主要有四种机制:
POSIX AIO(aio_* 系列)
系统调用:aio_read(), aio_write()
完成通知方式:
使用场景:文件 IO,可以在后台发起读写请求,主线程继续工作。
⚠️ 目前性能不如 epoll + 线程池模拟异步。
信号驱动 IO(SIGIO)
I/O 多路复用(select, poll, epoll)
本质是非阻塞 + 事件通知
Epoll + 非阻塞 IO 可以模拟高效的异步 IO
适合网络服务器、socket 编程
典型流程:
Linux AIO(io_uring)
特性 / 机制 | POSIX AIO | epoll + 非阻塞 IO | io_uring | 信号驱动 IO (SIGIO) |
---|---|---|---|---|
类型 | 异步文件 IO | 多路复用 + 非阻塞网络 IO | 高性能异步 IO | 异步事件通知 |
支持对象 | 文件 | 文件描述符(socket、管道等) | 文件 + 网络 + 其他 IO | 文件描述符(socket、pipe) |
用户态/内核态 | 系统调用提交,内核异步处理 | 用户态轮询/等待事件,内核检查 fd | 用户态 SQ + 内核 CQ | 用户注册 fd,内核通过信号通知 |
提交方式 | aio_read/aio_write | 写入 fd 并通过 epoll_wait 检查 | 写入 SQ(批量可提交) | 设置 O_ASYNC + F_SETOWN |
完成通知 | 信号 / 回调 / aio_error轮询 | epoll_wait 返回就绪事件 | 完成队列 (CQ),阻塞或非阻塞读取 | 信号处理函数 (SIGIO) |
性能 | 中等,系统调用多 | 高,单线程处理大量 fd | 很高,几乎零系统调用,批量提交 | 较低,信号开销大,适合少量 fd |
编程复杂度 | 中等偏复杂 | 中等,需要状态机处理 | 高,但灵活,可批量和链式操作 | 高,信号处理函数限制多,必须信号安全 |
适合场景 | 文件异步读写 | 高并发网络服务器 | 高性能文件和网络 IO | 少量异步事件或控制信号触发场景 |
io_uring 是 Linux 内核自 5.1 版本引入的一个异步 I/O 框架,它提供了 低延迟、高吞吐的异步文件和网络 I/O。它的特点是:
简单理解:它把传统阻塞 I/O 的 “系统调用来回” 改成了 共享环形队列 + 异步通知。
1 | sudo apt update |
检查安装路径
1 | ls /usr/include/liburing.h |
1 | git clone https://github.com/axboe/liburing.git |
Submission Queue(SQ)
字段 | 作用 |
---|---|
opcode |
I/O 类型,如读、写、fsync、accept、sendmsg |
fd |
文件描述符 |
off |
偏移量(文件 I/O) |
addr |
用户缓冲区地址 |
len |
I/O 数据长度 |
flags |
请求标志,如 IOSQE_FIXED_FILE 、IOSQE_IO_LINK |
Completion Queue(CQ)
字段 | 作用 |
---|---|
res |
I/O 结果,成功为正数(读写字节数),失败为负错误码 |
user_data |
用户自定义数据,方便识别请求 |
1 | +-----------+ +-----------+ |
注意:
传统异步 I/O(比如 Windows IOCP)必须注册回调或事件句柄,因为内核不会给你“主动通知”。
Linux io_uring 的设计哲学是:
所以你看到 io_uring 的官方示例都是 顺序写代码,但是仍然是异步 I/O,因为:
1 | #include <stdio.h> |
这个例子展示了 最基本的异步文件读取:
要区分 操作系统级别的异步 I/O 和 asio 的抽象,因为 asio 并不是单一机制,而是根据平台选择最优实现。具体分析如下:
asio 提供 异步接口(async_read, async_write 等),程序不会阻塞线程
内部实现方式根据平台不同而不同:
平台 | 异步方式 |
---|---|
Linux | 基于 epoll / io_uring / AIO,是真正的内核异步 I/O(零拷贝,内核通知完成) |
Windows | 基于 IOCP(I/O Completion Ports),内核异步 I/O |
Mac / BSD | 基于 kqueue / poll,有些情况下是模拟异步(多线程或事件轮询) |
要确认 asio 在你的 Linux 机器上选择了哪种底层 I/O 机制,可以按下面几个方法操作:
asio 有两个主要 I/O 后端:
在 编译时,asio 会检测系统特性:
在你的 asio 头文件中,可能有如下宏:
1 | #if defined(BOOST_ASIO_HAS_IOURING) |
这些宏在 boost/asio/detail/config.hpp 或 asio/config.hpp 中定义,表示底层机制。
asio 本身没有公开 API 显示底层 I/O 类型,但可以通过系统调用监控判断:
使用 strace 观察程序 I/O:
1 | strace -f ./your_program 2>&1 | grep io_uring |
对 epoll,strace 会显示 epoll_create1 / epoll_ctl / epoll_wait
类型 | 描述 | asio 中的表现 |
---|---|---|
阻塞 I/O | 调用时线程被挂起,等待 I/O 完成 | 不使用,线程会被阻塞,TBB 线程占用 |
模拟异步 | 内核不支持真正异步,用线程轮询或线程池实现 | Mac/BSD 某些场景下可能是模拟 |
真正异步 I/O | 内核支持,操作提交后立即返回,完成由内核通知 | Linux/io_uring、Windows IOCP 就是真正异步 |
malloc 是线程安全的,但有一些细节需要注意。
线程安全:多个线程同时调用 malloc/free 不会破坏堆管理结构,也不会导致内存管理崩溃。
实现方法:
glibc malloc 在内部使用 锁(mutex 或 spinlock) 保护全局堆管理数据结构
不同线程同时申请或释放内存,内核保证堆表一致
性能问题
多线程频繁 malloc/free,锁竞争可能成为瓶颈
对性能敏感的程序可能使用:
线程本地缓存(thread-local cache) 的 jemalloc / tcmalloc
避免全局锁竞争
信号处理上下文不安全
虽然线程安全,但 malloc 在信号处理函数里不安全
原因:
信号可能打断正在执行的 malloc
malloc 内部锁可能被持有 → 再次调用可能死锁
所以 signal handler 中不能直接调用 malloc/free
特性 | malloc/glibc |
---|---|
多线程调用安全 | ✅ 是线程安全的 |
信号处理函数调用安全 | ❌ 不安全 |
性能 | 可能锁竞争,需要优化(jemalloc/tcmalloc) |
malloc / free 内部会修改全局堆管理结构(如 free list)
如果信号到达时主程序正在调用 malloc 或 free,信号处理函数里再次调用 malloc/free → 堆数据结构可能被破坏,
可能导致崩溃或内存泄漏
虽然线程安全,但 malloc 在信号处理函数里不安全
原因:
所以 signal handler 中不能直接调用 malloc/free
信号处理函数里只做:
之后由主程序在安全上下文处理 malloc/free 或其他复杂操作
锁、malloc 等不能在信号处理函数里用。这里涉及到 异步信号安全 (async-signal-safe) 的概念。
当一个信号到达进程时,内核 异步中断当前执行流,立即跳转到信号处理函数执行。
这意味着:
当前线程可能 正在持有锁(mutex、spinlock 等)
当前线程可能 正在使用 malloc/free,操作堆数据结构
锁(mutex 等)
如果信号处理函数里调用 pthread_mutex_lock():
线程可能已经在信号到达前持有这个锁
信号处理函数再次尝试加锁 → 死锁
malloc / free
malloc 内部会修改全局堆管理结构(如 free list)
如果信号到达时主程序正在调用 malloc 或 free,信号处理函数里再次调用 malloc/free → 堆数据结构可能被破坏
可能导致崩溃或内存泄漏
POSIX 定义了一组 “异步信号安全函数”(async-signal-safe functions)
信号处理函数中 只允许调用这些函数
常用安全函数示例:
_exit()
write()(低级系统调用,不会锁堆)
sig_atomic_t 类型变量赋值
总结
函数类型 | 可在信号处理函数里用? | 原因 |
---|---|---|
pthread_mutex_lock | ❌ | 可能已持锁 → 死锁 |
malloc/free | ❌ | 可能正在操作堆 → 数据结构破坏 |
write(fd, buf, n) | ✅ | 系统调用,不会破坏用户态结构 |
_exit() | ✅ | 安全终止进程 |
核心思想:信号是异步的,中断当前执行流,调用非 async-signal-safe 函数可能破坏正在执行的操作,导致不可预测的行为。