IO + CPU 密集 + IO
tasks
1 | /** |
方案一
1 | /** |
方案二
1 | /** |
方案三
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 函数可能破坏正在执行的操作,导致不可预测的行为。
C++11 引入的存储类型说明符:
thread_local int x = 0;
表示 每个线程都有一份独立的 x,互不干扰。
生命周期:跟普通静态变量类似(全局存活直到线程退出)。
它的实现依赖于 TLS (Thread Local Storage, 线程局部存储) 机制。
在 ELF/Linux 下:
编译器在 .tdata / .tbss 段里为 thread_local 变量分配空间(就像全局变量在 .data / .bss 段里)。
程序加载时,动态链接器(ld.so)会记录这些 TLS 变量的“模板布局”。
每个线程启动时,线程库(glibc/pthread)会:
给这个线程分配一块 TLS 块(通常放在线程栈附近,或者专门的内存页)。
把 .tdata 里的初始值拷贝到这个线程的 TLS 块。
.tbss 部分(未初始化的 thread_local)则清零。
线程访问 thread_local 时,编译器生成的代码会通过 TLS 寄存器(如 x86-64 的 FS/GS 段寄存器)+ 偏移量,找到对应线程的存储单元。
例如 x86-64 Linux 上,errno 就是:
#define errno (*__errno_location())
而 __errno_location() 内部就是通过 %fs:offset 找到 TLS 块里的 errno。
Windows:在 TEB (Thread Environment Block) 里有 TLS 指针,__declspec(thread) 就用它。
Linux/ELF:在每个线程的 TLS 块里(通常分配在线程栈附近的一片内存区域)。访问通过 FS/GS 寄存器。
编译器细节:
GCC/Clang 默认用 “动态 TLS 模型”(访问时通过动态链接器查询 TLS 偏移)。
如果加 -ftls-model=initial-exec,编译器会直接用固定偏移访问 TLS,速度更快(但要求变量在主程序或静态库里)。
1 | #include <iostream> |
输出大致是:
1 | Thread 1: counter = 1 |
说明 counter 在不同线程里独立。
✅ 总结:
thread_local 变量放在 TLS 段,每个线程有自己的拷贝。
访问是通过 线程局部存储寄存器(FS/GS)+ 偏移量 实现的。
存储空间由线程库在创建线程时分配和初始化。
.
├── AT&T Assembly Manual.pdf
├── C++ Primer Plus 第五版中文版.pdf
├── C++.Primer.5th.Edition_2013.pdf
├── CplusplusConcurrencyInAction_PracticalMultithreading.pdf
├── Debug Hacks中文版:深入调试的技术和工具.pdf
├── IB-Host-and-Ports.pdf
├── IBTA-Overview-of-IBTA-Volume-1-Release-1.6-2022-07-15.pdf
├── InifiniBand Guide.pdf
├── LDAP Authentication Guide.pdf
├── Linux Kernel Networking - Implementation and Theory.pdf
├── Linux-UNIX系统编程手册(上、下册).pdf
├── Linux内核设计与实现(第三版中文高清带目录).pdf
├── Linux多线程服务端编程 - 陈硕.pdf
├── Linux多线程服务端编程:使用muduo C++网络库.pdf
├── Linux高性能服务器编程.pdf
├── OKTA Multifactor Authentication (MFA) FAQ.pdf
├── Shell脚本学习指南.pdf
├── TBB
│ ├── Intel TBB.pdf
│ ├── TBBtutorial.pdf
│ ├── intel-tbb.pdf
│ ├── oneTBB-master.zip
│ └── onetbb_developer-guide-api-reference_2021.6-772616-772617.pdf
├── UNIX环境高级编程(第三版).pdf
├── UNIX网络编程卷1:套接字联网API(第3版).pdf
├── ccia_code_samples-master.zip
├── dmtcp
│ ├── Be Kind, Rewind —Checkpoint & Restore Capability for Improving Reliability of Large-scale Semiconductor Design.pdf
│ ├── dmtcp-mug-17.pdf
│ └── plugin-tutorial.pdf
├── gdb-and-assembly.pdf
├── gdb-refcard.pdf
├── gdb.pdf
├── p4-p4v-cheat-sheet_cn.pdf
├── thread-analyzer-193426.pdf
├── 并行编程
│ ├── C++ Concurrency in Action.pdf
│ ├── C++-Concurrency-In-Action-2ed-zh-v0.2.pdf
│ ├── C++并发编程实战(中文版).pdf
│ ├── C++并发编程实战.pdf
│ ├── C++并发编程实战源代码
│ ├── C++并发编程实战源代码.zip
│ ├── Patterns_for_Parallel_Programming.pdf
│ └── 高并发网络模型.png
└── 编译原理(龙书) 第二版.pdf
本文解释利用github pages搭建个人主页/项目主页的方法。
github pages简介:官方链接。
github pages使用了CNAME record技术,参考:链接1、链接2、Custom domains in Github Pages。
注:Read the Docs也是一个很好的搭建个人主页的网站。
有3种类型的 Github Pages 站点(sites):project, user 和 organization 。
Project sites 连接到 github 上特定 project ,比如 Javascript library 或 recipe collection。user 或 organization sites 连接到 github.com 的特定账户。
发布 user site ,你必须创建一个你的个人账户下的一个名为 <username>.github.io 的 repository 。发布 organization site ,你必须创建一个组织所有的名为 <organization>.github.io 的 repository 。除非你使用 custom domain ,否则 user 和 organization sites 将位于 http(s)://<username>.github.io 或 http(s)://<organization>.github.io 。
project site 的源文件存储在作为 project 的相同的 repository 中。除非使用 custom domain , 否则 project sites 将位于 http(s)://<username>.github.io/<repository> 或 http(s)://<organization>.github.io/<repository> 。
有关如何自定义影响您网站的域名的更多信息,参见”About custom domains and GitHub Pages“。
每个 github 账户允许创建 1 个 user 或 organization 站点。无论是被组织还是个人所有,project 站点的个数不限制。
参考官方文档。
例如,你的project站点配置的发布源是gh-pages分支,然后在gh-pages分支上创建了一个about/contact-us.md文件,你将可以在https://<user>.github.io/<repository>/about/contact-us.html访问它。
你也可以使用Jekyll等静态站点生成器来给你的github page配置一个主题。
参考官方文档。
推荐的markdown编辑器:
VSCode markdown插件:
在线表格生成器:可以生成Markdown、Text、HTML、LaTex、MediaWiki格式的表格。
WSL,Windows Subsystem for Linux,是Windows提供的轻量级Linux虚拟机。
安装教程:见链接。
启用systemctl的方法:链接。
替代方法:不需要启动systemctl,因为会比较占用资源,启动也会变慢。可以使用service命令替代。
使用ssh连接到服务器时,需要服务器运行着sshd程序,否则连接不上,会出现”Connection refused“错误。
参考链接。
查看openssh-server有没有安装:
1 | dpkg --list | grep ssh |
注:如果安装了openssh-server,执行which sshd可以看到路径。
WSL默认没有安装openssh-server,安装方法:
1 | sudo apt-get install openssh-server |
启动ssh:
1 | sudo service ssh start |
git push不再支持输入用户名和密码,当提示输入密码时,需要输入personal access token.
步骤1:在github上创建personal access token;
步骤2:在命令行上使用personal access token;
步骤3:为了避免每次都需要输入personal access token,可以将其缓存在git client上:
1 | gh auth login |
注:使用gh命令需要先安装GitHub CLI:
1 | sudo apt-get install gh |
报错解释:
这个报错信息通常出现在使用SSH连接到一个新的主机时。它表示你的计算机无法验证远程服务器的身份,因为服务器的公钥不在你本地计算机的known_hosts文件中。这是SSH为了防止”中间人”攻击而进行的安全检查。
解决方法:
验证指纹信息:你可以查看远程主机的指纹信息,并与服务器gitee.com的公钥指纹进行对比,确保它们匹配。你可以在~/.ssh/known_hosts文件中找到已知主机的公钥指纹。
如果确认指纹正确无误,且你信任这个服务器,可以添加这个主机及其公钥到你的known_hosts文件中,以便SSH不再警告。执行以下命令:
ssh-keyscan -H gitee.com >> ~/.ssh/known_hosts
如果你不想添加到known_hosts文件中,可以在第一次连接时使用ssh -o StrictHostKeyChecking=no来跳过这个检查。但这样做会降低安全性。
如果你是在多个服务器上使用相同的IP地址,并且之前已经添加过这个IP的记录,那么可能是服务器的公钥发生了变化,这种情况下你应该联系服务器管理员确认公钥的变更。
注意:在实际生产环境中,不建议无条件信任新的SSH指纹,除非你完全了解这个服务器的来源和身份。
以下几种静态站点生成器都可以用来搭建个人主页。如果使用除JekyII外的工具,则需要配置Github Actions以构建和发布你的站点。
mkdocs是一个快速的静态网页生成器。
发布个人网站的方法:参见mkdocs-material官网。
Jekyll 是一个静态站点生成器,内置对 GitHub Pages 的支持和简化的构建进程。
RCU(Read-Copy-Update,读-拷贝-更新)是 Linux 内核中一种高效的 并发读-写同步机制,专门用于在多核系统下实现 大量读、少量写 的场景。它的核心思想是:读操作完全无锁,写操作通过复制更新,最后再安全地回收旧版本。
RCU 的核心思想
__rcu它是 RCU 机制中非常关键的一环,用来让 编译器和内核知道某个指针是 RCU 保护的。
__rcu 的定义在 Linux 内核中(以 x86_64 为例):
1 | #define __rcu |
实际上,它 本身对编译器不产生直接影响
主要用途是 标记类型,告诉 Sparse 静态分析工具 或 内核开发者:这个指针受 RCU 保护,不能随意直接读/写
也就是说,__rcu 是 一个注释性质的宏,编译器编译时忽略,但静态分析工具会检查 RCU 访问规则。
__rcu 的作用标记 RCU 保护的指针,常见用法:
1 | struct files_struct { |
含义:
fdt 是 RCU 指针fdt = new_ptr; 或直接解引用 fdt->xxxrcu_assign_pointer(fdt, new_fdt); → 安全更新指针rcu_dereference(fdt) → 安全读取指针__rcu 与编译器和内核__rcu 指针的写操作是否用 rcu_assign_pointerrcu_dereferencercu_assign_pointer 内部会加上 适当的写屏障 (smp_wmb())rcu_dereference 内部会加 读取屏障 (smp_rmb())__rcu 的核心原理总结| 方面 | 说明 |
|---|---|
| 编译器作用 | 本身是空宏,不改变代码 |
| 静态分析 | Sparse 检查 RCU 指针的安全读写 |
| 内存屏障 | 通过 rcu_assign_pointer / rcu_dereference 添加屏障,保证并发安全 |
| 运行时 | 指针仍然是普通指针,实际存储和访问和普通指针一样 |
sendfilemmap + writesplicereadv / writev:一次性读写多个缓冲区,减少系统调用 read/write的次数。参考
:玩转 Linux 内核:超硬核,基于 mmap 和零拷贝实现高效的内存共享
read + write从磁盘读取文件,发送到网络:
read:write:发生了 4 次拷贝、4 次上下文切换(每次系统调用都会发生两次上下文切换:用户态 → 内核态,内核态 → 用户
态)。
mmap + writemmap 的本质:虚拟地址(用户空间) → 物理页
将一段 用户空间的虚拟地址空间 映射到某个物理资源(文件、设备、或匿名页)上:
| mmap 类型 | 说明 |
|---|---|
| 映射文件 | 映射到关联文件的内核页缓存(page cache) |
| 匿名内存(RAM) | 映射到物理页 |
| 映射设备(如 GPU、FPGA) | 映射到设备地址 |
mmap:write:发生了 3 次拷贝、4 次上下文切换。
sendfile函数签名:
1 | ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count); |
一次系统调用 sendfile 就可以完成从 磁盘文件 → 网卡 的拷贝。
发生了 3 次拷贝、2 次上下文切换。
注意到,sendfile 方法 IO 数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比
如静态文件服务器。
sendfile 被称为“零拷贝”,因为完全绕过用户空间,没有用户空间和内核空间的数据拷贝。
mmap + write 只是多了一次系统调用(多了 2 次上下文切换),但是一般不称为零拷贝,因为它本质上是write 来完成。io_uring 是异步 IO,核心机制之一就是通过 mmap 映射共享环形缓冲区。mmap 是共享内存的方式之一,以公用文件名中转。文件名一般使用 /dev/shm/ 下的文件名,因为其为tmpfs 内存文件系统,数据不会落盘;如果使用普通文件名,则可能触发磁盘读写,尽管由于页缓存的存在,splice函数签名:
1 | ssize_t splice(int fd_in, loff_t *off_in, |
splice 把数据从一个文件描述符“拼接”到另一个文件描述符,而其中至少一个必须是管道(pipe)。
为什么一定要 pipe 参与?
pipe 是内核态缓冲区 pipe 是一种特殊的内核对象,拥有自己的 buffer(pipe buffer)。、
普通文件或 socket 虽然也有缓冲区(页缓存、socket buffer),但是它们不属于通用的缓冲区:
示例:
1 | int pipefd[2]; |
vmsplice函数签名:
1 | ssize_t vmsplice(int fd, const struct iovec *iov, |
vmsplice 将用户空间的内存页映射到 pipe buffer 。
示例:
1 | struct iovec iov = { .iov_base = user_buf, .iov_len = len }; |
vmsplice 几乎总是需要与 splice 协作使用,因为 vmsplice 本身只能将用户空间的内存页映射到一个
pipe 中,而不能直接发送到 socket 或写入文件。
| 特性 | sendfile | splice | vmsplice |
|---|---|---|---|
| 零拷贝 | ✅(文件 → socket) | ✅(文件/pipe/socket → 文件/pipe/socket) | ✅(用户页 → pipe) |
| 输入源 | 文件 | 文件 / pipe / socket | 用户缓冲区 |
| 输出目标 | Socket | 文件 / pipe / socket | Pipe |
| 用户空间参与 | 不参与 | 不参与 | 用户页作为数据源 |
完整示例:
1 | #include <unistd.h> |
常规 TLS(OpenSSL)会在用户态做加密,破坏零拷贝。可用内核 TLS(KTLS)或 NIC TLS/offload 实现零拷贝出
网(把加密放内核或网卡)。
io_uring(modern Linux)支持零拷贝模式(例如 splice/sendfile 的异步提交、SQE/ CQE),并且可以做更高
效的批处理。
散布读写支持一次性将数据从文件描述符读写到多个缓冲区:
如果 select / poll / epoll 通知可读写,那么一定可读写吗?答案是不一定。因为内核不是 实时地 检查
内核缓冲区是否有空间或有数据,所以内核的通知有时间差和虚假性。而 epoll 等函数只关注事件变化,不检查
缓冲区。这样可以提高效率。最终的结果就是鼓励用户程序尝试,但是不保证一定成功,也就是可能阻塞。所以需
要非阻塞 IO 来进一步提高性能。
EPOLLONESHOT阅读 manual:
Since even with edge-triggered epoll, multiple events can be generated upon receipt of multiple
chunks of data, the caller has the option to specify the EPOLLONESHOT flag, to tell epoll to
disable the associated file descriptor after the receipt of an event with epoll_wait(2). When the
EPOLLONESHOT flag is specified, it is the caller’s responsibility to rearm the file descriptor
using epoll_ctl(2) with EPOLL_CTL_MOD.
如果某个文件描述符上有多个数据块到达,那么即使是边沿触发也无法保证事件只通知一次。这可能是由于数据包
过大被分片,或者是新数据到达。
EPOLLONSHOT 需要调用者自行 reset 这个标志。https://blog.csdn.net/salmonwilliam/article/details/112347938
struct task_struct (进程控制块)task_struct结构体,内核用它来描述进程。files ,执行该进程的 files_struct 。1 | // https://elixir.bootlin.com/linux/v6.16/source/include/linux/sched.h |
files_struct (进程的文件表)file * 结构体。1 | // https://elixir.bootlin.com/linux/v6.16/source/include/linux/fdtable.h |
struct file (打开文件表项)open()、pipe()、socket() 创建一个 struct file。1 | // https://elixir.bootlin.com/linux/v6.16/source/include/linux/fs.h |
struct file 再指向更底层的对象,比如 inode(磁盘文件)、socket 缓冲区、pipe 缓冲区。
i-node 包含以下内容
stat结构中的大多数信息都取自i节点。
软链接与硬链接
| 类型 | 定义 |
|---|---|
| 硬链接 (Hard Link) | 表示有多少目录项(文件名)指向同一个 inode 。它们指向同一个文件内容。 |
| 软链接 (Symbolic Link / Symlink) | 类似快捷方式,是一个 独立文件,内容是指向目标文件的路径。 |
unlink,而不是delete。S_IFLINK,表明是符号链接。| 特性 | 硬链接 | 软链接 |
|---|---|---|
| 是否指向 inode | 是,直接指向同一 inode | 否,指向目标路径 |
| 是否可以跨文件系统 | 否,只能在同一分区 | 可以跨分区 |
| 是否可以链接目录 | 通常不能(除非 root) | 可以 |
| 删除目标文件后 | 文件内容仍可访问 | 链接会失效(称为“悬挂链接”) |
| 占用空间 | 不占用额外数据空间(只是多了一个目录项) | 占用少量空间存储路径信息 |
| 更新文件内容 | 所有硬链接同步可见 | 通过软链接修改目标文件内容时可见,软链接本身只是路径 |
O_APPEND 标志打开一个文件,那么相应的标志也被设置到文件表项的文件状态标志中。每次对文件执行写操作时,文件表项中的当前文件偏移量首先会被设置为 i 节点表项中的文件长度(相对其他进程来说是原子操作,不论是两个独立的进程,还是父子进程)。这就使得每次写入的数据都追加到文件的当前尾端处。这里有一个测试的例子,文章结论不见得正确,请参考评论的讨论。PIPE_BUF:只保证小于PIPE_BUF的内容是原子;如果大于则可能被多次多段写入。PIPE_BUF 是管道(pipe)单次写入保证原子的最大字节数,Linux 上是 4096 字节。1 | # 查看 PIPE_BUF 大小 |
以下是 man 2 write 关于 O_APPEND 的说明:
If the file was open(2)ed with O_APPEND, the file offset is first set to the end of the file before writing. The adjustment of the file offset and the write operation are performed as an atomic step.
若一个文件用 lseek 定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置为 i 节点表项中的当前文件长度(注意,此时,设置偏移量和写操作之间不是原子操作)。
dup()后的内核数据结构dup() / dup2() 只复制 fd ,也就是在 fd 数组中新增了一个 fd 项。一般用来重定向。
struct file ,并添加到 fd 数组或 fd 表中。fork() 后的子进程直接复制父进程的 fd 数组,exec() 也不能将其替换;struct task_struct 是深拷贝,所以 fd 数组被复制;struct file* 仍然指向父进程创建的 struct file (共享);fcntl()设置了FD_CLOEXEC标志,此时 exec 会关闭继承的文件描述符。子进程对文件表项的修改,会不会影响父进程?
fork()开启用户进程(子进程),该子进程复制父进程shell的所有文件描述符,于是0, 1, 2文件描述符被打开;测试代码:
1 | #include <fcntl.h> |
1 | $ ./a.out |
1 | $ ./a.out |
分析
a.out改变了。第三次运行:
重新启动shell,并运行a.out
1 | $ ./a.out |
分析
引申:
在此我们注意到,文件描述符0, 1, 2(标准输入、标准输出、标准错误)在一个shell及其所有子进程中,对应的文件(设备)是同一个。由于共享了文件表项,指向了同一个v-node表项,故都指向同一个虚拟终端。这与我们的平时观察一致,不然shell运行程序时,输入输出的入口在哪里呢?
如果进程打开文件,此时我们使用 rm / unlink 删除文件,会发生什么?
什么也不会发生。因为 Linux 文件系统的设计允许文件名(目录项)和文件内容(inode)分离。只有当所有引用(包括文件描述符和内存映射)都关闭后,inode 才会被删除。
在 Linux 中,一个文件由三部分组成:
/lib/libexample.sorm 只是删除了目录项(文件名),并没有删除 inode 或数据块,只要还有进程引用它。
引用 inode 的方式包括:
这些引用会让内核知道:这个 inode 仍然在使用中,不能释放。
这是为了支持非常重要的行为:
✅ 允许进程继续使用已打开或已映射的文件,即使文件名被删除。
这在很多场景下非常有用:
在 Linux 内核中,每个 inode 结构体有一个字段 i_count,表示该 inode 当前被引用的次数。这个引用包括:
这个字段不是用户空间可以直接查看的,但你可以通过以下方式间接观察:
| 方法 | 能看到什么 |
|---|---|
ls -li |
inode 号 + 硬链接引用计数 |
lsof -p <PID> | grep / fuser </path/to/so> |
是否有进程打开文件 |
/proc/<PID>/fd/ |
查看文件描述符引用 |
/proc/<PID>/maps |
查看映射 |
ldd |
查看可执行文件依赖的 .so |
strace |
跟踪运行时加载行为 |
内核字段 i_count |
真实引用计数(需内核调试) |
在本地文件系统(如 ext4)中:
rm 也能删除目录项但在 NFS 文件系统 中:
.nfsXXXX所以此时如果 rm -rf 目录,但是目录下某文件被使用,会提示 xxx/.nfs000000004ec2d5e70000da89 。
1 | $ lsof -p <PID> | grep .nfs |
因为 so 被手动删除,此时引用的 so 被重命名成 .nfsXXXX 。
手动删除 .so 文件后,系统生成了 .nfsXXXX 文件,但进程仍然能继续使用它。进程怎么知道新文件名?
答案是:进程根本不知道新文件名,也不需要知道。
当一个进程打开一个文件(比如 libexample.so),它获得的是一个 文件描述符(fd),这个描述符指向的是内核中的 inode,而不是文件名。
那 .nfsXXXX 文件名是给谁看的?
它是 NFS 客户端自动创建的临时文件名,用于:
📌 这个文件名不会被通知给进程,也不会影响进程的运行。
正常情况下:进程关闭后 .nfsXXXX 自动消失
⚠️ 异常情况:文件可能残留
如果出现以下情况,.nfsXXXX 文件可能不会自动删除:
在这些情况下,.nfsXXXX 文件会残留在文件系统中,直到手动清理。
通过上文的叙述,我们很容易想到管道本质上也是一种特殊的文件,所以管道机制之所以可以进程间通信也是根据共享文件表项保证的。
管道和文件进行进程间通信的本质相同。
TODO