0%

  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

系统文件

/etc/services: 端口列表
/etc/protocols: 协议列表

socket 参数

SO_REUSEADDR

主要功能:允许端口重用。客户端一般不用,只在服务端常见。
更准确地说,它有几个具体效果(以 TCP 为例):

TIME_WAIT 状态下可重绑定

正常情况下,当服务端关闭套接字,端口进入 TIME_WAIT 状态(持续 ~1-4 分钟)

如果不设置 SO_REUSEADDR,你必须等 TIME_WAIT 结束才能再次 bind() 相同端口

设置后,可以立即重新 bind()

多个套接字绑定到同一个端口(但 IP 必须不同)

常见于多播、广播,或者一台机器上多个不同地址绑定同一个端口

Linux 特性:允许 相同 IP 和端口 多个套接字同时监听(结合 SO_REUSEPORT 使用更常见,nginx 就依赖这个做负载均衡)。

TIME_WAIT

TIME_WAIT 只会出现在主动关闭连接的一方。

一般情况:客户端主动关闭 → 客户端 TIME_WAIT,服务端不需要考虑

特殊情况:如果服务端主动关闭 listen_fd(例如服务进程退出后重启),就会产生 TIME_WAIT,需要优化手段。

SO_REUSEPORT

多进程/线程共享端口(同一IP+端口),避免 TIME_WAIT 堆积影响单个进程。前提是都设置了 SO_REUSEPORT)。

内核会 均匀分发新连接 到这些套接字(负载均衡)。

常见用途:

  • 高性能网络服务(nginx、redis、haproxy 等)
  • 利用多进程/多线程提升网络吞吐量

与 SO_REUSEADDR 的区别

选项 功能 使用场景
SO_REUSEADDR 允许端口在 TIME_WAIT 状态下重用 服务端重启,避免 “Address already in use”
SO_REUSEPORT 允许同一端口被多个进程/线程同时绑定,内核负载均衡 高性能多进程网络服务
  • SO_REUSEADDR → 解决 TIME_WAIT 问题
  • SO_REUSEPORT → 解决 多进程监听同一端口的负载均衡

TCP_NODELAY

用于关闭 Nagle 算法。

  • 背景:Nagle 算法

    • Nagle 算法的作用:

      把很多小的 TCP 包合并成一个大包再发送,以减少网络中小包的数量,提高效率。

      规则:如果前一个包还没被确认(ACK),新的小包不要立刻发送,而是先缓存,等到收到 ACK 或者缓冲区积累够大时再发。

      1. 如果要发送的数据很大(≥ MSS,最大报文段长度),直接发。
      2. 如果应用要发小数据:
        • 有未确认的包 → 暂时把新数据放在缓冲区里,不发。
        • 收到了 ACK → 把缓存的数据打包一起发送。
    • 代价:

      会造成 延迟(例如即时通信、RPC 请求这种场景,一次 send() 可能会等下一个小包一起发)。

✅ 建议使用的场景:

  • RPC 框架
  • 即时通讯(IM)
  • 游戏服务器(低延迟要求)
  • 高频短小消息的交互

❌ 不建议使用的场景:

  • 大文件传输(比如 HTTP 下载)
  • 视频流、音频流(关闭 Nagle 反而降低效率)

SO_KEEPALIVE

开启 SO_KEEPALIVE 后,内核在后台做三件事(以 Linux 默认值为例,可能因系统不同而不同):

  • tcp_keepalive_time(默认 7200s = 2小时)
    如果一个连接在 tcp_keepalive_time 时间内都没有任何数据往来,内核会开始发送探测包。

  • tcp_keepalive_intvl(默认 75s)
    探测包之间的间隔。

  • tcp_keepalive_probes(默认 9次)
    如果连续 tcp_keepalive_probes 次探测都没有回应,内核会认为连接已经断开,并通知应用层 read()/recv() 返回 0 或 ECONNRESET。

可以通过 /proc/sys/net/ipv4/tcp_keepalive_* 修改这些参数:

1
2
3
4
5
6
cat /proc/sys/net/ipv4/tcp_keepalive_time
cat /proc/sys/net/ipv4/tcp_keepalive_intvl
cat /proc/sys/net/ipv4/tcp_keepalive_probes

# 修改方式
sysctl -w net.ipv4.tcp_keepalive_time=600

应用场景

  • 长连接 RPC、心跳检测:避免死连接无限挂着。
  • 服务端防止资源泄漏:客户端异常掉线时,服务端能最终释放连接。
  • 中间设备 NAT/防火墙:如果链路长时间无流量,可能被踢掉,Keepalive能保持活动状态(但注意默认周期太长,往往要调低)。

