0%

IO模型概念

IO 模型通常按两条维度划分:

  1. 阻塞 vs 非阻塞

    • 阻塞 IO(Blocking IO):调用 read/recv 等函数时,如果数据没准备好,进程会被挂起,直到数据就绪。
    • 非阻塞 IO(Non-blocking IO):调用 read/recv 时,如果数据没准备好,直接返回 EAGAIN 或 EWOULDBLOCK,进程继续做别的事情。
  2. 同步 vs 异步

    • 同步(Synchronous):调用者要等待操作完成才能继续。

      • 阻塞 IO + 同步:最常见,比如普通 read(fd, buf, n)
      • 非阻塞 IO + 同步:调用立即返回,如果没数据则报错或返回 0
    • 异步(Asynchronous):调用者发起操作后,不需要等待,操作完成时通过回调、信号、事件通知等告知结果。

⚡ 关键:异步 IO 的核心是不阻塞当前线程,而结果通知是通过事件或回调完成的。

Linux 常见异步 IO 方式

Linux 下主要有四种机制:

  1. POSIX AIO(aio_* 系列)

    • 系统调用:aio_read(), aio_write()

    • 完成通知方式:

      • 轮询 aio_error()
      • 信号通知 SIGIO
      • 回调函数 sigevent.sigev_notify = SIGEV_THREAD
    • 使用场景:文件 IO,可以在后台发起读写请求,主线程继续工作。

    • ⚠️ 目前性能不如 epoll + 线程池模拟异步。

  2. 信号驱动 IO(SIGIO)

    • 进程或文件描述符注册 F_SETOWN,开启 O_ASYNC
    • 当 fd 可读写时,内核发信号给进程
    • 通常用于少量 fd 的异步事件
  3. I/O 多路复用(select, poll, epoll)

    • 本质是非阻塞 + 事件通知

    • Epoll + 非阻塞 IO 可以模拟高效的异步 IO

    • 适合网络服务器、socket 编程

    • 典型流程:

      • 设置 fd 为非阻塞(否则 read/write 可能阻塞,因为 epoll 本质是同步的)
      • 注册 fd 到 epoll,关注 EPOLLIN / EPOLLOUT
      • 调用 epoll_wait 等待事件
      • 事件触发时读取或写入数据
  4. Linux AIO(io_uring)

    • 新一代高性能异步 IO 接口
    • 支持文件、网络 IO
    • 提供 提交队列 + 完成队列,几乎零系统调用开销
    • 可以真正做到线程几乎不阻塞等待

异步 IO 的优点

  • 不阻塞主线程,提高吞吐量
  • 可同时处理大量 IO(特别是网络/文件服务器)
  • 与多线程相比,降低线程上下文切换开销

异步 IO 的缺点

  • 编程复杂度高(需要事件驱动、回调或状态机)
  • 错误处理和信号安全问题复杂
  • 文件异步 IO 性能在传统 AIO 下不一定比多线程高

Linux 下常见异步 I/O 机制对比

特性 / 机制 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 少量异步事件或控制信号触发场景

Linux io_uring

io_uring 是 Linux 内核自 5.1 版本引入的一个异步 I/O 框架,它提供了 低延迟、高吞吐的异步文件和网络 I/O。它的特点是:

  • 零拷贝提交:应用程序可以直接向内核提交 I/O 请求,无需系统调用每次阻塞。
  • 环形队列机制:通过共享内存的 提交队列(Submission Queue, SQ) 和 完成队列(Completion Queue, CQ),用户态和内核态可以高效交互。
  • 支持多种 I/O 类型:文件读写、网络收发、文件同步、缓冲区操作等。
  • 批量提交和完成:可以一次提交多个 I/O 请求,并批量获取完成结果。

简单理解:它把传统阻塞 I/O 的 “系统调用来回” 改成了 共享环形队列 + 异步通知。

安装

  1. 方法一:从 APT 安装
1
2
sudo apt update
sudo apt install liburing-dev

检查安装路径

1
ls /usr/include/liburing.h
  1. 方法二:从源码安装
1
2
3
4
git clone https://github.com/axboe/liburing.git
cd liburing
make
sudo make install

io_uring 的核心数据结构

Submission Queue(SQ)

  • 用户态将 I/O 请求放入 SQ。
  • SQ 是一个环形数组,存放 io_uring_sqe(I/O 请求条目)。
  • 用户通过 系统调用 io_uring_enter 将 SQ 中的新请求通知内核。
  • 内核会按顺序处理 SQ 中的 I/O 请求。
字段 作用
opcode I/O 类型,如读、写、fsync、accept、sendmsg
fd 文件描述符
off 偏移量(文件 I/O)
addr 用户缓冲区地址
len I/O 数据长度
flags 请求标志,如 IOSQE_FIXED_FILEIOSQE_IO_LINK

Completion Queue(CQ)

  • 内核完成 I/O 后,将结果写入 CQ。
  • CQ 也是一个环形数组,存放 io_uring_cqe(完成条目)。
  • 用户可以轮询或等待 CQ 获取完成结果。
字段 作用
res I/O 结果,成功为正数(读写字节数),失败为负错误码
user_data 用户自定义数据,方便识别请求

io_uring 工作流程

1
2
3
4
5
6
7
8
9
10
11
12
+-----------+          +-----------+
| User App | <-----> | Kernel |
+-----------+ +-----------+
| |
| write SQE to SQ | <- Submission Queue
|-------------------->|
| |
| io_uring_enter | <- 通知内核处理
|-------------------->|
| |
| <------------------ | <- CQE 放入 CQ
| read CQE from CQ |
  • 用户态填充 SQE(Submission Queue Entry)。
  • 调用 io_uring_enter() 提交 SQE (不阻塞)。
  • 内核处理 I/O 请求。
  • 内核把完成结果写入 CQ。
  • 用户态可以:
    • 轮询 CQ:主动读取 CQE(Completion Queue Entry)
    • 注册回调(liburing 新版本支持 IORING_SETUP_IOPOLL + IORING_SETUP_SQPOLL 或自己封装)

注意:

  • 异步 I/O ≠ 必须用回调。关键是提交后不阻塞等待,可以同步轮询完成结果,也可以异步触发回调。
  • 回调是一种可选的使用方式。
  • 最核心的是 共享环形队列 + 完成队列,用户可以同步取结果也可以异步通知。

为什么 io_uring 没有强制回调

传统异步 I/O(比如 Windows IOCP)必须注册回调或事件句柄,因为内核不会给你“主动通知”。

Linux io_uring 的设计哲学是:

  • 用户态和内核共享内存 → 用户态可以自己轮询完成队列。
  • 减少系统调用次数 → 不依赖信号或回调触发。
  • 需要回调时,用户可以自己封装一个事件循环。

所以你看到 io_uring 的官方示例都是 顺序写代码,但是仍然是异步 I/O,因为:

  • 提交后内核可以并行处理多个 I/O。
  • 用户态无需阻塞等待内核完成处理(可以去做别的事)。

io_uring 的使用示例(C 语言)

io_uring_hello.cview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <liburing.h>

#define QUEUE_DEPTH 8
#define BUFFER_SIZE 1024

