Skip to content

操作系统:把“程序在等什么”和“资源归谁”讲清楚 ​

一个 Go 服务开了十万条连接,不等于开了十万个内核线程;删除了日志文件,磁盘空间也未必马上回来;调用 write 成功,更不等于数据已经持久保存。这些现象背后都有一个共同问题:应用看到的对象,与内核实际管理的对象,不是一一对应的。

本文用 Linux 把这层关系展开:谁执行代码,地址如何对应内存,文件描述符引用什么,I/O 如何等待,以及“零拷贝”究竟省掉哪一步。进程与线程是广泛存在的概念,epoll、sendfile 等接口及本文实验则是 Linux 语境,不冒充跨平台保证。实验使用 Linux 6.18.44、GCC 14.2.0;Go 相关解释核对了 Go 1.27.2 文档和固定版本源码。

先分清三种动作:进入内核、等待资源、换一个任务执行 ​

普通应用运行在受限制的用户态。读文件、创建连接等操作通过系统调用请求内核执行;内核检查参数和权限,再访问相应资源。一次系统调用可能很快返回,也可能等待数据。等待期间,调度器可以让其他可运行的任务使用 CPU。Linux 系统调用概览、sched(7)

动作发生了什么不应混淆成什么
用户态进入内核态同一线程请求内核服务或处理异常不一定切换到另一线程
线程上下文切换保存当前任务执行状态,恢复另一个任务不一定伴随不同进程地址空间切换
goroutine 切换Go 运行时安排另一条 goroutine 执行不一定发生内核线程切换
I/O 等待当前操作的条件尚未满足不表示 CPU 必须一直空转

这几种成本可以相互关联,但不能拿“系统调用次数”直接代替“上下文切换次数”。后面的 I/O 模型正是在安排谁等待、怎样被唤醒。Go 1.27.2 调度器说明

进程、线程、协程:隔离什么,共享什么 ​

进程给资源划边界,线程在边界内执行 ​

进程有自己的地址空间等资源视图;同一进程中的 POSIX 线程共享地址空间、打开的文件描述符等资源,每个线程又有自己的栈、寄存器状态、线程 ID 和信号屏蔽字。共享让通信方便,也要求同步,否则两个线程可能同时改坏同一份状态。pthreads(7) DESCRIPTION

对象调度方与共享关系适合解决的问题需要付出的代价
进程内核管理;通常独立地址空间故障隔离、权限和资源边界进程间通信、独立资源维护
内核线程内核调度;同进程线程共享内存利用多核、阻塞调用与并行计算栈、内核调度、同步与竞争
用户态协程用户态库或运行时调度用较低管理成本组织大量可暂停任务仍依赖承载线程;阻塞与抢占能力看实现
Go goroutineGo 运行时在 OS 线程上调度并发请求、流水线、后台任务栈和运行时状态仍占内存,生命周期仍需管理

协程不是一种统一的操作系统对象。有的实现只能协作让出,有的运行时支持抢占;不能把“协程绝不会被抢占”套到 goroutine。Go 的 G 表示 goroutine,M 表示 OS 线程,P 表示执行用户 Go 代码所需的调度资源;GOMAXPROCS 约束这类并行执行资源,不是进程最多能创建的线程数。Go 1.27.2 runtime/HACKING.md

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart TB
    P["进程:地址空间与文件描述符等资源"] --> H["共享堆、全局数据"]
    P --> T1["内核线程 M1:寄存器、线程栈"]
    P --> T2["内核线程 M2:寄存器、线程栈"]
    R["Go 运行时:可运行 goroutine 队列"] --> T1
    R --> T2
    T1 --> G1["当前执行 G1:自己的 Go 栈"]
    T2 --> G2["当前执行 G2:自己的 Go 栈"]
    G1 --> H
    G2 --> H
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart TB
    P["进程:地址空间与文件描述符等资源"] --> H["共享堆、全局数据"]
    P --> T1["内核线程 M1:寄存器、线程栈"]
    P --> T2["内核线程 M2:寄存器、线程栈"]
    R["Go 运行时:可运行 goroutine 队列"] --> T1
    R --> T2
    T1 --> G1["当前执行 G1:自己的 Go 栈"]
    T2 --> G2["当前执行 G2:自己的 Go 栈"]
    G1 --> H
    G2 --> H