RESET vs KeepAlive

  • 客户端正常断开

    • 客户端调用 close() 或 shutdown()

    • 会向服务端发送 FIN,服务端的 read() 返回 0

    • 这是“主动优雅关闭”,服务端能马上感知

      ✅ 这种情况下不需要 SO_KEEPALIVE

  • 客户端异常掉线(断电、拔网线、进程挂掉)

    • TCP 不能立即知道对方掉线

    • 为什么?

      • TCP 是端到端协议,除非收到 RST 或 FIN,否则内核认为连接仍然存在
      • 异常掉线不会发送 FIN
      • 网络异常(如网线拔掉)也不会发送 RST
      • 这时服务端的 read() / write() 不会马上报错
      • 写时可能阻塞
      • 读时可能一直阻塞

      ✅ 所以需要 SO_KEEPALIVE 来让内核周期性探测,最终发现对方掉线

  • 客户端发送 RST 的情况

    • RST 只在一些场景出现:
      • 客户端向已经关闭的套接字写数据
      • 本地进程调用 abort()
    • 但 异常掉线(掉电、断网、进程被 kill -9)不会发 RST
    • 因此 服务端不能依赖 RST 来发现死连接
场景 服务端能否立即发现 是否需要 Keepalive
客户端正常 close()
客户端异常掉线(断电、拔网线)
客户端本地 abort() 或 write 已关闭套接字 是(会收到 RST)

所以 SO_KEEPALIVE 主要用于发现客户端异常掉线。没有它,长连接可能永远挂在 ESTABLISHED,资源泄漏。

参考

端口0的作用

用户空间和内核空间是隔离的

切换内核态和用户态为什么要内存复制?

问题 描述
安全性 用户程序可能传入非法地址,导致内核崩溃或被利用攻击
稳定性 用户程序可能在 poll() 返回前修改数组,导致内核读取不一致
内存管理 用户空间内存可能被 swap 出或者释放,内核无法保证访问有效

内核态

我们在说“切换到内核态”时,其实指的是 CPU 特权级别从用户态(ring3)切换到内核态(ring0) 的过程。

用户态 vs 内核态

用户态(user mode)

应用程序(比如你写的 C++ 程序)运行的环境。

权限受限,不能直接操作硬件(如磁盘、网卡),也不能随便访问内核内存。

只能通过 系统调用 (syscall) 向内核请求服务。

内核态(kernel mode)

操作系统内核运行的环境。

拥有最高权限(ring0),可以操作 CPU 指令集、内存管理、驱动、硬件寄存器。

系统调用的实现代码就在内核态里。

“切换到内核态”的过程

当你调用一个系统调用,比如:

read(fd, buf, size);

你的程序在 用户态,调用 read()。

实际上 read() 会触发一条特殊指令(例如 Linux x86-64 用 syscall 指令)。

CPU 捕捉到这个指令,自动:

从用户态切换到内核态。

切换到内核栈。

保存用户态寄存器上下文。

跳到系统调用处理函数(比如 sys_read)。

内核态代码执行 sys_read,比如访问文件系统、检查 socket buffer。

处理完后返回,CPU 再切回用户态,恢复上下文,继续执行应用代码。

🔹 为什么要区分用户态/内核态?

安全:用户程序不能直接操作硬件。

稳定性:即使用户程序崩溃,内核和其他进程不会受影响。

性能隔离:内核提供抽象(系统调用接口),避免应用直接干扰底层资源。

✅ 所以,总结一句:
“切换到内核态”就是 CPU 从运行普通用户代码(权限受限)切换到执行内核代码(最高权限)的过程,常常发生在系统调用、异常、硬件中断时。

1. 左值、将亡值、纯右值

C++11的值必定属于:左值、右值(将亡值、纯右值)三者之一。不是左值就是右值。详见值类别。

  • 左值的特点:“有名字、可以取址”。没有名字或者不能取址,则必定是右值。
  • 右值的特点:即将消亡,也就是说“会被析构”。
    • 纯右值:一定没有名字。比如除去string之外字面值常量、函数返回值、运算表达式。
    • 将亡值:即将消亡的值:比如临时变量,一旦离开作用域就会被销毁;可能没有名字,例如函数的返回值(非引用)。

示例:

1
2
3
4
5
int main() {
A(); // 匿名对象的作用域仅限于语句中,一旦离开当前语句,就会析构。
getchar(); // 暂停
return 0;
}

2. 引用、右值引用

右值引用涉及“右值”和“引用”两个概念。

  • 引用不是对象,所以定义一个“右值引用”不会调用构造函数,避免了多余的构造过程。
  • 右值是即将析构的值,把右值绑定到右值引用上,延长了右值的生命期,所以右值对象没有析构。