int main() {
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
int ret, fd;
char buf[BUFFER_SIZE];

// 打开文件
fd = open("test.txt", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}

// 初始化 io_uring
ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
if (ret < 0) {
perror("io_uring_queue_init");
return 1;
}

// 获取提交队列条目
sqe = io_uring_get_sqe(&ring);
if (!sqe) {
fprintf(stderr, "io_uring_get_sqe failed\n");
return 1;
}

// 准备读取操作
io_uring_prep_read(sqe, fd, buf, BUFFER_SIZE, 0);

// 提交到内核
ret = io_uring_submit(&ring);
if (ret < 0) {
perror("io_uring_submit");
return 1;
}

// 等待完成
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
perror("io_uring_wait_cqe");
return 1;
}

// 读取结果
if (cqe->res < 0) {
fprintf(stderr, "Async read failed: %d\n", cqe->res);
} else {
printf("Read %d bytes: %.*s\n", cqe->res, cqe->res, buf);
}

// 通知内核完成
io_uring_cqe_seen(&ring, cqe);

// 关闭
io_uring_queue_exit(&ring);
close(fd);

return 0;
}

这个例子展示了 最基本的异步文件读取:

  • 初始化 ring。
  • 获取一个 SQE 并填充读请求。
  • 提交 SQE。
  • 等待 CQE 获取结果。
  • 标记完成并清理。

Boost asio

要区分 操作系统级别的异步 I/O 和 asio 的抽象,因为 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 机制,可以按下面几个方法操作:

  1. 查看 asio 使用的 I/O 对象

asio 有两个主要 I/O 后端:

  • 旧版 AIO / epoll(select_reactor / epoll_reactor)
  • io_uring(在新版本 Boost.Asio 或 standalone Asio 支持)

在 编译时,asio 会检测系统特性:

  • 如果 Linux 内核 ≥ 5.1,asio 默认启用 io_uring
  • 否则使用 epoll
  1. 通过宏或配置查看

在你的 asio 头文件中,可能有如下宏:

1
2
3
4
5
#if defined(BOOST_ASIO_HAS_IOURING)
std::cout << "asio will use io_uring\n";
#elif defined(BOOST_ASIO_HAS_EPOLL)
std::cout << "asio will use epoll\n";
#endif

这些宏在 boost/asio/detail/config.hpp 或 asio/config.hpp 中定义,表示底层机制。

  1. 运行时确认

asio 本身没有公开 API 显示底层 I/O 类型,但可以通过系统调用监控判断:

使用 strace 观察程序 I/O:

1
strace -f ./your_program 2>&1 | grep io_uring
  • 如果看到 io_uring_setup、io_uring_enter 系统调用,就说明启用了 io_uring
  • 如果没有,只看到 epoll_wait / epoll_ctl,说明使用的是 epoll

对 epoll,strace 会显示 epoll_create1 / epoll_ctl / epoll_wait

阻塞 vs 异步

类型 描述 asio 中的表现
阻塞 I/O 调用时线程被挂起,等待 I/O 完成 不使用,线程会被阻塞,TBB 线程占用
模拟异步 内核不支持真正异步,用线程轮询或线程池实现 Mac/BSD 某些场景下可能是模拟
真正异步 I/O 内核支持,操作提交后立即返回,完成由内核通知 Linux/io_uring、Windows IOCP 就是真正异步

线程安全性

malloc 是线程安全的,但有一些细节需要注意。

  1. 线程安全含义

线程安全:多个线程同时调用 malloc/free 不会破坏堆管理结构,也不会导致内存管理崩溃。

实现方法:

glibc malloc 在内部使用 锁(mutex 或 spinlock) 保护全局堆管理数据结构

不同线程同时申请或释放内存,内核保证堆表一致

  1. 限制与注意事项

性能问题

多线程频繁 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 在信号处理函数里不安全

原因:

  • 信号可能打断正在执行的 malloc
  • malloc 内部锁可能被持有 → 再次调用可能死锁

所以 signal handler 中不能直接调用 malloc/free

安全做法

信号处理函数里只做:

  • 设置标志位(sig_atomic_t flag = 1;)
  • 写入 pipe / eventfd
  • 调用 async-signal-safe 系统调用(write(), _exit())

之后由主程序在安全上下文处理 malloc/free 或其他复杂操作

异步信号

锁、malloc 等不能在信号处理函数里用。这里涉及到 异步信号安全 (async-signal-safe) 的概念。

  1. 信号处理函数的执行环境

当一个信号到达进程时,内核 异步中断当前执行流,立即跳转到信号处理函数执行。

这意味着:

当前线程可能 正在持有锁(mutex、spinlock 等)

当前线程可能 正在使用 malloc/free,操作堆数据结构

  1. 为什么不能调用这些函数

锁(mutex 等)

如果信号处理函数里调用 pthread_mutex_lock():

线程可能已经在信号到达前持有这个锁

信号处理函数再次尝试加锁 → 死锁

malloc / free

malloc 内部会修改全局堆管理结构(如 free list)

如果信号到达时主程序正在调用 malloc 或 free,信号处理函数里再次调用 malloc/free → 堆数据结构可能被破坏

可能导致崩溃或内存泄漏

  1. async-signal-safe 函数

POSIX 定义了一组 “异步信号安全函数”(async-signal-safe functions)

信号处理函数中 只允许调用这些函数

常用安全函数示例:

_exit()

write()(低级系统调用,不会锁堆)

sig_atomic_t 类型变量赋值

总结

函数类型 可在信号处理函数里用? 原因
pthread_mutex_lock 可能已持锁 → 死锁
malloc/free 可能正在操作堆 → 数据结构破坏
write(fd, buf, n) 系统调用,不会破坏用户态结构
_exit() 安全终止进程

核心思想:信号是异步的,中断当前执行流,调用非 async-signal-safe 函数可能破坏正在执行的操作,导致不可预测的行为。

  1. thread_local 的基本语义

C++11 引入的存储类型说明符:

thread_local int x = 0;

表示 每个线程都有一份独立的 x,互不干扰。

生命周期:跟普通静态变量类似(全局存活直到线程退出)。

  1. 底层实现原理

它的实现依赖于 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。

  1. 存放在哪里?

Windows:在 TEB (Thread Environment Block) 里有 TLS 指针,__declspec(thread) 就用它。

Linux/ELF:在每个线程的 TLS 块里(通常分配在线程栈附近的一片内存区域)。访问通过 FS/GS 寄存器。

编译器细节:

GCC/Clang 默认用 “动态 TLS 模型”(访问时通过动态链接器查询 TLS 偏移)。

如果加 -ftls-model=initial-exec,编译器会直接用固定偏移访问 TLS,速度更快(但要求变量在主程序或静态库里)。

  1. 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <thread>
thread_local int counter = 0;

void worker(int id) {
for (int i = 0; i < 3; i++) {
counter++;
std::cout << "Thread " << id << ": counter = " << counter << "\n";
}
}

int main() {
std::thread t1(worker, 1);
std::thread t2(worker, 2);
t1.join();
t2.join();
}

输出大致是:

1
2
3
4
5
6
Thread 1: counter = 1
Thread 1: counter = 2
Thread 1: counter = 3
Thread 2: counter = 1
Thread 2: counter = 2
Thread 2: counter = 3

说明 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

1. Github Pages

本文解释利用github pages搭建个人主页/项目主页的方法。

github pages简介:官方链接

github pages使用了CNAME record技术,参考:链接1链接2Custom domains in Github Pages

注:Read the Docs也是一个很好的搭建个人主页的网站。

Github Pages 站点类型