图只表达共享与承载关系,省略 P 的详细获取、系统栈和调度队列实现。goroutine 的堆数据可以共享,自己的栈也不等于其中所有指针指向的数据都私有。

fork 后“值相同”,不代表普通内存仍是同一份可写状态 ​

Linux 的 fork 创建子进程,父子最初看到相同内容,普通私有内存后续写入彼此隔离。实现使用写时复制:可以先共享底层页,发生写入时再为私有修改安排副本。它降低了立即复制所有数据的成本,却不让后续写入免费。fork(2) DESCRIPTION、NOTES

实验在 fork 前设 value=1,子进程改成 7,通过管道把值报告给父进程。随后再创建一个 pthread,修改共享全局变量,主线程 join 后读取:

c
int value = 1;
pid_t pid = fork();
if (pid == 0) {
    value = 7;
    write(p[1], &value, sizeof value); // 子进程通过管道报告
    _exit(0);
}
// 父进程 read 收报告,再 waitpid 回收子进程
pthread_create(&t, NULL, thread_fn, NULL); // thread_fn 修改共享全局变量
pthread_join(t, NULL);

这里抽出关键操作;所有返回值检查和管道关闭见完整程序。实际输出:

text
fork: parent=1 child=7
thread after join: shared=9

第一行是普通私有内存的隔离,第二行是同进程线程共享内存。第二个实验刻意在 pthread_join 后读取,避免把没有同步的并发访问当成有效演示。显式的共享映射则可以让不同进程共享数据,不能把“进程隔离”误解成进程间永远不能共用内存。pthread_join(3)、mmap(2)

创建进程也要认识两个后续动作:execve 替换当前进程的程序映像,不是再额外创建一个进程;父进程用 wait 系列回收子进程的退出状态,已经退出但尚未回收的子进程可能处于 zombie 状态。Zombie 不在继续运行,但保留退出信息等资源。execve(2)、wait(2) NOTES

虚拟内存:地址、物理页和文件不是同一件东西 ​

程序访问的是虚拟地址。页表描述虚拟页如何映射到物理页及其权限,处理器通常通过 TLB 缓存地址转换结果。一个进程申请了很大一段虚拟地址,不代表此刻已经拥有同样大小的常驻物理内存;真正访问时可能触发缺页处理,建立映射或载入所需页面。Linux 内存概念:Virtual Memory、Page Tables、Demand Paging

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    V["进程虚拟地址"] --> T{"地址转换与权限检查"}
    T -->|映射可用| P["物理页"]
    T -->|需要缺页处理| K["内核:查映射类型和访问权限"]
    K --> Z["匿名页:分配或准备零页"]
    K --> F["文件页:从页缓存或存储取得"]
    K --> E["非法访问:报告错误或信号"]
    Z --> P
    F --> P
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    V["进程虚拟地址"] --> T{"地址转换与权限检查"}
    T -->|映射可用| P["物理页"]
    T -->|需要缺页处理| K["内核:查映射类型和访问权限"]
    K --> Z["匿名页:分配或准备零页"]
    K --> F["文件页:从页缓存或存储取得"]
    K --> E["非法访问:报告错误或信号"]
    Z --> P
    F --> P

缺页不等于磁盘读取。首次分配匿名页、写时复制都可能引起不需要存储 I/O 的缺页;需要从存储取数据的缺页则可能很慢。把“page fault 多”直接解释成“磁盘坏了”会走错方向。Linux Demand Paging、getrusage(2) 的 ru_minflt、ru_majflt

观察或操作能说明什么不能直接说明什么
虚拟地址空间大小当前映射或保留的地址范围实际占用多少独占物理内存
RSS当前驻留物理内存的页规模所有页面是否仅由这个进程独占
minor fault不需要存储 I/O 的缺页计数零成本
major fault需要存储 I/O 的缺页计数每次耗时相同
mmap建立一个地址范围与文件或匿名内存的映射所有页已经读入、内容已经持久化