右值引用规则:

  • 可以把左值绑定到左值引用。
  • 可以把右值绑定到右值引用。
  • 不允许把左值绑定到右值引用。
  • 不允许把右值绑定到左值引用。
  • const左值引用可以接受左值或右值。

示例:

1
2
3
int a1 = 10; // 10是纯右值
const int& aa = 10; // 常量引用可以接受右值
int&& aaa = 10; // 右值引用接受右值

3. 引用和右值引用是左值

引用(包括右值引用)本身是左值,可以取址,但不能对右值取址。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <utility>
using namespace std;

void fun1(int& t) { // 接受一个左值参数
}

void fun2(int&& t) { // 接受一个右值参数,但t本身是左值
}

int main() {
int a = 10;
int&& ra = move(a); // move(a)返回一个右值,ra却是一个左值

fun1(ra); // 正确:ra是左值,可以绑定到左值引用
fun2(move(a)); // 正确:move(a)返回一个右值

return 0;
}

4. 复制构造函数和移动构造函数

为什么右值引用的构造函数被视为“移动”语义?

因为输入参数是一个引用(右值引用也是引用),可以直接访问所引对象的资源并接管它,同时将源对象的资源置空。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

class A {
public:
A() { cout << "A()" << endl; }
A(const A&) { cout << "A(const A&)" << endl; }
A(A&&) { cout << "A(A&&)" << endl; }
};

int main() {
A a;
A b(std::move(a)); // 调用移动构造函数
return 0;
}

5. 完美转发

完美转发是指对模板参数实现完美转发:即输入什么类型(左值、右值)的参数,就是什么类型的参数。

引用折叠规则:

  • 如果有左值引用,优先折叠成左值引用。
  • 如果只有右值引用,参数推导成右值引用。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

void RunCode(int& m) { cout << "lvalue ref" << endl; }
void RunCode(int&& m) { cout << "rvalue ref" << endl; }

template<typename T>
void PerfectForward(T&& t) {
RunCode(static_cast<T&&>(t)); // 保证完美转发
}

int main() {
int a = 10;
PerfectForward(a); // lvalue ref
PerfectForward(move(a)); // rvalue ref
return 0;
}

6. 移动构造函数、移动赋值函数、复制构造函数、复制赋值函数

移动构造函数的注意事项:

  • 移动构造函数不允许抛出异常,建议添加noexcept关键字。
  • 使用std::move_if_noexcept可以在移动构造函数抛出异常时回退到复制构造函数。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

class A {
public:
A() { cout << "A()" << endl; }
A(const A&) { cout << "A(const A&)" << endl; }
A(A&&) noexcept { cout << "A(A&&)" << endl; }
};

int main() {
A a;
A b(std::move(a)); // 调用移动构造函数
return 0;
}

7. 编译器优化

编译器默认会采用“返回值优化”(RVO或NRVO)。要观察移动语义与复制语义的不同,应该关闭编译器优化。

关闭优化命令:

1
g++ -o test main.cc -fno-elide-constructors

8. 合成的移动操作

如果没有定义复制构造/赋值函数,编译器会为我们合成(浅复制)。但如果自定义了复制构造函数、复制赋值运算符或析构函数,编译器将不会合成移动构造/赋值函数。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

class A {
public:
A() { cout << "A()" << endl; }
A(const A&) { cout << "A(const A&)" << endl; }
A(A&&) { cout << "A(A&&)" << endl; }
};

int main() {
A a;
A b(std::move(a)); // 调用移动构造函数
return 0;
}

9. std::move的实现

std::move是一个类型转换,没有完成其他工作。

实现:

1
2
3
4
template<class T>
constexpr typename std::remove_reference<T>::type&& move(T&& t) noexcept {
return static_cast<typename std::remove_reference<T>::type&&>(t);
}

10. unique_ptrstd::move

示例:

1
2
3
4
5
6
7
8
9
#include <iostream>
#include <memory>
using namespace std;

int main() {
unique_ptr<int> up(new int(10));
unique_ptr<int> p = std::move(up); // 现在up为空
return 0;
}

解释:std::move调用unique_ptr的移动构造函数,转移up所拥有的资源,并将up置为空。


11. 右值与sizeof

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct A {
int x, y;
};

int f() {
return 1;
}

int main() {
cout << sizeof(int()) << endl; // 1
cout << sizeof(10) << endl; // 4
cout << sizeof(A) << endl; // 8
cout << sizeof(A()) << endl; // 1
cout << sizeof(f()) << endl; // 4
}

参考资料

  • 《深入理解C++11: C++新特性解析与应用》
  • 《C++ Primer 第五版》
  • C++中文 - API参考文档