有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.iohttp(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 站点的个数不限制。

GitHub Pages 访问方法

参考官方文档

例如,你的project站点配置的发布源是gh-pages分支,然后在gh-pages分支上创建了一个about/contact-us.md文件,你将可以在https://<user>.github.io/<repository>/about/contact-us.html访问它。

你也可以使用Jekyll等静态站点生成器来给你的github page配置一个主题。

站点发布常见问题的解决方法

Github workflows

参考官方文档

2. 配置前准备

2.1. Markdown编辑器

推荐的markdown编辑器

  • VSCode:免费。VSCode原生支持Markdown,安装一些插件可以帮助更快地编写markdown文件。
  • Typora:现在已经开始收费。

VSCode markdown插件:

  • Mardown All in One: 提供快捷键,帮助更快的编写markdown文件。
  • Markdown+Math:提供数学公式支持。
  • Markdown Preview Enhanced: 将原生markdown预览的黑色背景改成白色。
  • Markdown Preview Github Styling:提供Github风格的预览。

在线表格生成器:可以生成Markdown、Text、HTML、LaTex、MediaWiki格式的表格。

2.2. 轻量级虚拟机WSL

WSL,Windows Subsystem for Linux,是Windows提供的轻量级Linux虚拟机。

安装教程:见链接

2.2.1. WSL默认没有启用systemctl:

启用systemctl的方法:链接

替代方法:不需要启动systemctl,因为会比较占用资源,启动也会变慢。可以使用service命令替代。

2.2.2. WSL默认没有安装openssl-server:

使用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

2.2.3. 通过https登录到github

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

2.2.4 执行git pull/push时,出现”The authenticity of host ‘github.com (20.205.243.166)’ can’t be established. ED25519 key fingerprint is SHA256:…”错误

报错解释:

这个报错信息通常出现在使用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指纹,除非你完全了解这个服务器的来源和身份。

3. 静态站点生成器

以下几种静态站点生成器都可以用来搭建个人主页。如果使用除JekyII外的工具,则需要配置Github Actions以构建和发布你的站点。

3.1. mkdocs

mkdocs是一个快速的静态网页生成器。

发布个人网站的方法:参见mkdocs-material官网

3.2. JekyII

Jekyll 是一个静态站点生成器,内置对 GitHub Pages 的支持和简化的构建进程。

参见 About GitHub Pages and Jekyll

RCU (读-复制-更新)

RCU(Read-Copy-Update,读-拷贝-更新)是 Linux 内核中一种高效的 并发读-写同步机制,专门用于在多核系统下实现 大量读、少量写 的场景。它的核心思想是:读操作完全无锁,写操作通过复制更新,最后再安全地回收旧版本。

RCU 的核心思想

  • 读操作直接访问数据结构,不加锁
  • 写操作:
    • 先 复制原数据 → 修改副本
    • 更新指针,使新数据生效
    • 延迟回收旧数据,保证当前正在读取旧数据的线程不受影响

__rcu

它是 RCU 机制中非常关键的一环,用来让 编译器和内核知道某个指针是 RCU 保护的。

  1. __rcu 的定义

在 Linux 内核中(以 x86_64 为例):

1
#define __rcu

实际上,它 本身对编译器不产生直接影响

主要用途是 标记类型,告诉 Sparse 静态分析工具 或 内核开发者:这个指针受 RCU 保护,不能随意直接读/写

也就是说,__rcu 是 一个注释性质的宏,编译器编译时忽略,但静态分析工具会检查 RCU 访问规则。

  1. __rcu 的作用

标记 RCU 保护的指针,常见用法:

1
2
3
4
struct files_struct {
struct fdtable __rcu *fdt;
struct file __rcu *fd_array[NR_OPEN_DEFAULT];
};

含义:

  • fdt 是 RCU 指针
  • 不能直接写 fdt = new_ptr; 或直接解引用 fdt->xxx
  • 必须通过 RCU API,如:
    • rcu_assign_pointer(fdt, new_fdt); → 安全更新指针
    • rcu_dereference(fdt) → 安全读取指针
  • 这样可以保证:
    • 写线程更新指针时不会破坏读线程的访问
    • 读线程可以无锁访问旧数据
  1. __rcu 与编译器和内核
  • 静态检查(Sparse)
    • Sparse 是 Linux 内核推荐的静态分析工具
    • 它会检查:
      • 所有 __rcu 指针的写操作是否用 rcu_assign_pointer
      • 所有读取是否用 rcu_dereference
    • 如果直接访问,就会报错,避免 RCU 访问错误
  • 内存屏障和优化
    • rcu_assign_pointer 内部会加上 适当的写屏障 (smp_wmb())
    • rcu_dereference 内部会加 读取屏障 (smp_rmb())
    • 防止编译器/CPU 重排导致读写顺序错误
  1. __rcu 的核心原理总结
方面 说明
编译器作用 本身是空宏,不改变代码
静态分析 Sparse 检查 RCU 指针的安全读写
内存屏障 通过 rcu_assign_pointer / rcu_dereference 添加屏障,保证并发安全
运行时 指针仍然是普通指针,实际存储和访问和普通指针一样

高效的 IO 函数

  • sendfile
  • mmap + write
  • splice
  • readv / writev:一次性读写多个缓冲区,减少系统调用 read/write的次数。

零拷贝 (Zero copy)

文件到网络(最常见)

参考
玩转 Linux 内核:超硬核,基于 mmap 和零拷贝实现高效的内存共享

read + write

从磁盘读取文件,发送到网络:

  1. read:
    1. DMA 拷贝:磁盘 → 内核页缓存;
    2. CPU 拷贝:内核页缓存 → 用户缓冲区。
  2. write:
    1. CPU 拷贝:用户缓冲区 → 内核 socket 缓冲区;
    2. DMA 拷贝:内核 socket 缓冲区 → 网卡。

发生了 4 次拷贝、4 次上下文切换(每次系统调用都会发生两次上下文切换:用户态 → 内核态,内核态 → 用户
态)。

mmap + write

mmap 的本质:虚拟地址(用户空间) → 物理页

将一段 用户空间的虚拟地址空间 映射到某个物理资源(文件、设备、或匿名页)上:

mmap 类型 说明
映射文件 映射到关联文件的内核页缓存(page cache)
匿名内存(RAM) 映射到物理页
映射设备(如 GPU、FPGA) 映射到设备地址
  1. mmap
    1. DMA 拷贝:磁盘 → 内核页缓存(同时也是用户空间可见的);
  2. write:
    1. CPU 拷贝:内核页缓存 → 内核 socket 缓冲区;
    2. DMA 拷贝:内核 socket 缓冲区 → 网卡。

发生了 3 次拷贝、4 次上下文切换。

sendfile

函数签名:

1
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

一次系统调用 sendfile 就可以完成从 磁盘文件 → 网卡 的拷贝。

  1. DMA 拷贝:磁盘 → 内核磁盘缓冲区;
  2. CPU 拷贝:内核磁盘缓冲区 → 内核 socket 缓冲区;
  3. DMA 拷贝:内核 socket 缓冲区 → 网卡。

发生了 3 次拷贝、2 次上下文切换。

注意到,sendfile 方法 IO 数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比
如静态文件服务器。

sendfile 被称为“零拷贝”,因为完全绕过用户空间,没有用户空间和内核空间的数据拷贝。

  • mmap + write 只是多了一次系统调用(多了 2 次上下文切换),但是一般不称为零拷贝,因为它本质上是
    把文件内容映射到用户空间,并且数据传输还是要 write 来完成。
  • io_uring 是异步 IO,核心机制之一就是通过 mmap 映射共享环形缓冲区。
  • mmap 是共享内存的方式之一,以公用文件名中转。文件名一般使用 /dev/shm/ 下的文件名,因为其为
    tmpfs 内存文件系统,数据不会落盘;如果使用普通文件名,则可能触发磁盘读写,尽管由于页缓存的存在,
    除非缺页异常,否则被共享者直接读取页缓存就可以,但是终究有实际磁盘 IO 的参与,效率不高。
splice

函数签名:

1
2
3
ssize_t splice(int fd_in, loff_t *off_in,
int fd_out, loff_t *off_out,
size_t len, unsigned int flags);

splice 把数据从一个文件描述符“拼接”到另一个文件描述符,而其中至少一个必须是管道(pipe)。

为什么一定要 pipe 参与?

  • pipe 是内核态缓冲区 pipe 是一种特殊的内核对象,拥有自己的 buffer(pipe buffer)。、

    • splice 利用 pipe buffer 来暂存数据,避免用户空间拷贝。
    • pipe buffer 是通用 buffer ,是隔离的、一次性消费的,适合做数据通路。
  • 普通文件或 socket 虽然也有缓冲区(页缓存、socket buffer),但是它们不属于通用的缓冲区:

    • socket buffer 的数据必须经过 TCP/IP 协议栈处理(如分片、拥塞控制、校验等)。
    • 页缓存属于文件系统,且可能被多进程共享(COW,写时复制)。

示例:

1
2
3
4
5
6
7
8
int pipefd[2];
pipe(pipefd);

// 文件 → pipe
splice(file_fd, NULL, pipefd[1], NULL, len, SPLICE_F_MOVE);

// pipe → socket
splice(pipefd[0], NULL, socket_fd, NULL, len, SPLICE_F_MOVE);
vmsplice

函数签名:

1
2
ssize_t vmsplice(int fd, const struct iovec *iov,
unsigned long nr_segs, unsigned int flags);

vmsplice 将用户空间的内存页映射到 pipe buffer 。

示例:

1
2
3
struct iovec iov = { .iov_base = user_buf, .iov_len = len };
vmsplice(pipefd[1], &iov, 1, SPLICE_F_GIFT);
splice(pipefd[0], NULL, socket_fd, NULL, len, 0);

vmsplice 几乎总是需要与 splice 协作使用,因为 vmsplice 本身只能将用户空间的内存页映射到一个
pipe 中,而不能直接发送到 socket 或写入文件。

特性 sendfile splice vmsplice
零拷贝 ✅(文件 → socket) ✅(文件/pipe/socket → 文件/pipe/socket) ✅(用户页 → pipe)
输入源 文件 文件 / pipe / socket 用户缓冲区
输出目标 Socket 文件 / pipe / socket Pipe
用户空间参与 不参与 不参与 用户页作为数据源

完整示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <unistd.h>
#include <sys/uio.h>
#include <fcntl.h>
#include <stdio.h>

int main() {
int pipefd[2];
pipe(pipefd); // pipefd[1] 写端, pipefd[0] 读端

char buf[] = "Hello zero-copy via vmsplice!";
struct iovec iov;
iov.iov_base = buf;
iov.iov_len = sizeof(buf);

// 用户缓冲区插入 pipe
//
// flags = SPLICE_F_GIFT :
// 内核“接管”了这些页(把页的所有权赠送给内核)。
// 应用不能再修改 iov 指向的缓冲区,否则会产生数据竞争或内核数据损坏。
// 这个模式能确保 真正零拷贝,但代价是用户缓冲区失去控制权。
//
// flags = 0
// 在 vmsplice 返回后可以安全修改 iov 的内容
// flags=0时,vmsplice默认采用SPLICE_F_MOVE尝试移动内存页,若失败则回退到拷贝;而write始终执行拷贝
ssize_t n = vmsplice(pipefd[1], &iov, 1, 0);
if (n < 0) { perror("vmsplice"); return 1; }

// 从 pipe 读出
char out[64];
n = read(pipefd[0], out, sizeof(out));
write(STDOUT_FILENO, out, n); // 输出到终端

return 0;
}

网络到网络 / 用户态网卡绕过

  • AF_XDP / XDP / Netmap / PF_RING / DPDK:绕开传统 kernel network stack,把网卡队列直接映射到用户态
    缓冲区(NUMA/hugepages),实现用户态零拷贝和超低延迟。
  • RDMA(InfiniBand / RoCE):RDMA NIC 通过 DMA 直接把远端内存读写到本地内存,完全绕过 CPU 复制(需要
    硬件与协议支持)。

加密 / TLS 场景

常规 TLS(OpenSSL)会在用户态做加密,破坏零拷贝。可用内核 TLS(KTLS)或 NIC TLS/offload 实现零拷贝出
网(把加密放内核或网卡)。

存储层

  • O_DIRECT / direct I/O:绕过 page cache,用户空间 buffer 与磁盘 DMA 直接交互,但要求对齐(页/块对齐
    )。
  • mmap + msync:对于某些场景可以减少复制(但写回仍会有开销)。

异步 I/O 与零拷贝

io_uring(modern Linux)支持零拷贝模式(例如 splice/sendfile 的异步提交、SQE/ CQE),并且可以做更高
效的批处理。

Page cache 和异步 IO

https://www.xiaolincoding.com/os/8_network_system/zero_copy.html#%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0%E9%9B%B6%E6%8B%B7%E8%B4%9D

  • O_DIRECT: 绕过操作系统缓存,直接读写磁盘,可以避免缓存延迟,提高性能。可用于:
    • 数据库系统:对性能要求极高,且直接操作磁盘数据。
    • 存储设置:如 SSD、硬盘,直接与硬件设备进行高效的 I/O 操作。
    • 注意:由于绕过了缓存,所以 read 如果小于当前数据包的大小,则本次 read 后,内核会直接丢弃多余的数
      据。这是为了避免多余的数据在内存中驻留。

散布读写 (Scatter read/write)

  • readv
  • writev

散布读写支持一次性将数据从文件描述符读写到多个缓冲区:

  • 避免多次系统调用;
  • 直接分块读取,不需要额外用户态 memcpy 到不同的块。

IO 复用

  • select
  • poll
  • epoll
  • 非阻塞 IO

如果 select / poll / epoll 通知可读写,那么一定可读写吗?答案是不一定。因为内核不是 实时地 检查
内核缓冲区是否有空间或有数据,所以内核的通知有时间差和虚假性。而 epoll 等函数只关注事件变化,不检查
缓冲区。这样可以提高效率。最终的结果就是鼓励用户程序尝试,但是不保证一定成功,也就是可能阻塞。所以需
要非阻塞 IO 来进一步提高性能。

epoll

  • 减少数据拷贝:select / poll 只有一个函数,这会要求每次调用都必须将描述事件的数据从用户空间复制到内
    核空间;所以 epoll 拆分成三个函数,用户可以向内核直接注册事件数据;
  • 红黑树:epoll 事件数据是用红黑树来记录,增删查改的时间复杂度为 O(logn) ;select / poll 是线性扫描
    ,时间复杂度 O(n) 。红黑树需要额外的空间,所以这是空间换时间的办法。

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.

如果某个文件描述符上有多个数据块到达,那么即使是边沿触发也无法保证事件只通知一次。这可能是由于数据包
过大被分片,或者是新数据到达。

  • 这在单线程程序上不会有太大影响,因为对同一个 fd 不会造成重复读写。
  • 多线程程序中,fd 准备好后,我们常常将这个 fd 交给某个线程去处理。此时如果 fd 有新的事件,会造成多
    线程处理同一个 fd 的情况。
    • 为了避免竞争,要么加锁;要么使用内核的 ONESHOT 机制。后者由内核保证,无锁,更高效。
  • EPOLLONSHOT 需要调用者自行 reset 这个标志。

参考

https://blog.csdn.net/salmonwilliam/article/details/112347938

进程打开文件

image

  • struct task_struct (进程控制块)
    • 每个进程有一个 task_struct结构体,内核用它来描述进程。
    • 里面有一个指针 files ,执行该进程的 files_struct
1
2
3
4
5
6
7
// https://elixir.bootlin.com/linux/v6.16/source/include/linux/sched.h

/* 进程控制块 */
struct task_struct {
struct files_struct *files; // 进程当前打开的文件
// ...
};
  • files_struct (进程的文件表)
    • 这里保存了一个指向 fd 数组 的指针。
    • fd 数组的下标就是 0, 1, 2…,每个元素指向一个 file * 结构体。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// https://elixir.bootlin.com/linux/v6.16/source/include/linux/fdtable.h

struct files_struct {
struct fdtable __rcu *fdt; // fd 表(动态管理)
struct file __rcu *fd_array[NR_OPEN_DEFAULT]; // 固定大小数组(早期 fd 数组)
// NR_OPEN_DEFAULT 通常为 1024
// 早期没有设置 FD_CLOEXEC 新特性
// 为什么 fdt 与 fd_array 同时存在?
// 1. 性能优化:大多数程序,fd 都在 0~1023,直接访问数组更快
// 2. 向后兼容:早期接口会访问fd_array
// 3. 动态扩展:如果 fd 超过 NR_OPEN_DEFAULT,内核会复制 fd_array 到 fdt->fd 来保证一致性。

// ... `struct files_struct' 还有其他成员
};

// fd 表(动态管理)
struct fdtable {
unsigned int max_fds;
struct file __rcu **fd; /* 当前打开的 fd 指针数组 */
unsigned long *close_on_exec; // fd 标志,目前只有一个
// FD_CLOEXEC 定义在 <fcntl.h> 中
// close_on_exec 指向一个位图(bitmap),每个bit代表一个fd
// 如果bit=1,表示该fd设置了FD_CLOEXEC
unsigned long *open_fds; // 标记哪些fd是打开的,也是位图
unsigned long *full_fds_bits; // 辅助位图,用于快速找到空闲fd
struct rcu_head rcu;
};
  • struct file (打开文件表项)
    • 内核为每次 open()pipe()socket() 创建一个 struct file
    • 它记录了文件状态(读写偏移、flag、引用计数等)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// https://elixir.bootlin.com/linux/v6.16/source/include/linux/fs.h

struct file {
spinlock_t f_lock;
fmode_t f_mode;
const struct file_operations *f_op;
struct address_space *f_mapping;
void *private_data; // 比如 socket
struct inode *f_inode;
unsigned int f_flags; // 文件状态标志,如 O_RDONLY, O_NONBLOCK, O_APPEND 等
unsigned int f_iocb_flags;
const struct cred *f_cred;
struct fown_struct *f_owner;
/* --- cacheline 1 boundary (64 bytes) --- */
struct path f_path;
union {
/* regular files (with FMODE_ATOMIC_POS) and directories */
struct mutex f_pos_lock;
/* pipes */
u64 f_pipe; // 管道
};
// ...
};
  • inode / pipe / socket 内核对象
    • struct file 再指向更底层的对象,比如 inode(磁盘文件)、socket 缓冲区、pipe 缓冲区。

    • i-node 包含以下内容

      • 链接计数(指向该i节点的目录项数);
      • 文件类型、文件访问权限位、文件长度、指向文件数据块的指针等。stat结构中的大多数信息都取自i节点。
      • 只有两项重要数据放在目录项中:文件名和i-node编号。
磁盘、分区和文件系统
磁盘、分区和文件系统
i节点和数据块
i节点和数据块

软链接与硬链接

类型 定义
硬链接 (Hard Link) 表示有多少目录项(文件名)指向同一个 inode 。它们指向同一个文件内容。
软链接 (Symbolic Link / Symlink) 类似快捷方式,是一个 独立文件,内容是指向目标文件的路径。
  • 硬链接:当硬链接数降为0时,才从磁盘的数据块中删除该文件,所以删除文件(即目录项)称为unlink,而不是delete
  • 软链接:i-node中的文件类型是S_IFLINK,表明是符号链接。
特性 硬链接 软链接
是否指向 inode 是,直接指向同一 inode 否,指向目标路径
是否可以跨文件系统 否,只能在同一分区 可以跨分区
是否可以链接目录 通常不能(除非 root) 可以
删除目标文件后 文件内容仍可访问 链接会失效(称为“悬挂链接”)
占用空间 不占用额外数据空间(只是多了一个目录项) 占用少量空间存储路径信息
更新文件内容 所有硬链接同步可见 通过软链接修改目标文件内容时可见,软链接本身只是路径

两个独立进程各自打开同一个文件

两个独立进程各自打开同一个文件

  • O_APPEND:
    • 原子操作:如果使用 O_APPEND 标志打开一个文件,那么相应的标志也被设置到文件表项的文件状态标志中。每次对文件执行写操作时,文件表项中的当前文件偏移量首先会被设置为 i 节点表项中的文件长度(相对其他进程来说是原子操作,不论是两个独立的进程,还是父子进程)。这就使得每次写入的数据都追加到文件的当前尾端处。这里有一个测试的例子,文章结论不见得正确,请参考评论的讨论。
    • PIPE_BUF:只保证小于PIPE_BUF的内容是原子;如果大于则可能被多次多段写入。PIPE_BUF 是管道(pipe)单次写入保证原子的最大字节数,Linux 上是 4096 字节。
1
2
3
4
5
6
# 查看 PIPE_BUF 大小
# `/tmp' 可以换成任意文件系统路径
$ getconf PIPE_BUF /tmp
4096
# 也可以查看所有文件系统相关的 PIPE_BUF 限制
$ getconf -a 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:

若一个文件用 lseek 定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置为 i 节点表项中的当前文件长度(注意,此时,设置偏移量和写操作之间不是原子操作)。


dup()后的内核数据结构

dup() / dup2() 只复制 fd ,也就是在 fd 数组中新增了一个 fd 项。一般用来重定向。

dup(1)


fork与文件共享

  • 进程每打开一个文件,都会新建一个 struct file ,并添加到 fd 数组或 fd 表中。
    • 对同一个文件,不同进程拥有各自的文件表项。
    • 但是对每个文件,v节点表项在整个操作系统中只有一份。
  • fork() 后的子进程直接复制父进程的 fd 数组,exec() 也不能将其替换;
    • 子进程对 struct task_struct 是深拷贝,所以 fd 数组被复制;
    • 但是子进程对 fd 数组是浅拷贝,fd 数组中的 struct file* 仍然指向父进程创建的 struct file (共享);
    • 所以子进程共享了文件状态标志 (O_APPEND, O_NONBLOCK, O_RDONLY 等)、当前文件偏移量。
  • 除非该文件描述符使用fcntl()设置了FD_CLOEXEC标志,此时 exec 会关闭继承的文件描述符。

fork

子进程对文件表项的修改,会不会影响父进程?

  • shell进程启动时,会自动打开这三个文件描述符(可能由配置项决定);
  • shell利用fork()开启用户进程(子进程),该子进程复制父进程shell的所有文件描述符,于是0, 1, 2文件描述符被打开;
  • 由于子进程共享父进程的文件表项,子进程对文件状态标志(读、写、同步或非阻塞等)、文件偏移量的修改,将会影响父进程

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>

#define err_sys(x) \
{ \
perror(x); \
exit(1); \
}

void pr_fl(int fd); // 自定义函数:打印文件状态标志
void set_fl(int fd, int flags); // 自定义函数:设置文件状态标志

int main() {
pr_fl(0);

set_fl(0, O_APPEND);
pr_fl(0);

return 0;
}

void set_fl(int fd, int flags) {
int val;

if ((val = fcntl(fd, F_GETFL, 0)) < 0)
err_sys("fcntl F_GETFL error");

val |= flags;

if (fcntl(fd, F_SETFL, val) < 0)
err_sys("fcntl F_SETFL error");
}

void pr_fl(int fd) {
int val;

// do not guarantee success on certain system, check EINVAL first
if ((val = fcntl(fd, F_GETFL, 0)) < 0)
err_sys("fcntl F_GETFL error");

switch (val & O_ACCMODE) {
case O_RDONLY:
printf("read only");
break;
case O_WRONLY:
printf("write only");
break;
case O_RDWR:
printf("read write");
break;
default:
err_sys("unknown open type");
}

if (val & O_CREAT)
printf(", create");
if (val & O_APPEND)
printf(", append");
if (val & O_NONBLOCK)
printf(", non-block");
if (val & O_SYNC)
printf(", synchronized file");
// if (val & O_DSYNC)
// printf(", synchronize data");

putchar('\n');
}
  • 第一次运行:
1
2
3
$ ./a.out
read write
read write, append
  • 第二次运行:
1
2
3
$ ./a.out
read write, append
read write, append
  • 分析

    • 第二次运行时,文件描述符0的初始状态保持了第一次运行的结果!
    • 这是因为父进程shell的文件表项的文件状态标志被子进程a.out改变了。
  • 第三次运行:

    重新启动shell,并运行a.out

1
2
3
$ ./a.out
read write
read write, append
  • 分析

    • 第三次运行,结果与第一次一致,这说明我们的猜测正确。
    • 父进程shell关闭之后,所有文件描述符被关闭,文件IO被关闭,文件表被释放。重启shell也就重置了文件表。
  • 引申:
    在此我们注意到,文件描述符0, 1, 2(标准输入、标准输出、标准错误)在一个shell及其所有子进程中,对应的文件(设备)是同一个。由于共享了文件表项,指向了同一个v-node表项,故都指向同一个虚拟终端。这与我们的平时观察一致,不然shell运行程序时,输入输出的入口在哪里呢?

如果进程打开文件,此时我们使用 rm / unlink 删除文件,会发生什么?

什么也不会发生。因为 Linux 文件系统的设计允许文件名(目录项)和文件内容(inode)分离。只有当所有引用(包括文件描述符和内存映射)都关闭后,inode 才会被删除。

在 Linux 中,一个文件由三部分组成:

  • 目录项(filename):比如 /lib/libexample.so
  • inode(元数据):记录权限、大小、时间戳等
  • 数据块(内容):实际的文件内容

rm 只是删除了目录项(文件名),并没有删除 inode 或数据块,只要还有进程引用它。

引用 inode 的方式包括:

  • 打开文件(open())
  • 映射文件(mmap())
  • 动态链接器加载 .so 文件

这些引用会让内核知道:这个 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 也能删除目录项
  • 文件内容仍保留在 inode 中,直到所有引用关闭

但在 NFS 文件系统 中:

  • 客户端不能立即删除正在被使用的文件
  • 所以它会将文件重命名为 .nfsXXXX
  • 等引用释放后,自动删除该临时文件

所以此时如果 rm -rf 目录,但是目录下某文件被使用,会提示 xxx/.nfs000000004ec2d5e70000da89

1
2
$ lsof -p <PID> | grep .nfs
my_executable 283964 my_name mem REG 0,183 18371240 1321391591 /XXX/.nfs000000004ec2d5e70000da89 (xxx:/yyy/zzz)

因为 so 被手动删除,此时引用的 so 被重命名成 .nfsXXXX

手动删除 .so 文件后,系统生成了 .nfsXXXX 文件,但进程仍然能继续使用它。进程怎么知道新文件名?

答案是:进程根本不知道新文件名,也不需要知道。

当一个进程打开一个文件(比如 libexample.so),它获得的是一个 文件描述符(fd),这个描述符指向的是内核中的 inode,而不是文件名。

.nfsXXXX 文件名是给谁看的?

它是 NFS 客户端自动创建的临时文件名,用于:

  • 保留 inode 内容,直到所有引用关闭
  • 让系统知道这个文件还不能真正删除
  • 让你可以用 lsof 或 fuser 查找谁在使用它

📌 这个文件名不会被通知给进程,也不会影响进程的运行。

正常情况下:进程关闭后 .nfsXXXX 自动消失

  • .nfsXXXX 文件是由 NFS客户端 创建的临时文件
  • 当某个文件被打开后删除,客户端会将其重命名为 .nfsXXXX
  • 一旦该文件的 打开引用计数为 0(即所有进程都关闭了该文件),客户端会自动删除 .nfsXXXX

⚠️ 异常情况:文件可能残留

如果出现以下情况,.nfsXXXX 文件可能不会自动删除:

  • 客户端 crash 或断网,未能完成清理动作
  • 使用 kill -9 强制终止进程,绕过了正常关闭流程
  • NFS 客户端或服务器之间同步延迟
  • 文件被多个进程同时打开,只有部分进程关闭

在这些情况下,.nfsXXXX 文件会残留在文件系统中,直到手动清理。

管道

通过上文的叙述,我们很容易想到管道本质上也是一种特殊的文件,所以管道机制之所以可以进程间通信也是根据共享文件表项保证的。
管道和文件进行进程间通信的本质相同。

Linux 文件锁与记录锁

TODO

参考链接:链接1链接2

参考

  • 《UNIX 环境高级编程》
  • 《Linux 内核设计与实现(原书第 3 版) - Linux Kernel Development, Third Edition》,(美)拉芙(Love, R.)著;陈莉君,康华译. ——北京:机械工业出版社,2011.9(2021.5 重印)
  • 图解进程控制块stask_struct

Unix 权限涉及三个部分:** 用户 进程 文件 **。
权限分为常用权限、SELinux 权限。
manpage: man 2 stat 中搜索 “mode” 可以看到几种常用权限的详情。

用户和分组

用户用 user ID 区分。多个用户可以划入同一个用户分组,一个用户可以同时属于不同的分组。这就是 UID(User ID)和 GID(Group ID)。

现在我将使用我的凭据登录到 shell 并运行:

1
grep $LOGNAME /etc/passwd

rotem: x:1000:1000:rotem,,,:/home/rotem:/bin/bash

您可以看到我的日志名 (rotem)、均为 1000 的 UID 和 GID,以及其他详细信息,例如我登录的 shell。

进程的用户 ID (UID)

每个进程都有一个所有者,并且每个进程都属于一个组。

进程有 3 种 UID:real user IDeffective user IDsaved user ID。其中还有一个 set-user-ID 的概念,这个概念和 effective user ID 紧密关联。

实际用户 ID (RUID, Real User ID) [^5]

在我们的 shell 中,我们现在将运行的每个进程都将继承我的用户帐户的权限,并将使用相同的 UID 和 GID 运行。

当您 fork 一个新进程时,该进程会继承父进程的 RUID。通常父级的 RUID 是你的 shell 并且它有当前登录用户的 UID。所以新进程有当前登录用户的 UID 的 RUID。通常这不会改变,只有 root 可以改变它。

举个例子,想想 init 进程派生了你的登录 shell。在 fork 期间,shell 将具有 root 的 RUID(因为 shell 的父级是 init)。但是,init 进程使用 /etc/passwd 将 shell 的 RUID 改成你的 UID. 因此,此后登录 shell 的 RUID 将是您的 UID,而不是 root。所以,我们可以说 RUID 是进程所有者的。

让我们运行一个简单的命令来检查它:

1
2
$ sleep 100 & ps aux | grep 'sleep'
bi-an 3741 0.0 0.0 11252 928 pts/0 S 17:00 0:00 sleep 100

然后根据 ps 命令打印出的 PID (3741) ,查进程 UID 和 GID:

1
2
$ stat -c "%u %g" /proc/3741
1000 1000

然而,系统判断一个进程对一个文件是否有权限时,要验证的 ID 是 effective user ID,而不是 real user ID。

有效用户 ID(EUID, Effective User ID)

Linux 通常都不建议用户使用 root 权限去进行一般的处理,但是普通用户在做很多很多 services 相关的操作的时候,可能需要一个特殊的权限。为 了满足这样的要求,许多 services 相关的 executable 有一个标志,这就是 set-user-ID bit。当这个 set-user-ID bit=ON 的时候,这个 executable 被用 exec 启动之后的进程的 effective user ID 就是这个 executable 的 owner id,而并非 parent process real user id。如果 set-user-ID bit=OFF 的时候,这个被 exec 起来的进程的 effective user ID 应该是等于进程的 user ID 的。
我们以 ping 命令为例。

使用 which 命令搜索二进制位置,然后运行 ls -la

-rwsr-xr-x 1 root root 64424 Mar 10 2017 ping

可以看到文件的所有者和组是 root. 这是因为该 ping 命令需要打开一个套接字,而 Linux 内核 root 为此需要特权。

但是,如果没有 root 特权,我如何使用 ping

注意文件权限的所有者部分中的 “s” 字母而不是 “x”。

这是特定二进制可执行文件(如 pingsudo)的特殊权限位,称为 set-user-ID

这是 EUIDEGID 发挥作用。

将会发生的情况是,当执行设置了 setuid 的二进制文件 ping 时,该进程将其有效用户 ID (EUID) 从默认值 RUID 更改为此特殊二进制可执行文件的所有者,在本例中为 root。

这一切都是通过这个文件有这个简单的事实来完成的 set-user-ID

内核通过查看进程的 EUID 来决定该进程是否具有特权。因为现在 EUID 指向 root,操作不会被内核拒绝。

注意:在最新的 Linux 版本中,ping 命令的输出看起来会有所不同,因为它们采用了 Linux Capabilities 方法而不是这种 setuid 方法(对于不熟悉的人)请阅读 此处

set-user-ID

参考 链接 1

Unix 包含另一个权限位,即该权限 set-user-ID 位。如果为可执行文件设置了该位,那么只要所有者以外的用户执行该文件,该用户就可以访问所有者的其他任何文件,从而获得所有者的所有文件读 / 写 / 执行特权!

这会导致运行该文件的任何人或进程都可以访问系统资源,就好像他们是该文件的所有者一样。

1
2
$ ls -l testfile
-rw-rw-r-- 1 rotem rotem 0 Nov 8 17:12 testfile

为文件添加 set-user-ID 权限:

1
2
3
$ sudo chmod u+s testfile
$ ls -l testfile
-rwSrw-r-- 1 rotem rotem 0 Nov 8 17:12 testfile

说明:大写 S 表示,有 set-user-ID 权限,但是没有执行权限。

set-group-ID

阅读 Manual:man 2 stat

The set-group-ID bit (S_ISGID) has several special uses.
For a directory, it indicates that BSD semantics is to be used for that directory: files created there inherit their group ID from the directory, not from the effective group ID of the creating process, and directories created there will also get the S_ISGID bit set.

如果目录具有设置组 ID(S_ISGID):

  • 其下创建的文件将从其父目录的继承组 ID,而不能从创建其的进程中继承有效组 ID;
  • 其下创建的子目录将继承设置组 ID 位(S_ISGID)。

For a file that does not have the group execution bit (S_IXGRP) set, the set-group-ID bit indicates mandatory file/record locking.

如果一个文件有 “设置组 ID(set-group-ID)” 但是没有 “组执行权限(S_IXGRP)”,也就说具有如下权限:

1
2
% ls -l a.txt
-rw-r-Sr-- 1 username groupname 0 Feb 23 19:34 a.txt

那么,此时 set-group-ID 位喻示强制文件 / 记录锁。

TODO:强制文件 / 记录锁是什么?
Mandatory File Locking
Why remove group execute for mandatory file lock?

粘滞位:

The sticky bit (S_ISVTX) on a directory means that a file in that directory can be renamed or deleted only by the owner of the file, by the owner of the directory, and by a privileged process.

大写 T 表示,有 restricted deletion flag or sticky bit(粘滞位),但是没有执行权限,t 权限只对目录有效,作用是保护目录项不能被其他用户删除。目录要同时具有 xs 才能保证粘滞位有效。

保存的用户 ID (SUID, Saved User ID)

为什么要设置一个 saved set-user-ID 呢?它的意义是,它相当于是一个 buffer, 在 exec 启动进程之后,它会从 effective user ID 位拷贝信息到自己。

  • 对于非 root 用户,可以在未来使用 setuid() 来将 effective user ID 设置成为 real user ID 和 saved set-user-ID 中的任何一个。但是非 root 用户是不允许用 setuid() 把 effective user ID 设置成为任何第三个 user id。
  • 对于 root 来说,就没有那么大的意义了。因为 root 调用 setuid() 的时候,将会设置所有的这三个 user ID 位。所以可以综合说,这三个位的设置为为了让 unprivilege user 可以获得两种不同的 permission 而设置的。

《Unix 环境高级编程》的例子是,普通用户去执行一个 tip 进程,set-user-ID bit=ON,执行起来的时候,进程可以有 uucp (executable owner) 的权限来写 lock 文件,也有当前普通用户的权限来写数据文件。在两种文件操作之间,使用 setuid() 来切换 effective user id。但是正是因为 setuid() 的限制,该进程无法获得更多的第三种用户的权限。

saved set-user-ID 是无法取出来的,是 kernel 来控制的。注意 saved set-user-ID 是进程中的 id,而 set-user-ID bit 则是文件上的权限。

总结

用户 ID:

属性 决定特权 说明
EUID ✅ 是 内核权限检查时使用
RUID ❌ 否 表示谁启动了进程
FSUID ⚠️ 有时 文件访问权限相关
SUID ⚠️ 有时 用于权限恢复

组 ID:

属性 说明 用途
EGID 有效组 ID 用于权限检查
RGID 实际组 ID 表示谁启动了进程
SGID 保存组 ID 用于权限恢复
FSGID 文件系统组 ID 文件访问专用

以上都是讨论用户 ID,如果没有特殊说明,以上的组 ID 和用户 ID 基本是类似的,只是作用对象为组。

文件权限

符号形式的权限(Symbolic permissions)

符号形式表示文件权限有 5 种:rwxst

  • r:可读。对文件来说,意味着能执行 vim 查看、cat 等。对目录来说,意味着能执行 ls 查看其下的文件列表。

  • w:可写。对文件来说,意味着能执行 vim 等工具编辑并保存。对目录来说,意味着能够创建、删除文件或新的目录。

  • x:可执行(文件) / 可搜索(目录)。对文件来说,意味着可以执行。对目录来说,意味着能够 cd 进入该目录。

《UNIX 环境高级编程》P80 中如此描述:

读权限允许我们读目录,获得该目录中所有文件名的列表。当一个目录是我们要访问文件的路径名的一个组成部分时,对该目录的执行权限使我们可以通过该目录(也就是搜索该目录,寻找一个特定的文件名)。

ls 没有可搜索权限的目录 可以看到文件列表、文件类型,但是不能看到其他信息,比如文件权限、所有者、大小、修改时间等,因为这些信息保存在 inode 中,必须先 cd 进入该目录,才能读取这些信息。同样,ls -R 不能显示没有执行权限的目录下的子目录下的文件,因为这也必须先 cd 该目录,然后执行 ls 显示子目录的文件。

1
2
3
4
5
6
7
8
$ ls -lR mydir/
mydir/:
ls: cannot access 'mydir/dir2': Permission denied
ls: cannot access 'mydir/file2': Permission denied
total 0
d????????? ? ? ? ? ? dir2
-????????? ? ? ? ? ? file2
ls: cannot open directory 'mydir/dir2': Permission denied

给目录递归恢复权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ chmod -R u+X mydir/
$ ls -lR mydir/
mydir/:
total 4
drwxrwxr-x 3 rotem rotem 4096 Nov 9 10:02 dir2
-rwxrw-r-- 1 rotem rotem 0 Nov 9 10:03 file2

mydir/dir2:
total 8
-rwxrw-r-- 1 rotem rotem 10 Nov 8 22:55 1.txt
-rwxrw-r-- 1 rotem rotem 0 Nov 8 22:55 2.txt
drwxrwxr-x 2 rotem rotem 4096 Nov 9 10:05 dir3

mydir/dir2/dir3:
total 0
-rwxrw-r-- 1 rotem rotem 0 Nov 9 10:05 3.txt
  • s:即 set-user-ID 权限,如果可执行文件有 s 权限属性,那么任意进程执行该文件时,将自动获得该文件所有者相同的所有权限。如果文件没有 x 权限,却有 s 权限,那么 ls -l 命令将该文件显示为大写的 S。文件只有同时具备 s 权限和 x 权限,才有意义,因为一个文件要应用 set-user-ID 属性,首先要保证其可执行。

例如 ping 文件:

1
2
$ ls -l /bin/ping
-rwsr-xr-x 1 root root 44168 May 8 2014 /bin/ping

由于设置了 s 权限,所以任何文件都能以 root 用户的身份运行,也就被内核允许打开套接字。

  • t:即 restricted deletion flag or sticky bit,称为 “粘滞位” 或 “限制删除标记”。仅仅对目录有效,对文件无效。在一个目录上设了 t 权限位后,(如 /home,权限为 1777) 任何的用户都能够在这个目录下创建文档,但只能删除自己创建的文档 (root 除外),这就对任何用户能写的目录下的用户文档 启到了保护的作用。如果目录 / 文件没有 x 权限,却有 s 权限,则 ls -l 命令将目录 / 文件显示为大写的 T。目录只有同时具备 t 权限和 x 权限,才有意义,因为一个目录如果本来就不允许增删目录项(x 权限),删除其他用户的文件更无须提了。

    • 当一个目录设置了 t 权限时:
      • 所有用户都可以在该目录中创建文件(如果有写权限)
      • 但只能删除自己创建的文件(当然要有写权限,否则也不能创建文件)
      • root 用户除外,始终可以删除任何文件

例如:/tmp/var/tmp 目录供所有用户暂时存取文件,亦即每位用户皆拥有完整的权限进入该目录,去浏览、删除和移动文件。

数字形式的权限(Numeric permissions)

可以用 4 位八进制数(0-7)表示这些文件权限,由 4、2、1 相加得到,0 表示所有权限都没有。

这 4 位的含义如下:

  • 第 1 bit:4 表示 set-user-ID,2 表示 set-group-ID,1 表示 restricted deletion or sticky 属性(粘滞位);
  • 第 2 bit:表示文件所有者的权限:可读(4)、可写(2)、可执行(1);
  • 第 3 bit:表示文件所属组的权限:可读(4)、可写(2)、可执行(1);
  • 第 4 bit:表示其他用户的权限:可读(4)、可写(2)、可执行(1)。

以下是 chmod 的 man page 的说明 [^2]:

A numeric mode is from one to four octal digits (0-7), derived by adding up the bits with values 4, 2, and 1. Omitted digits are assumed to be leading zeros. The first digit selects the set user ID (4) and set group ID (2) and restricted deletion or sticky (1) attributes. The second digit selects permissions for the user who owns the file: read (4), write (2), and execute (1); the third selects permissions for other users in the file’s group, with the same values; and the fourth for other users not in the file’s group, with the same values.

相关命令

ls 命令

ls -l 命令用于查看文件权限。

chmod 命令

chmod 命令用于改变文件权限。

基本用法:

1
2
3
chmod [OPTION]... MODE[,MODE]... FILE...
chmod [OPTION]... OCTAL-MODE FILE...
chmod [OPTION]... --reference=RFILE FILE...

** 大写 X 参数 ** ^1

例如 chmod u+X filenamechmod u-X filename

chmod 的 man page 中介绍如下:

The letters rwxXst select file mode bits for the affected users: read (r), write (w), execute (or search for directories) (x), execute/search only if the file is a directory or
already has execute permission for some user (X)
, set user or group ID on execution (s), restricted deletion flag or sticky bit (t) [^2].

加黑体的话很费解,但是又十分准确,解释如下:

  1. 对所有目录赋予执行权限,这意味着可以执行 cd

  2. 对所有文件,如果原来文件的 ugo(user / group / others)任意一个原先有执行权限,那么动作与小写 -x 参数相同;如果原先没有,那么忽略。例如:

    1
    ls -l

    结果:

    -rw-rw-r– 1 rotem rotem 0 Nov 9 10:55 file1
    -rw-rw-r-x 1 rotem rotem 0 Nov 9 10:56 file2

    可以看到,原来 file1 的 ugo 都不具备执行权限,file2 的 others 具备执行权限。

    • 对 file1 执行 X 动作,将无效:
    1
    2
    sudo chmod u+X file1
    ls -l file1

    -rw-rw-r– 1 rotem rotem 0 Nov 9 10:55 file1

    • 但是对 file2 执行 X,能够生效:
    1
    2
    sudo chmod u+X file2
    ls -l file2

    -rwxrw-r-x 1 rotem rotem 0 Nov 9 10:56 file2

    题外话: chmod -R u-X mydir 该命令无法递归执行,因为当执行完顶层目录的权限更改之后,已经没有权限 cd 顶层目录了,其他执行全部被停止。

  • getfacl/setfacl
  • umask

SELinux 权限

SELinux 提供更为严格的访问控制。
可以用 ls -Z 查看文件的 SELinux 权限(安全上下文)。这部分将来另用一篇博客说明。暂时可以参考文献 [^3]。

参考文献

[^2]: chmod 的 man page
[^3]:https://www.jianshu.com/p/73621cc7c222
[^4]:https://en.wikipedia.org/wiki/User_identifier#Saved_user_ID
[^5]:https://stackoverflow.com/questions/32455684/difference-between-real-user-ID-effective-user-ID-and-saved-user-ID
[^6]:https://cloud.tencent.com/developer/article/1722142