RSS 等进程指标的 Linux 定义见 proc_pid_status(5)。共享页面会使“直接把所有进程 RSS 加起来”与物理占用不一致。

MAP_PRIVATE 和 MAP_SHARED,差在修改往哪里去 ​

文件映射可以把读写文件转成对映射地址的访问。MAP_PRIVATE 的修改是私有写时复制,不回写原文件;MAP_SHARED 的修改可被其他共享映射观察,并传播到对应文件,时机和持久化要另行处理。mmap(2)

实验先创建一个首字节为 A 的临时文件。私有映射改成 P,用 pread 读文件;随后共享映射改成 S,调用 msync(MS_SYNC) 后再读:

c
char *p = mmap(NULL, page, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
p[0] = 'P';
pread(fd, &c, 1, 0); // 文件仍为 A
munmap(p, page);
p = mmap(NULL, page, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
p[0] = 'S';
msync(p, page, MS_SYNC);
pread(fd, &c, 1, 0); // 此时读到 S

这是完整程序的操作摘录,省略了逐步错误检查。实际输出:

text
MAP_PRIVATE: mapping=P file=A
MAP_SHARED: mapping=S file=S

这种方式适合随机访问大文件、共享数据等场景,成本则包括映射管理、缺页、访问权限和并发同步。MAP_SHARED 只解决“这份修改能共享”,没有自动提供互斥锁;一方截断仍被映射的文件,也可能使另一方访问时收到 SIGBUS。mmap(2) NOTES、ERRORS

页缓存让读取更快,也把“写成功”和“落盘”分开 ​

Linux 可以把文件页保留在页缓存中。普通文件读取命中缓存时,不需要再次从块设备读取;写入可能先修改缓存中的脏页,再由后续回写送往存储。write 成功与持久化完成因此不是同一个边界。Linux Page Cache、Writeback

需要持久化时,应按文件系统和应用协议使用 fsync、fdatasync 等接口并检查错误。对新建文件或重命名后的目录项,还可能需要同步目录,不能以“文件 fsync 了”代替目录项的持久化要求。fsync(2) DESCRIPTION。本实验只验证映射可见性,没有进行掉电故障注入,不能证明某种存储设备的断电保证。

文件描述符:整数只是入口,打开状态在另一层 ​

open 返回的 fd 是进程文件描述符表里的索引。表项指向内核的 open file description,那里保存文件偏移、文件状态标志等;再往下才是对应的文件对象。套接字、管道等也使用文件描述符接口。open(2) DESCRIPTION、Open file descriptions

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A["进程 fd=3"] --> O["打开文件描述<br/>offset、status flags"]
    B["dup 得到 fd=4"] --> O
    C["独立 open 得到 fd=5"] --> O2["另一份打开文件描述<br/>独立 offset"]
    O --> F["同一个文件对象"]
    O2 --> F
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A["进程 fd=3"] --> O["打开文件描述<br/>offset、status flags"]
    B["dup 得到 fd=4"] --> O
    C["独立 open 得到 fd=5"] --> O2["另一份打开文件描述<br/>独立 offset"]
    O --> F["同一个文件对象"]
    O2 --> F

dup 复制入口,不会复制一份独立文件偏移。两次独立 open 则通常有各自的打开文件描述。实验文件是 ABC,依次从原 fd、dup fd、独立 open fd 读取一个字节:

c
int alias = dup(fd);
int independent = open(path, O_RDONLY | O_CLOEXEC);
read(fd, &a, 1);
read(alias, &b, 1);
read(independent, &c, 1);

上面省略的返回值检查保留在附录。实际输出:

text
offsets: first=A dup=B independent=A

原 fd 读过 A 后,dup fd 从同一偏移继续读出 B;独立 open 仍从头读出 A。fork 后父子继承的对应 fd 也引用同一个打开文件描述,所以“父子内存隔离”并不意味着文件偏移也隔离。dup(2)、fork(2)

删除名字、关闭入口、销毁对象,是三件事 ​

接着删除文件路径,再用仍打开的 fd 读取:

c
CHECK(unlink(path) == 0);
CHECK(read(fd, &c, 1) == 1);
text
after unlink: next=C path_exists=no

unlink 去掉目录中的名字。若文件已经没有硬链接,但仍有进程持有打开引用,内容仍可通过这个引用访问;最后相关引用释放后才具备回收空间的条件。这就是“删了仍打开的日志,空间没立刻回来”的常见原因。unlink(2)

close 释放当前 fd。号码可以被很快重用,所以把裸 fd 整数保存很久、再由另一线程随意关闭,可能关到后来打开的新对象。关闭资源应有明确所有者,也不要见到 close 报错就无条件重试同一个数字;Linux 文档专门提醒了 fd 重用风险。close(2) NOTES

FD_CLOEXEC 决定成功 exec 时是否关闭该描述符。多线程程序要避免“先 open,再设置 CLOEXEC”之间的竞争窗口,可在创建时使用 O_CLOEXEC 等原子选项。文件状态标志如 O_NONBLOCK 与描述符标志如 FD_CLOEXEC 处于不同层,dup 后的共享关系也不同。open(2) 的 O_CLOEXEC、dup(2)

I/O 模型:不忙等,也不等于“不需要等待” ​

先以管道或套接字读取为例。阻塞 fd 上没有数据时,读操作可能等待;非阻塞 fd 上暂时无数据时,返回 EAGAIN 或相应的 EWOULDBLOCK。数据还可能只到了一部分,所以“读就绪”不等于“整条业务消息已完整”。指定行为见 read(2)

模型程序怎样写谁在等待常见成本
阻塞 I/O调用 read,条件满足才返回调用它的线程大量等待线程的资源与调度成本
非阻塞 I/Oread 立即返回结果或 EAGAIN应用自己决定何时重试无策略反复重试会浪费 CPU
I/O 多路复用先集中等待就绪,再读写epoll 等接口等待多个 fd维护兴趣集合、处理事件与连接状态
完成通知式异步 I/O提交操作,之后收完成结果内核或其他执行设施完成操作生命周期、取消和缓冲区管理更复杂

epoll 是就绪通知机制,最后一行是另一种接口模型。两者不能因为都能“处理许多连接”就划等号。epoll(7)、aio(7)

epoll 的三个入口与两种触发方式 ​

epoll_create1 创建实例,epoll_ctl 添加、修改或删除感兴趣的 fd,epoll_wait 返回当前就绪事件。实例维护兴趣列表和就绪列表,不需要应用每轮把所有连接逐一 read 一次来找谁有数据。epoll(7) DESCRIPTION

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A["应用:登记关注读写的 fd"] -->|epoll_ctl| I["内核兴趣集合"]
    I --> R["资源状态变化:形成就绪事件"]
    R -->|epoll_wait| E["应用事件循环"]
    E --> IO["非阻塞 read 或 write"]
    IO -->|EAGAIN| E
    IO -->|完整消息| B["业务处理"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A["应用:登记关注读写的 fd"] -->|epoll_ctl| I["内核兴趣集合"]
    I --> R["资源状态变化:形成就绪事件"]
    R -->|epoll_wait| E["应用事件循环"]
    E --> IO["非阻塞 read 或 write"]
    IO -->|EAGAIN| E
    IO -->|完整消息| B["业务处理"]
触发方式何时继续提醒程序最容易犯的错
LT,水平触发,默认只要条件仍满足,就可以继续报告每次只处理很少数据,引发大量重复唤醒
ET,边缘触发状态变化时报告;不能依赖未读数据持续提醒读一点就回去等,剩余数据一直没人处理

ET 的通常写法是使用非阻塞 fd,持续读到 EAGAIN,或把尚未处理完的任务明确留在应用自己的就绪队列中。后者可以兼顾公平性,否则一个总有数据的连接可能长期占用事件循环。epoll(7) Level-triggered and edge-triggered、Possible pitfalls

实验向非阻塞管道写入 AB,收到一次 ET 事件后只读一个字节,再做零超时 epoll_wait:

c
struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = p[0]};
epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev);
write(p[1], "AB", 2);
epoll_wait(ep, &out, 1, 1000);
read(p[0], b, 1);                  // 只取 A
int count = epoll_wait(ep, &out, 1, 0);
read(p[0], b, 1);                  // 仍能直接取 B

摘录省略了创建、错误检查和收尾,完整程序逐步断言结果:

text
empty nonblocking pipe: EAGAIN=yes
ET first read=A
ET without new write: events=0
ET drain remaining=B
all writers closed: read=0 (EOF)

事件数量为 0,并不表示管道里已经没有数据,接下来的直接 read 仍读出 B。继续读到 EAGAIN 才知道此刻已读空。最后关闭所有写端,缓冲也耗尽后读到 0,才表示 EOF。EAGAIN 是“现在没有”,EOF 是“这个流到头了”,业务不能把二者都当成正常断开。pipe(7)

epoll 不会把普通磁盘读变成异步操作 ​

许多常规文件不能加入 epoll,epoll_ctl 会以 EPERM 拒绝不支持的目标类型。即使某类接口报告“可读”,后续操作的具体开销仍取决于对象。讨论 epoll 扩展性时,应以网络套接字、管道等可等待对象为前提,不要套成“Linux 所有 I/O 都用 epoll 就不会阻塞”。epoll_ctl(2) ERRORS

Go 网络代码常写成顺序的 conn.Read。底层若需等待,运行时可以挂起这条 goroutine,借助网络轮询器等数据可用后再恢复;这不要求每条等待连接独占一个 OS 线程。普通文件、cgo 或其他阻塞系统调用又可能走不同路径,不能说“Go 的所有阻塞都不占线程”。Go 1.27.2 runtime/netpoll_epoll.go、internal/poll/fd_unix.go

零拷贝:先明确省的是哪一次复制 ​

文件发往网络的普通路径可能是:文件页进入页缓存,read 把数据复制到用户缓冲区,write 再把用户缓冲区数据交给内核发送路径,设备最后传输数据。真实路径还受缓存命中、驱动、DMA、页引用和硬件能力影响,不能把一张示意图当作所有机器固定四次复制的证明。sendfile(2)、Linux DMA 指南

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart TB
    F["文件页或页缓存"] -->|普通 read:复制| U["用户缓冲区"]
    U -->|普通 write:交给内核| S["套接字发送路径"]
    F -->|sendfile:不经过用户缓冲区搬运| S
    S --> N["设备或其他接收端"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart TB
    F["文件页或页缓存"] -->|普通 read:复制| U["用户缓冲区"]
    U -->|普通 write:交给内核| S["套接字发送路径"]
    F -->|sendfile:不经过用户缓冲区搬运| S
    S --> N["设备或其他接收端"]
机制主要省掉什么不能承诺什么
mmap 后读取映射避免额外 read 到独立用户缓冲区的那一步映射、缺页、之后的发送都免费
sendfile输入 fd 到输出 fd 的传输不经用户态中转底层没有任何内存复制或 DMA
splice通过管道在 fd 间搬运,避免用户态中转任意两个 fd 都支持;至少一端须为管道
Go io.Copy统一复制入口,可能利用类型的专门快路径任意 Reader/Writer 组合都一定零拷贝

splice 的条件见 splice(2)。io.Copy 会优先调用源的 WriterTo,否则尝试目标的 ReaderFrom;具体类型再决定是否使用系统快路径。这是 API 的分派规则,不能靠函数名推断实际内核调用。io.Copy

实验把三字节临时文件用 sendfile 发给本地 socketpair:

c
off_t offset = 0;
CHECK(sendfile(s[0], fd, &offset, 3) == 3);
text
sendfile: received=xyz offset=3

它证明这个输入、输出组合传输了三字节,且显式偏移前进到 3;没有证明底层复制次数为零。真实大文件程序还必须处理短传输、EAGAIN、中断及目标关闭,不能把教学里的三字节单次调用当作生产发送循环。sendfile(2) RETURN VALUE、NOTES

如果应用要压缩、转换或在用户态加密内容,就需要接触或生成新的字节,原有直接传输路径未必还能用。支持内核 TLS 等专门设施的系统又可能有其他路径;“加了 TLS 一定不能零拷贝”和“用了 sendfile 一定全程零拷贝”都缺少实现条件。选择前应检查实际类型、内核路径和测量目标。

信号:异步通知,不是可以随意执行代码的回调 ​

信号用于通知进程或线程发生了某类事件。某个信号的处理方式可以是默认动作、忽略或安装处理函数,但 SIGKILL、SIGSTOP 不能被捕获、阻塞或忽略。标准信号通常不排队,多个相同信号在被阻塞期间到达,不能据此统计精确事件次数。signal(7)

信号常见用途或默认含义实际设计边界
SIGINT终端中断服务可转成有界的清理流程
SIGTERM请求终止允许处理,不保证进程立即退出
SIGKILL强制终止没有应用清理机会
SIGCHLD子进程状态变化需要配合 wait 等检查状态
SIGPIPE向已关闭读端的管道等写入处理方式受语言运行时影响,须查实际接口

C 的信号处理函数可能打断正在执行的普通代码;如果再调用使用相同内部锁或状态的库函数,就可能死锁或破坏状态。标准只允许其中一组异步信号安全操作,printf 不是通用安全选择。signal-safety(7)

本实验处理函数只设置 volatile sig_atomic_t 标志,raise(SIGUSR1) 返回后由普通代码打印:

c
static volatile sig_atomic_t notified = 0;
static void handler(int sig) { (void)sig; notified = 1; }
text
signal handler flag=1

Go 服务通常通过 os/signal.Notify 或 NotifyContext 把信号转换为普通 goroutine 可处理的通知,不要照搬 C 的处理函数模型。NotifyContext 返回的 stop 函数需要调用,以释放注册资源,并可能恢复该信号的默认行为。Go os/signal、NotifyContext

收到退出信号后,一个可控的流程是停止接新任务、通知已有工作取消或收尾、等待到截止时间,然后退出。这是设计建议;信号本身不会替你关闭全部业务任务,也不能让不响应取消的代码自动变得可取消。

上下文切换:有开销,但次数不是性能结论 ​

线程切换要保存和恢复执行状态。切到另一地址空间时,还涉及相应的地址转换环境;代码与数据工作集变化也可能影响缓存局部性。具体成本与处理器、内核、工作集和线程关系有关,因此不能给一个“线程切换永远 X 纳秒”的通用数字。Linux 调度说明、Linux 地址转换概念

getrusage 区分主动上下文切换和非主动上下文切换:前者例如等待资源而让出,后者例如时间片或更高优先级任务导致被切走。它们能说明调度活动,却不能单独说明“锁竞争严重”或“服务一定慢”。getrusage(2) 的 ru_nvcsw、ru_nivcsw

实际排查可以把问题拆开:

  • CPU 真在做计算,还是因为频繁轮询把一个核用满?结合调用栈看热点。
  • 可运行任务很多,还是多数任务在等 I/O?结合运行队列、线程状态和请求延迟看。
  • 唤醒很频繁但每次只做很少工作?考虑合理批处理,但同时检查尾延迟。
  • goroutine 很多但 OS 线程少?先看 goroutine 等待原因,不能用线程数量代替 goroutine 状态。

这些是从前述机制得到的检查路线。批量越大不一定越好,等待线程越少也不一定越快;真正要优化的是端到端吞吐、延迟和资源消耗。

依据与本次验证 ​

本文主要引用 Linux man-pages:intro(2)、pthreads(7)、pthread_join(3)、fork(2)、execve(2)、wait(2)、mmap(2)、open(2)、dup(2)、unlink(2)、close(2)、read(2)、pipe(7)、epoll(7)、epoll_ctl(2)、sendfile(2)、splice(2)、signal(7)、signal-safety(7)、fsync(2)、getrusage(2)、sched(7)、proc_pid_status(5)、aio(7)。对应结论旁有具体页面链接。虚拟内存和 DMA 另据上文 Linux 内核官方文档;Go 部分采用固定 go1.27.2 源码与同版本包文档。

本次在 Linux/amd64 上实际编译运行附录 C 程序,启用 -Wall -Wextra -Werror,所有检查通过。验证了 fork 私有变量、线程共享、dup 偏移、unlink 后读取、两种 mmap、非阻塞管道、epoll ET 未读尽行为、自发信号和 sendfile。没有测硬件复制次数、调度耗时、磁盘掉电一致性或跨平台行为。

尝试用 strace 跟踪这个自建程序时,当前环境拒绝了 ptrace:

text
strace: test_ptrace_get_syscall_info: PTRACE_TRACEME: Operation not permitted
strace: ptrace(PTRACE_TRACEME, ...): Operation not permitted

因此没有宣称取得系统调用跟踪,也没有尝试修改权限或绕过限制。程序自己的输出已经取得;接口语义依据官方手册。

完整可复现代码 ​

保存为 lab.c,在一个可写的临时工作目录编译运行。程序只修改自己创建的临时文件,只向自己发送 SIGUSR1,不修改系统服务。

sh
gcc --version
uname -srmo
gcc -std=c11 -Wall -Wextra -Werror -O2 -pthread lab.c -o lab
./lab
# 可选:受 ptrace 权限限制,本次该命令未成功
strace -f -o trace.log \
  -e trace=clone,clone3,openat,dup,read,write,unlink,mmap,msync,epoll_ctl,epoll_wait,sendfile \
  ./lab

示例为小规模受控实验:短管道读写和三字节 sendfile 直接断言字节数,失败立即退出。生产代码应实现完整的短读写、中断和错误清理循环,不宜把这里的断言当成通用 I/O 封装。

lab.c ​

代码块 · 91 行 · 点击收起 / 展开
c
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/mman.h>
#include <sys/sendfile.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <pthread.h>

#define CHECK(x) do { if (!(x)) { fprintf(stderr,"failed: %s at line %d: %s\n",#x,__LINE__,strerror(errno)); exit(1); } } while(0)
static void fd_demo(void) {
 char path[]="./fd-lab-XXXXXX",a=0,b=0,c=0;
 int fd=mkstemp(path); CHECK(fd>=0);
 CHECK(write(fd,"ABC",3)==3); CHECK(lseek(fd,0,SEEK_SET)==0);
 int alias=dup(fd); CHECK(alias>=0);
 int independent=open(path,O_RDONLY|O_CLOEXEC); CHECK(independent>=0);
 CHECK(read(fd,&a,1)==1); CHECK(read(alias,&b,1)==1);
 CHECK(read(independent,&c,1)==1);
 printf("offsets: first=%c dup=%c independent=%c\n",a,b,c);
 CHECK(unlink(path)==0);
 CHECK(read(fd,&c,1)==1); printf("after unlink: next=%c path_exists=%s\n",c,access(path,F_OK)==0?"yes":"no");
 CHECK(close(independent)==0); CHECK(close(alias)==0); CHECK(close(fd)==0);
}
static void memory_demo(void) {
 char path[]="./map-lab-XXXXXX",c=0;
 int fd=mkstemp(path); CHECK(fd>=0); CHECK(unlink(path)==0);
 long page=sysconf(_SC_PAGESIZE); CHECK(page>0); CHECK(ftruncate(fd,page)==0);
 CHECK(pwrite(fd,"A",1,0)==1);
 char *p=mmap(NULL,(size_t)page,PROT_READ|PROT_WRITE,MAP_PRIVATE,fd,0); CHECK(p!=MAP_FAILED);
 p[0]='P'; CHECK(pread(fd,&c,1,0)==1); printf("MAP_PRIVATE: mapping=%c file=%c\n",p[0],c); CHECK(munmap(p,(size_t)page)==0);
 p=mmap(NULL,(size_t)page,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0); CHECK(p!=MAP_FAILED);
 p[0]='S'; CHECK(msync(p,(size_t)page,MS_SYNC)==0); CHECK(pread(fd,&c,1,0)==1);
 printf("MAP_SHARED: mapping=%c file=%c\n",p[0],c);
 CHECK(munmap(p,(size_t)page)==0); CHECK(close(fd)==0);
}
static void epoll_demo(void) {
 int p[2]; CHECK(pipe2(p,O_NONBLOCK|O_CLOEXEC)==0);
 char b[8]; errno=0; ssize_t n=read(p[0],b,sizeof b);
 printf("empty nonblocking pipe: EAGAIN=%s\n",n==-1&&errno==EAGAIN?"yes":"no");
 CHECK(n==-1&&errno==EAGAIN);
 int ep=epoll_create1(EPOLL_CLOEXEC); CHECK(ep>=0);
 struct epoll_event ev={.events=EPOLLIN|EPOLLET,.data.fd=p[0]},out;
 CHECK(epoll_ctl(ep,EPOLL_CTL_ADD,p[0],&ev)==0);
 CHECK(write(p[1],"AB",2)==2); CHECK(epoll_wait(ep,&out,1,1000)==1);
 CHECK(read(p[0],b,1)==1); printf("ET first read=%c\n",b[0]);
 int count=epoll_wait(ep,&out,1,0); CHECK(count==0);
 printf("ET without new write: events=%d\n",count);
 CHECK(read(p[0],b,1)==1); printf("ET drain remaining=%c\n",b[0]);
 errno=0; CHECK(read(p[0],b,sizeof b)==-1&&errno==EAGAIN);
 CHECK(close(p[1])==0); CHECK(read(p[0],b,sizeof b)==0);
 puts("all writers closed: read=0 (EOF)");
 CHECK(close(p[0])==0); CHECK(close(ep)==0);
}
static int shared_value=1;
static void *thread_fn(void *unused) { (void)unused; shared_value=9; return NULL; }
static void process_thread_demo(void) {
 int p[2]; CHECK(pipe(p)==0); int value=1; pid_t pid=fork(); CHECK(pid>=0);
 if(pid==0) { close(p[0]); value=7; if(write(p[1],&value,sizeof value)!=(ssize_t)sizeof value)_exit(2); close(p[1]); _exit(0); }
 CHECK(close(p[1])==0); int child=0,status=0;
 CHECK(read(p[0],&child,sizeof child)==(ssize_t)sizeof child); CHECK(close(p[0])==0);
 CHECK(waitpid(pid,&status,0)==pid); CHECK(WIFEXITED(status)&&WEXITSTATUS(status)==0);
 printf("fork: parent=%d child=%d\n",value,child);
 pthread_t t; CHECK(pthread_create(&t,NULL,thread_fn,NULL)==0); CHECK(pthread_join(t,NULL)==0);
 printf("thread after join: shared=%d\n",shared_value);
}
static volatile sig_atomic_t notified=0;
static void handler(int sig) { (void)sig; notified=1; }
static void signal_demo(void) {
 struct sigaction sa={0}; sa.sa_handler=handler; CHECK(sigemptyset(&sa.sa_mask)==0);
 CHECK(sigaction(SIGUSR1,&sa,NULL)==0); CHECK(raise(SIGUSR1)==0);
 printf("signal handler flag=%d\n",(int)notified);
}
static void sendfile_demo(void) {
 char path[]="./send-lab-XXXXXX",b[4]={0}; int fd=mkstemp(path); CHECK(fd>=0); CHECK(unlink(path)==0);
 CHECK(write(fd,"xyz",3)==3); int s[2]; CHECK(socketpair(AF_UNIX,SOCK_STREAM,0,s)==0);
 off_t offset=0; CHECK(sendfile(s[0],fd,&offset,3)==3); CHECK(read(s[1],b,3)==3);
 printf("sendfile: received=%s offset=%lld\n",b,(long long)offset);
 CHECK(close(s[0])==0); CHECK(close(s[1])==0); CHECK(close(fd)==0);
}
int main(void) {
 setvbuf(stdout,NULL,_IONBF,0);
 process_thread_demo(); fd_demo(); memory_demo(); epoll_demo(); signal_demo(); sendfile_demo();
 return 0;
}

保留各书来源与许可 · 示例运行条件以正文为准