Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.3 高级文件 I/O:原子性、fcntl、mmap 与文件锁

本章回答

fcntl 能改什么、不能改什么?mmap 与 read 该怎么选?flock 和 fcntl 记录锁的语义差在哪?怎样让一次配置发布在崩溃后要么是旧版、要么是新版?

读完你会

用 pread/pwrite 消除共享偏移竞态;用 mmap 读写文件并解释 SIGBUS;用记录锁实现单实例;写出「临时文件 + fsync + rename + 目录 fsync」的原子发布。

2.2 给了你五个函数和三条纪律:短读要循环、短写要循环、EINTR 要重试。那是最小可用的文件 I/O。真实程序很快会遇到它解决不了的问题:两个线程共用一个 fd,偏移互相踩;想给日志文件加锁,却不知道 flock 和 fcntl 哪个才对;配置文件写到一半机器断电,重启后读到半个 JSON。

这一章补上三块能力:不共享偏移的读写、把文件映射进内存、让多个进程安全地共享文件。它们都围绕同一个主题:多条系统调用之间没有原子性,必须用内核原语把「检查 + 修改」压成一步。fcntl 是门把手——它既能改 fd 标志,也是记录锁的入口。

2.3.1 fcntl:一个函数,五类操作

#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */);

第三个参数的类型由 cmd 决定:可能是 int、struct flock *,也可能不用。编译器不检查类型,传错就是未定义行为。

类别cmd第三个参数成功返回典型失败
读文件状态标志F_GETFL无flagsEBADF
改文件状态标志F_SETFLint flags0EBADF、EINVAL
读/改 fd 标志F_GETFD / F_SETFDint flagsflags / 0EBADF
复制 fdF_DUPFD / F_DUPFD_CLOEXEC最小可用 fd新 fdEBADF、EINVAL、EMFILE
记录锁F_GETLK / F_SETLK / F_SETLKWstruct flock *0EACCES、EAGAIN、EINVAL、EDEADLK

F_GETFL/F_SETFL 只能改状态位

访问模式(O_RDONLY/O_WRONLY/O_RDWR)在 open 时固定;能改的是 O_APPEND、O_NONBLOCK、O_ASYNC、O_DIRECT、O_NOATIME。

int flags = fcntl(fd, F_GETFL);
if (flags == -1) errExit("F_GETFL");

/* 正确:读-改-写,保留其他状态位 */
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1)
    errExit("F_SETFL");

/* 错误:把访问模式位塞进去。返回 0,但 fd 仍然只读 */
fcntl(fd, F_SETFL, flags | O_RDWR);
`F_SETFL` 的三个坑
  1. 「fcntl(fd, F_SETFL, O_NONBLOCK) 就够了」——这会清掉 O_APPEND 等其他状态位。必须先 F_GETFL。
  2. 「F_SETFL 能改访问模式」——不能,内核静默忽略访问模式位。
  3. 「F_GETFL + F_SETFL 原子」——不是。两个线程同时读-改-写会丢更新;启动时一次设好,或用互斥锁保护。

FD_CLOEXEC 与 F_DUPFD_CLOEXEC

fd 标志只有一位有意义:FD_CLOEXEC。open(O_CLOEXEC) 在创建时原子设置;已打开的 fd 只能事后补:

int fdflags = fcntl(fd, F_GETFD);
if (fdflags == -1) errExit("F_GETFD");
if (fcntl(fd, F_SETFD, fdflags | FD_CLOEXEC) == -1)
    errExit("F_SETFD");

事后补有竞态窗口:多线程下另一个线程可能在这两步之间 fork + exec,fd 漏给子程序。F_DUPFD_CLOEXEC 的第三个参数是「新 fd 的下界」,不是指定号:

int newfd = fcntl(fd, F_DUPFD_CLOEXEC, 3);   /* 新 fd ≥ 3,且 exec 时关闭 */

很多人把它当 dup2 用,结果拿到一个不是自己想要的号。

2.3.2 dup、dup2、dup3:复制「打开文件描述」

dup 家族复制 fd 表项,不复制 struct file。复制出的两个 fd 共享偏移、状态标志和 flock 锁。

调用语义失败
dup(fd)取最小可用 fd,不带 CLOEXECEBADF、EMFILE
dup2(oldfd, newfd)复制到指定号;newfd 已打开则先原子关闭EBADF、EINTR(POSIX 允许)
dup3(oldfd, newfd, flags)同上,flags 只接受 O_CLOEXEC;两参数相等返回 EINVALEBADF、EINVAL、EMFILE

dup2 的关键是原子:close(1); dup(fd); 在多线程下,另一个线程可能抢走 fd 1;dup2(fd, 1) 一步完成关闭与复制。

int saved = dup(STDOUT_FILENO);
if (saved == -1) errExit("dup");

fflush(stdout);                          /* 关键:先刷 stdio 缓冲 */
if (dup2(logfd, STDOUT_FILENO) == -1)
    errExit("dup2");

printf("这行进日志文件\n");
fflush(stdout);                          /* 切回之前再刷一次 */
dup2(saved, STDOUT_FILENO);
close(saved);

dup2 只换 fd,不搬缓冲区;不 fflush 就会把旧缓冲区写进新目标(见 2.4)。dup2(fd, fd) 直接返回 fd,dup3 则返回 EINVAL。

文件描述符、打开文件描述与 inode 的三层关系 fd 是进程内的下标;struct file 保存偏移与标志;inode 表示文件本身。 PROCESS · task_structOPEN FILE DESCRIPTIONINODE / DENTRY CACHEfiles_structfd 表指针0stdin→1stdout→2stderr→3access.log→4access.log→struct file偏移 off_t + 标志 + 引用计数f_count = 2dup / fork 共享同一 open file descriptionf_op->read / write具体文件系统的方法表f_pos共享偏移:两个 fd 指向同一 struct file 时互相影响struct inode类型 / 权限 / 大小 / 时间i_ino / i_nlink磁盘上的对象身份address_spacepage cache 的宿主i_op / i_fopinode 操作与文件操作fd → filefile → inode同一 inode 可被多个 struct file 打开;同一 struct file 可被多个 fd 共享(dup/fork)
读图:先看左边 fd 表:0/1/2 指向终端,3/4 指向同一日志文件。3 和 4 是两个独立的 struct file,各有自己的偏移;如果它们由 dup() 或 fork() 产生,则共享同一个 struct file,偏移也共享——这是「两个进程同时写日志却互相覆盖」的根源。

读图:fd 3 与 fd 4 指向同一个 struct file 时,f_pos 只有一份。dup/fork 产生这种关系;两次 open 产生两个 struct file,偏移各自独立。

2.3.3 pread/pwrite 与 readv/writev

ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

pread/pwrite 在 offset 处读写,不改变 f_pos。返回值与 read/write 相同:可能短读/短写;-1 时 errno 为 EBADF、EINVAL(负偏移或 O_DIRECT 未对齐)、ESPIPE(管道/socket)、EINTR。

readv/writev 把多段缓冲区拼成一次 I/O,返回总字节数,可能短读/短写;errno 为 EINVAL(iovcnt < 0 或超过 IOV_MAX)、EFAULT、EINTR、EAGAIN。Linux 的 IOV_MAX 是 1024,用 sysconf(_SC_IOV_MAX) 查。

短写后不能重试整个数组,要推进 iovec。下面程序的 writev_full() 就是正确写法:每轮只提交剩余段,按已写字节数推进或切短当前段。

/* iov_log.c — writev 写头+体;pwrite 原地改 seq */
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/uio.h>
#include <unistd.h>

struct rec_hdr { uint32_t len, seq; };

static ssize_t writev_full(int fd, struct iovec *iov, int iovcnt)
{
    size_t total = 0;
    int i = 0;

    while (i < iovcnt) {
        ssize_t n = writev(fd, &iov[i], iovcnt - i);
        if (n == -1) {
            if (errno == EINTR) continue;
            return -1;
        }
        if (n == 0) { errno = EIO; return -1; }

        total += (size_t)n;
        size_t left = (size_t)n;
        while (i < iovcnt && left >= iov[i].iov_len) {
            left -= iov[i].iov_len;
            i++;
        }
        if (i < iovcnt) {
            iov[i].iov_base = (char *)iov[i].iov_base + left;
            iov[i].iov_len -= left;
        }
    }
    return (ssize_t)total;
}

int main(void)
{
    const char *msg = "disk full on /var; retry scheduled";
    struct rec_hdr h = { .len = (uint32_t)strlen(msg), .seq = 0 };
    struct iovec iov[2] = {
        { .iov_base = &h, .iov_len = sizeof h },
        { .iov_base = (void *)msg, .iov_len = strlen(msg) },
    };

    int fd = open("iov_demo.bin", O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
    if (fd == -1) { perror("open"); return 1; }
    if (writev_full(fd, iov, 2) == -1) { perror("writev"); return 1; }

    uint32_t seq = 1;
    if (pwrite(fd, &seq, sizeof seq, offsetof(struct rec_hdr, seq)) == -1) {
        perror("pwrite"); return 1;
    }
    if (fsync(fd) == -1) { perror("fsync"); return 1; }

    struct rec_hdr back;
    char body[64] = {0};
    ssize_t n;
    do { n = pread(fd, &back, sizeof back, 0); } while (n == -1 && errno == EINTR);
    if (n != (ssize_t)sizeof back || back.len >= sizeof body) {
        fprintf(stderr, "bad header\n"); return 1;
    }
    do { n = pread(fd, body, back.len, (off_t)sizeof back); } while (n == -1 && errno == EINTR);
    if (n != (ssize_t)back.len) { fprintf(stderr, "bad body\n"); return 1; }

    printf("seq=%u len=%u body=%s\n", back.seq, back.len, body);
    close(fd);
    unlink("iov_demo.bin");
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 iov_log.c -o iov_log
./iov_log     # seq=1 len=30 body=disk full on /var; retry scheduled
`pwrite` 与 `O_APPEND` 的 Linux 行为

POSIX 规定 pwrite 忽略 O_APPEND,在指定偏移写。Linux 不遵守:fd 带 O_APPEND 时,pwrite 仍追加到末尾,offset 被忽略(见 man 2 pwrite 的 BUGS)。可移植代码不要在 O_APPEND fd 上用 pwrite,或先清掉 O_APPEND。

2.3.4 mmap:把文件页映射进地址空间

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
int msync(void *addr, size_t length, int flags);

mmap 在进程地址空间建一个 VMA(vm_area_struct),把 [offset, offset+length) 按页映射进来。访问映射区就是访问 page cache;缺页时由 filemap_fault() 读入文件页。它既不「把文件读进内存」,也不绕过内核。

  进程虚拟地址空间                        page cache                 磁盘
┌────────────────────┐
│ 代码 / 堆 / 栈      │
│ ┌────────────────┐ │  VMA: vm_start..vm_end
│ │  mmap 区域      │─┼────► 页表项 ─────► page cache 页 ─────► 文件块
│ │  addr .. addr+n │ │      缺页时 filemap_fault 读入
│ └────────────────┘ │
└────────────────────┘
   MAP_SHARED:写页 = 改 page cache;msync/回写后落盘,其他进程可见
   MAP_PRIVATE:写页触发 COW,改动只在本进程,文件不变
参数取值含义
protPROT_READ/WRITE/EXEC/NONE映射区权限,不能超过 fd 的打开模式
flagsMAP_SHARED / MAP_PRIVATE写回文件 / 写时复制
MAP_ANONYMOUS不关联文件,fd 传 -1
MAP_FIXED强制地址,会覆盖已有映射,危险
MAP_POPULATE预读全部页

成功返回映射区首地址;失败返回 MAP_FAILED((void *)-1),不是 NULL。errno:EACCES(fd 模式与 prot 冲突)、EINVAL(offset 未页对齐或 length==0)、ENODEV(文件系统不支持)、ENOMEM、EOVERFLOW。

两个硬约束:offset 必须页对齐;length 向上取整到页,最后一页超出 EOF 的部分读为 0,但写回去不会扩展文件。

SIGBUS:映射后文件被截断

mmap 只建映射,不锁文件大小。若另一个进程 truncate 了文件,访问超出新 EOF 的整页会触发 SIGBUS,默认终止进程。read 在同样情况下只返回 0。这就是「mmap 不能假设文件大小不变」的原因。

int fd = open("bus.bin", O_RDWR | O_CREAT | O_TRUNC, 0644);
ftruncate(fd, 8192);
char *p = mmap(NULL, 8192, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
ftruncate(fd, 4096);          /* 另一个进程做同样的事也会触发 */
printf("%c\n", p[4096]);      /* SIGBUS */

正确做法:访问前 fstat 比对 st_size,或处理 SIGBUS 并重建映射。不要把「文件不会被截断」当前提,除非你持有写锁。

msync(addr, len, MS_SYNC) 把脏页写回并等待;MS_ASYNC 只排队;MS_INVALIDATE 让其他映射失效。addr 必须页对齐,否则 EINVAL。munmap 不保证写回。madvise 只是提示:MADV_SEQUENTIAL 加大预读、MADV_WILLNEED 提前读、MADV_DONTNEED 丢页。

完整示例:原地改写并验证

/* mmap_scan.c — 映射临时文件、原地大写、msync 后读回验证 */
#include <ctype.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>        /* mkstemp */
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(void)
{
    char path[] = "/tmp/mmap_demo.XXXXXX";
    int fd = mkstemp(path);
    if (fd == -1) { perror("mkstemp"); return 1; }

    const char *text = "hello mmap\nsecond line\n";
    size_t len = strlen(text);
    if (write(fd, text, len) != (ssize_t)len) { perror("write"); return 1; }

    struct stat st;
    if (fstat(fd, &st) == -1) { perror("fstat"); return 1; }

    char *p = mmap(NULL, (size_t)st.st_size, PROT_READ | PROT_WRITE,
                   MAP_SHARED, fd, 0);
    if (p == MAP_FAILED) { perror("mmap"); return 1; }

    for (off_t i = 0; i < st.st_size; i++)
        p[i] = (char)toupper((unsigned char)p[i]);

    if (msync(p, (size_t)st.st_size, MS_SYNC) == -1)
        perror("msync");                 /* 记录;munmap 后仍可能回写 */
    if (munmap(p, (size_t)st.st_size) == -1)
        perror("munmap");

    char buf[64] = {0};
    ssize_t n = pread(fd, buf, sizeof buf - 1, 0);
    if (n == -1) { perror("pread"); return 1; }
    printf("file now: %s", buf);

    close(fd);
    unlink(path);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 mmap_scan.c -o mmap_scan
./mmap_scan        # file now: HELLO MMAP / SECOND LINE
`mmap` 不是 `read` 的万能替代

适合:随机访问大文件、多进程共享只读数据、把文件当数组。 不适合:小文件(建映射比读一次贵)、流式处理(read + 预读更简单)、网络文件系统(缺页延迟不可控)、O_DIRECT 场景、32 位地址空间里映射超大文件。

映射建立之后:mprotect、mlock、mincore 与 mremap

mmap 建好映射后,还可以改保护、锁页、查询驻留、扩缩容。这组调用把「虚拟内存操作」补齐,边界比 mmap 更细:

int mprotect(void *addr, size_t len, int prot);       /* 改权限,addr/len 页对齐 */
int mlock(const void *addr, size_t len);              /* 锁在 RAM,不换出 */
int munlock(const void *addr, size_t len);
int mincore(void *addr, size_t len, unsigned char *vec); /* 每页一字节,bit0=驻留 */
void *mremap(void *old, size_t oldsz, size_t newsz, int flags, ...);

mprotect 最常见的用途是「先读写、后只读」和 guard page:mmap(PROT_NONE) 一块,再对中间 mprotect(PROT_READ|PROT_WRITE)。给 MAP_SHARED 的文件映射加 PROT_WRITE 时,fd 必须以可写方式打开,否则 EACCES;PROT_EXEC 还受 noexec 挂载与 W^X 策略限制。

mlock 把页固定在物理内存,避免实时路径被换页打断;未特权进程受 RLIMIT_MEMLOCK 限制(systemd 服务还有 MemoryLock=)。锁不跨 fork,execve 时自动解除。mincore 只告诉你「现在在不在内存」,结果随时会变,适合监控而不是控制流。mremap 用 MREMAP_MAYMOVE 扩缩映射,可能搬家并返回新地址;realloc 对大块内存会走它。

2.3.5 零拷贝:sendfile、splice、copy_file_range

接口输入输出典型用途
sendfile文件 fdsocket(2.6.33 起也可为文件)静态文件服务器
splice至少一端是管道文件/socket代理、转发
copy_file_range文件 fd文件 fd同机复制、reflink

三者都返回「实际传输字节数」,可能为 0(EOF)或短于请求,必须循环。errno:EINVAL(fd 类型不支持、源目标相同)、EXDEV(跨文件系统)、EOPNOTSUPP(文件系统不支持)、ENOSYS(内核太老)、EAGAIN/EPIPE(管道)。

/* 返回复制字节数;-2 表示应回退到 read/write,-1 表示真错误 */
static off_t copy_range(int in, int out, off_t len)
{
    off_t done = 0;

    while (done < len) {
        ssize_t n = copy_file_range(in, NULL, out, NULL,
                                    (size_t)(len - done), 0);
        if (n == -1) {
            if (errno == EINTR) continue;
            if (errno == EXDEV || errno == EOPNOTSUPP || errno == ENOSYS)
                return -2;
            return -1;
        }
        if (n == 0) break;
        done += n;
    }
    return done;
}
零拷贝的三个误解
  1. 「零拷贝 = 完全不拷贝」——数据仍要从 page cache 到网卡(DMA),省掉的是「内核 → 用户 → 内核」那一份。
  2. 「copy_file_range 一定更快」——同文件系统且支持 reflink 时快;跨文件系统可能退化或返回 EXDEV。
  3. 「splice 可任意连两个 fd」——至少一端必须是管道,否则 EINVAL。

2.3.6 文件锁:flock 与 fcntl 记录锁

维度flock(BSD)fcntl 记录锁(System V)
锁范围整个文件字节区间
归属打开文件描述进程
fork 后子进程继承并共享子进程不继承
释放LOCK_UN 或最后一个 fd 关闭进程关闭任意指向该文件的 fd 即释放全部
阻塞控制LOCK_NBF_SETLK 非阻塞 / F_SETLKW 阻塞
/proc/locksFLOCKPOSIX
struct flock {
    short l_type;     /* F_RDLCK / F_WRLCK / F_UNLCK */
    short l_whence;   /* SEEK_SET / SEEK_CUR / SEEK_END */
    off_t l_start;    /* 相对 l_whence 的起点 */
    off_t l_len;      /* 0 表示到文件末尾,含未来增长 */
    pid_t l_pid;      /* F_GETLK 返回持锁者 */
};

F_SETLK 冲突时返回 -1,errno 为 EACCES 或 EAGAIN(Linux 用 EAGAIN);F_SETLKW 可能被信号打断返回 EINTR,检测到死锁返回 EDEADLK。F_GETLK 不加锁:无人阻挡时把 l_type 改成 F_UNLCK,否则填入阻挡者信息。

/* lockfile.c — 用记录锁实现单实例;第二个实例退出码 2 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc != 2) { fprintf(stderr, "usage: %s LOCKFILE\n", argv[0]); return 1; }

    int fd = open(argv[1], O_RDWR | O_CREAT | O_CLOEXEC, 0644);
    if (fd == -1) { perror("open"); return 1; }

    struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET,
                        .l_start = 0, .l_len = 0 };
    if (fcntl(fd, F_SETLK, &fl) == -1) {
        if (errno == EAGAIN || errno == EACCES) {
            fprintf(stderr, "another instance holds %s\n", argv[1]);
            return 2;
        }
        perror("fcntl"); return 1;
    }

    printf("pid %ld holds the lock; sleeping 30s\n", (long)getpid());
    sleep(30);                      /* 关闭 fd 或进程退出即释放 */
    close(fd);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 lockfile.c -o lockfile
./lockfile /tmp/demo.lock &      # 第一个实例持锁
./lockfile /tmp/demo.lock; echo "exit=$?"   # exit=2
grep demo /proc/locks
$ ./lockfile /tmp/demo.lock &
[1] 48211
pid 48211 holds the lock; sleeping 30s
$ ./lockfile /tmp/demo.lock; echo "exit=$?"
another instance holds /tmp/demo.lock
exit=2
$ grep demo /proc/locks
1: POSIX  ADVISORY  WRITE 48211 08:01:7340033 0 EOF

这一行的字段依次是:序号、锁类型(POSIX/FLOCK/OFDLCK)、ADVISORY、READ/WRITE、持有者 PID、major:minor:inode、起始字节、结束字节(EOF 表示到文件末尾)。OFD 锁的 PID 是 -1,因为锁属于打开文件描述而不是某个进程;lslocks 的输出更易读。/proc/locks 从 Linux 4.9 起按 PID 命名空间过滤,容器里看不到宿主机的锁。不要按 MANDATORY 设计程序:强制锁在 Linux 5.15 已被移除。

三个必须记住的陷阱:

  1. 关闭任意 fd 就释放全部记录锁。同一文件打开两次,close 其中一个,另一个 fd 上的锁也没了。解决办法:加锁后别再 open 同一文件,或改用 F_OFD_SETLK(Linux 3.15+,锁归属打开文件描述)。
  2. fork 后子进程不继承记录锁,但继承 flock。用 flock 做守护进程锁时,子进程共享锁,父进程退出不会释放。
  3. 锁与 stdio 缓冲的顺序:加锁 → 写 → fflush → 解锁。否则另一个进程拿到锁时看不到你的输出。

lockf():老代码里的 fcntl 简写

lockf(fd, F_LOCK, len) 是 fcntl(F_SETLK/F_SETLKW) 的便捷封装:F_LOCK 等价「写锁 + 阻塞」,F_TLOCK 非阻塞,F_ULOCK 解锁,F_TEST 测试。它只能锁从当前偏移开始的区间,不能指定起始偏移;要锁任意区间,仍用 fcntl 的 struct flock。

#include <unistd.h>
lseek(fd, 0, SEEK_SET);
if (lockf(fd, F_TLOCK, 0) == -1) {   /* len=0 表示到文件末尾 */
    if (errno == EACCES || errno == EAGAIN) { /* 已被占用 */ }
}
...
lockf(fd, F_ULOCK, 0);

lockf 与 fcntl 锁在 Linux 上共享同一套 POSIX 记录锁;flock 是另一套(BSD)。三者混用不会互斥,这是老代码迁移时最常见的坑。

2.3.7 原子发布:临时文件 + fsync + rename + 目录 fsync

O_TRUNC 先把文件清空;崩溃时留下空文件或半个文件,读进程也可能看到半成品。正确做法是写新的,再原子替换:

int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0644); /* 同目录 */
write_full(fd, data, len);
fsync(fd);                 /* 1. 数据落盘 */
close(fd);
rename(tmp, dst);          /* 2. 原子替换(同一文件系统) */
fsync(dirfd);              /* 3. 目录项落盘 */

第 3 步常被忽略:rename 改的是目录项。只 fsync 文件,断电后可能出现「新文件内容在、名字还是旧的」甚至「两个名字都丢」。rename 在同一文件系统内保证 dst 要么旧、要么新,不会不存在;跨文件系统返回 EXDEV。

/* publish.c — 取排他锁,写临时文件,fsync,rename,fsync 目录 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static int write_full(int fd, const char *buf, size_t len)
{
    size_t done = 0;
    while (done < len) {
        ssize_t n = write(fd, buf + done, len - done);
        if (n == -1) { if (errno == EINTR) continue; return -1; }
        if (n == 0) { errno = EIO; return -1; }
        done += (size_t)n;
    }
    return 0;
}

static int fsync_parent(const char *path)
{
    char dir[4096];
    const char *slash = strrchr(path, '/');

    if (!slash) snprintf(dir, sizeof dir, ".");
    else if (slash == path) snprintf(dir, sizeof dir, "/");
    else snprintf(dir, sizeof dir, "%.*s", (int)(slash - path), path);

    int fd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
    if (fd == -1) return -1;
    int rc = fsync(fd), saved = errno;
    close(fd);
    errno = saved;
    return rc;
}

int main(int argc, char *argv[])
{
    if (argc != 3) { fprintf(stderr, "usage: %s DEST CONTENT\n", argv[0]); return 1; }

    char lock[4096], tmp[4096];
    snprintf(lock, sizeof lock, "%s.lock", argv[1]);
    snprintf(tmp, sizeof tmp, "%s.tmp.%ld", argv[1], (long)getpid());

    int lockfd = open(lock, O_RDWR | O_CREAT | O_CLOEXEC, 0644);
    if (lockfd == -1) { perror("lock"); return 1; }
    struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET,
                        .l_start = 0, .l_len = 0 };
    if (fcntl(lockfd, F_SETLK, &fl) == -1) { perror("lock"); return 1; }

    int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0644);
    if (fd == -1) { perror("open tmp"); return 1; }

    if (write_full(fd, argv[2], strlen(argv[2])) == -1 ||
        write_full(fd, "\n", 1) == -1 ||
        fsync(fd) == -1) {
        perror("write/fsync");
        close(fd);
        unlink(tmp);
        return 1;
    }
    if (close(fd) == -1) { perror("close"); unlink(tmp); return 1; }

    if (rename(tmp, argv[1]) == -1) { perror("rename"); unlink(tmp); return 1; }
    if (fsync_parent(argv[1]) == -1)
        perror("fsync dir");        /* 某些文件系统不支持目录 fsync */

    printf("published %zu bytes to %s\n", strlen(argv[2]) + 1, argv[1]);
    close(lockfd);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 publish.c -o publish
./publish /tmp/app.conf '{"workers":4}' && cat /tmp/app.conf
strace -e trace=openat,write,fsync,rename,close ./publish /tmp/app.conf '{"workers":8}' 2>&1 | tail -8
原子发布的四个坑
  1. 「临时文件放 /tmp 再 rename」——跨文件系统 rename 返回 EXDEV,临时文件必须与目标同目录。
  2. 「fsync 文件就够了」——目录项也要 fsync。
  3. 「O_TRUNC + 写是原子的」——不是,先清空再写,崩溃留空文件。
  4. 「rename 后要删临时文件」——临时名字已经消失,unlink(tmp) 返回 ENOENT,别当错误。

O_TMPFILE 可以连临时名字都不要:

int fd = open("/srv/app", O_TMPFILE | O_WRONLY | O_CLOEXEC, 0644);
write_full(fd, data, len);
fsync(fd);
char proc[64];
snprintf(proc, sizeof proc, "/proc/self/fd/%d", fd);
linkat(AT_FDCWD, proc, AT_FDCWD, "/srv/app/conf.new", AT_SYMLINK_FOLLOW);

linkat(fd, "", ..., AT_EMPTY_PATH) 更直接,但需要 CAP_DAC_READ_SEARCH;普通进程用 /proc/self/fd 这条。

2.3.8 O_DIRECT、fallocate 与 ftruncate

O_DIRECT 绕过 page cache,代价是对齐:缓冲区地址、偏移、长度都必须是逻辑块大小(通常 512 或 4096)的整数倍,否则返回 EINVAL。它适合自建缓存、数据库 WAL;普通应用用它通常更慢。

void *buf;
if (posix_memalign(&buf, 4096, 4096) != 0) errExit("posix_memalign");
ssize_t n = pread(fd, buf, 4096, 0);       /* 三者都按 4096 对齐 */

fallocate(fd, mode, offset, len) 真正分配磁盘块:默认扩展文件大小,FALLOC_FL_KEEP_SIZE 只分配不扩展,FALLOC_FL_PUNCH_HOLE|FALLOC_FL_KEEP_SIZE 打洞,FALLOC_FL_ZERO_RANGE 清零。失败:EOPNOTSUPP、ENOSPC、EINVAL、EFBIG。

ftruncate(fd, len) 截断或扩展;扩展部分是稀疏空洞,读为 0,不占块。这正是 st_size 与 st_blocks 不同的原因(见 2.5):

truncate -s 1G sparse.img && ls -lh sparse.img && du -h sparse.img   # 1.0G / 0

ftruncate 之后,已有 mmap 访问超出新 EOF 的页会触发 SIGBUS(2.3.4)。

2.3.9 mmap 参数与映射类型全表

这一节回答:MAP_SHARED 与 MAP_PRIVATE 之外,那些 MAP_* 标志各自改变什么?匿名映射、文件映射、巨页映射在 fork 后如何表现?

2.3.4 给了 mmap 的基本模型。Kerrisk 第 49 章把 flags 分成「必须二选一」和「可叠加」两类(p882、p893):MAP_SHARED/MAP_PRIVATE 必须有且只有一个,其余是可选的修饰位。

flag作用边界/失败
MAP_SHARED写反映到底层文件,对其他映射可见文件映射要求 PROT_WRITE 时 fd 可写
MAP_PRIVATE写触发 COW,改动只在本进程fd 只读也可写(只要 PROT_WRITE)
MAP_SHARED_VALIDATE同 MAP_SHARED,但未知 flag 返回 EOPNOTSUPP用于探测新特性
MAP_ANONYMOUS无底层文件,fd 传 -1内容初始化为 0(除非 MAP_UNINITIALIZED)
MAP_FIXED强制使用 addr,覆盖已有映射危险;地址冲突会静默替换
MAP_FIXED_NOREPLACE地址被占用则 EEXIST,不覆盖Linux 4.17+
MAP_POPULATE建映射时预读/预缺页不保证之后不被换出
MAP_NONBLOCK只与 MAP_POPULATE 合用,不阻塞预读可能什么都没填充
MAP_LOCKED等价于建映射后 mlock受 RLIMIT_MEMLOCK 约束
MAP_NORESERVE不为交换空间预留与 overcommit_memory 交互
MAP_HUGETLB使用巨页需要预留巨页/权限,否则 ENOMEM
MAP_STACK提示内核这是栈目前基本无效果
MAP_GROWSDOWN向下增长的映射(旧栈语义)易被滥用,现代程序不用
MAP_SYNC写直达持久内存(DAX)只对支持 DAX 的文件有效
MAP_DENYWRITE/MAP_EXECUTABLE历史兼容位Linux 忽略

MAP_FIXED 与 MAP_FIXED_NOREPLACE 的区别值得单独记:前者会解除已有映射再建立新映射,后者在目标区间已被占用时返回 EEXIST。做「把文件的一段映射到预留地址」时,正确的顺序是先用 mmap(NULL, ...) 建匿名保护区,再用 MAP_FIXED 覆盖其中一段;不要用 MAP_FIXED 去赌一个「应该空闲」的地址。

四种映射类型

类型创建方式写操作fork 后
私有文件映射MAP_PRIVATE + fdCOW,不写回文件父子各自 COW
共享文件映射MAP_SHARED + fd写 page cache,最终回写文件共享同一物理页
私有匿名映射`MAP_PRIVATEMAP_ANONYMOUS`本进程私有,初始 0
共享匿名映射`MAP_SHAREDMAP_ANONYMOUS`所有映射者共享

第四种是 fork 后父子通信最便宜的方式之一(不需要文件、不需要 shm_open);memfd_create + MAP_SHARED 则把「共享匿名内存」变成了「有 fd、可传、可加 seal」的对象(2.3.12)。

prot 与 fd 打开模式的精确规则

Kerrisk 49.4.4(p846)把交互规则讲得很细,比「prot 不能超过 fd 模式」更精确:

fd 模式MAP_PRIVATEMAP_SHARED
O_RDONLY任意 prot(写只影响私有副本)只能 PROT_READ(可加 PROT_EXEC)
O_WRONLY任何 prot 都 EACCES任何 prot 都 EACCES
O_RDWR任意 prot任意 prot

原因在硬件:很多架构不支持「只写可映射」的页,PROT_WRITE 隐含可读;而 O_WRONLY 打开的文件不允许读出初始内容,所以内核直接拒绝。mmap 成功后可以立刻 close(fd),映射仍然有效——struct file 的引用计数还在。

`mmap` 标志的五个坑
  1. 「MAP_PRIVATE 写会写回文件」——不会,COW 后的页只在本进程;想落盘用 MAP_SHARED + msync。
  2. 「MAP_ANONYMOUS 必须传 fd=0」——Linux 忽略 fd,但可移植代码应传 -1;MAP_ANONYMOUS 需要 _GNU_SOURCE/_DEFAULT_SOURCE。
  3. 「MAP_POPULATE 保证之后不缺页」——只保证建映射时预读;内存压力下仍会被换出。
  4. 「MAP_LOCKED 不需要权限」——受 RLIMIT_MEMLOCK 约束,超限 ENOMEM/EPERM。
  5. 「MAP_FIXED 会失败而不是覆盖」——它会静默解除重叠映射,踩掉别人的映射;要安全用 MAP_FIXED_NOREPLACE。

2.3.10 缺页路径与 SIGBUS/SIGSEGV 的分界

这一节回答:访问映射区时,什么时候是 SIGSEGV,什么时候是 SIGBUS?为什么「文件被截断」比「越界」更难防?

mmap 只建立 VMA(vm_area_struct),不读文件。第一次访问触发缺页异常,内核走 handle_mm_fault():

情况内核路径结果
页在 page cachefilemap_fault → filemap_map_pages建立 PTE,无磁盘 I/O
页不在 cachefilemap_fault → page_cache_sync_readahead/read_folio读盘后映射
私有映射写do_wp_page(COW)复制一页,改 PTE 为可写
共享映射写do_shared_fault → ->page_mkwrite标记页脏,等待回写
地址不在任何 VMAdo_user_addr_fault → bad_areaSIGSEGV
地址在 VMA 内但超出文件 EOF 的整页filemap_fault 返回 VM_FAULT_SIGBUSSIGBUS

关键分界(Kerrisk 49.4.3,p845):

  • 超出映射长度的地址 → SIGSEGV(不在 VMA 里)。
  • 在映射长度内、但落在文件 EOF 之后的整页 → SIGBUS(VMA 有,文件没有对应页)。
  • EOF 所在页的尾部(同一页内、超出 EOF 的字节)→ 可访问,读为 0,写入不落盘、不与其他进程共享。

所以「映射 8 KiB、文件实际 5 KiB」时,偏移 5000–8191 可读为 0,偏移 8192 起才 SIGBUS;而「另一个进程把文件截断到 4 KiB」会让原来有效的第 5 KiB 变成 SIGBUS。read 在同样情况下只返回 0,这是 mmap 与 read 最重要的行为差异。

/* mmap_bus.c — 子进程访问被截断文件的页,父进程报告 SIGBUS */
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    const char *path = "mmap_bus.bin";
    int fd = open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
    if (fd == -1) { perror("open"); return 1; }
    if (ftruncate(fd, 8192) == -1) { perror("ftruncate"); return 1; }

    char *p = mmap(NULL, 8192, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (p == MAP_FAILED) { perror("mmap"); return 1; }

    int sync[2];
    if (pipe(sync) == -1) { perror("pipe"); return 1; }

    pid_t pid = fork();
    if (pid == -1) { perror("fork"); return 1; }

    if (pid == 0) {
        close(sync[1]);
        char c;
        if (read(sync[0], &c, 1) != 1) _exit(2);   /* 等父进程截断 */
        volatile char x = p[4096];                 /* 第 5 KiB:SIGBUS */
        printf("child read %d (unexpected)\n", (int)x);
        _exit(0);
    }

    close(sync[0]);
    /* 父进程把文件截断到 4 KiB,再放行子进程 */
    if (ftruncate(fd, 4096) == -1) { perror("ftruncate 2"); return 1; }
    if (write(sync[1], "x", 1) != 1) { perror("write pipe"); return 1; }
    close(sync[1]);

    int status;
    if (waitpid(pid, &status, 0) == -1) { perror("waitpid"); return 1; }
    if (WIFSIGNALED(status))
        printf("child killed by signal %d (%s)\n",
               WTERMSIG(status), strsignal(WTERMSIG(status)));
    else
        printf("child exited normally, status=%d\n", WEXITSTATUS(status));

    munmap(p, 8192);
    close(fd);
    unlink(path);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 mmap_bus.c -o mmap_bus
./mmap_bus
# child killed by signal 7 (Bus error)

SIGBUS 的默认动作是终止并 core dump;生产程序若必须使用 mmap,要么在访问前 fstat 检查 st_size,要么安装 SIGBUS 处理器用 sigsetjmp/siglongjmp 跳出(注意 siglongjmp 不在 POSIX 的 async-signal-safe 列表里,只能在「没有其他线程、且能接受未定义行为风险」的受限场景用)。更稳的做法是不把文件大小当不变式:每次访问前重新 fstat,或用文件锁阻止截断。

为什么内核不直接给 `SIGSEGV`

SIGSEGV 表示「你访问了不属于你的地址」,SIGBUS 表示「地址属于你,但背后的对象不在了」。前者是程序 bug,后者是外部状态变化(文件被截断、设备被拔)。分开报错,调试时才能区分「越界」与「文件变了」。

2.3.11 msync、madvise、mlock、mincore 与 mremap 全表

这一节回答:映射建立之后,怎样控制回写、预读、驻留与扩缩容?哪些调用只是「提示」,哪些有强制语义?

msync:只对共享映射有意义

int msync(void *addr, size_t length, int flags);

成功返回 0,失败 -1;EINVAL(addr 未页对齐、flags 非法)、ENOMEM(区间无映射)、EBUSY(MS_INVALIDATE 与进行中的 I/O 冲突)。MS_SYNC 等数据落到设备;MS_ASYNC 只保证对其他进程可见(进了 page cache),不保证落盘;MS_INVALIDATE 让缓存副本失效,下次访问重新从文件读。

Linux 是统一页缓存(unified page cache):mmap 与 read/write 看到的是同一份 page cache,所以 msync 的主要作用是强制回写,而不是「让其他进程看见」。Kerrisk 49.5(p847)特别指出,SUSv3 不要求统一页缓存,可移植程序仍需在映射与 read 混用时正确使用 msync/MS_INVALIDATE。

madvise:一组「建议」,不是命令

advice语义注意事项
MADV_NORMAL默认预读
MADV_RANDOM随机访问,禁用预读
MADV_SEQUENTIAL顺序访问,激进预读可能提前释放已读页
MADV_WILLNEED预读进内存不保证保留多久
MADV_DONTNEED不再需要这些页私有映射会丢弃修改,见下
MADV_FREE页可回收,但保留内容直到压力Linux 4.5+;与 DONTNEED 不同
MADV_REMOVE释放并打洞(共享映射)需要文件系统支持
MADV_PAGEOUT主动回收页Linux 5.4+
MADV_COLD标记为冷页Linux 5.4+
MADV_HUGEPAGE/MADV_NOHUGEPAGE控制 THP
MADV_DONTFORK/MADV_DOFORKfork 时是否保留影响子进程地址空间
MADV_WIPEONFORK/MADV_KEEPONFORK子进程是否清零Linux 4.14+
MADV_MERGEABLE/MADV_UNMERGEABLEKSM 合并有侧信道风险

MADV_DONTNEED 在 Linux 上的语义是破坏性的:对 MAP_PRIVATE 匿名映射,它丢弃页上的修改,下一次访问读回 0;对文件私有映射,重新从文件读。Kerrisk 50.4(p866)明确提醒:可移植程序不应依赖这种语义,其他 UNIX 可能只是「建议换出」。

mlock/mlockall:锁进物理内存

int mlock(const void *addr, size_t len);
int mlock2(const void *addr, size_t len, int flags);   /* MLOCK_ONFAULT */
int mlockall(int flags);                                /* MCL_CURRENT|MCL_FUTURE|MCL_ONFAULT */

mlock 以页为单位,addr 不必页对齐(内核向下取整),len 向上取整。失败:ENOMEM(物理内存不足或超 RLIMIT_MEMLOCK)、EPERM(无 CAP_IPC_LOCK 且超限)、EINVAL(flags 非法)。

语义要点(Kerrisk 50.2,p861–863):

  • 非特权进程受 RLIMIT_MEMLOCK 软限制;2.6.9 起默认是 8 页(32 KiB on x86-32),很多发行版调高。
  • 锁不被 fork 继承,exec 时解除;munmap/MAP_FIXED 覆盖也会解除。
  • 锁不叠加:同一页 mlock 两次,munlock 一次就解锁;但同一文件映射到两个地址分别加锁,要两次 munlock。
  • mlockall(MCL_FUTURE) 会让后续 mmap/sbrk 可能失败,甚至栈增长触发 SIGSEGV。

/proc/PID/status 的 VmLck 字段给出当前锁住的字节数;ulimit -l 是 RLIMIT_MEMLOCK 的 shell 视图。

mincore:查询驻留,不保证未来

int mincore(void *addr, size_t length, unsigned char *vec);

vec 每页一个字节,最低位为 1 表示该页当前在 RAM。length 向上取整到页;vec 至少要 (length + PAGE_SIZE - 1) / PAGE_SIZE 字节。失败:ENOMEM(区间无映射)、EFAULT、EINVAL(addr 未页对齐)。结果是快照:查询返回后页可能立刻被换出,只能用于监控/启发式,不能用于正确性判断。

mremap:扩缩映射

void *mremap(void *old_address, size_t old_size, size_t new_size, int flags, ...);

MREMAP_MAYMOVE 允许内核搬家(返回新地址);不带它且原地空间不足返回 ENOMEM。MREMAP_FIXED(需同时 MAYMOVE)把映射搬到指定地址,额外参数 new_address。MREMAP_DONTUNMAP(5.7+)把映射搬到新地址但保留旧映射,用于 userfaultfd 场景。

成功返回新起始地址,可能不等于旧地址;因此映射里的指针必须用偏移量保存,不能保存绝对地址。realloc 对大块 mmap 内存会走 mremap,避免整块复制。

/* mmap_ops.c — mincore/mprotect/madvise/mlock/mremap 的行为 */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

int main(void)
{
    long ps = sysconf(_SC_PAGESIZE);
    size_t len = (size_t)ps * 2;

    char *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) { perror("mmap"); return 1; }

    /* 1. mincore:未触碰的匿名页不在 RAM */
    unsigned char vec[8] = {0};
    size_t pages = len / (size_t)ps;
    if (mincore(p, len, vec) == -1) perror("mincore 1");
    printf("before touch: vec[0]=%u vec[1]=%u\n", vec[0], vec[1]);

    p[0] = 'A';
    if (mincore(p, len, vec) == -1) perror("mincore 2");
    printf("after touch:  vec[0]=%u vec[1]=%u\n", vec[0], vec[1]);

    /* 2. madvise(DONTNEED):私有匿名页的修改被丢弃 */
    p[0] = 'B';
    if (madvise(p, (size_t)ps, MADV_DONTNEED) == -1) perror("madvise");
    printf("after DONTNEED: p[0]=%d (0 表示已丢弃)\n", (int)p[0]);

    /* 3. mprotect:改保护位;这里只验证调用成功,不故意触发 SIGSEGV */
    if (mprotect(p + ps, (size_t)ps, PROT_READ) == -1)
        perror("mprotect");
    else
        printf("mprotect second page -> PROT_READ ok\n");
    /* 恢复可写,否则下一步 mremap 的区间跨两个不同保护的 VMA */
    if (mprotect(p + ps, (size_t)ps, PROT_READ | PROT_WRITE) == -1)
        perror("mprotect restore");

    /* 4. mlock:可能因 RLIMIT_MEMLOCK 失败,失败不是致命 */
    errno = 0;
    if (mlock(p, (size_t)ps) == -1)
        printf("mlock: %s\n", strerror(errno));
    else
        printf("mlock first page: ok\n");

    /* 5. mremap:扩到 4 页,允许搬家 */
    char *q = mremap(p, len, (size_t)ps * 4, MREMAP_MAYMOVE);
    if (q == MAP_FAILED) { perror("mremap"); return 1; }
    printf("mremap: %s, new page writable\n", q == p ? "same address" : "moved");
    q[3 * ps] = 'C';

    munmap(q, (size_t)ps * 4);
    (void)pages;
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 mmap_ops.c -o mmap_ops
./mmap_ops
# before touch: vec[0]=0 vec[1]=0
# after touch:  vec[0]=1 vec[1]=0
# after DONTNEED: p[0]=0 (0 表示已丢弃)
# mprotect second page -> PROT_READ ok
# mlock first page: ok
# mremap: moved, new page writable

madvise(DONTNEED) 后 p[0] 变回 0,是 Linux 私有匿名映射的破坏性语义;如果这是业务数据,调用它就是数据丢失。

映射后续操作的五个坑
  1. 「msync 对 MAP_PRIVATE 也能把修改写回文件」——不能;私有映射的修改从不落盘。
  2. 「madvise(DONTNEED) 只是提示,不会丢数据」——在 Linux 的私有匿名映射上会丢。
  3. 「mincore 返回 1 就保证访问不缺页」——结果只是快照,页可能立刻被回收。
  4. 「mremap 返回的地址一定不变」——带 MREMAP_MAYMOVE 时可能搬家,绝对指针失效。
  5. 「mlock 能锁任意多内存」——非特权进程受 RLIMIT_MEMLOCK 限制,超限 ENOMEM。

2.3.12 memfd_create 与 seals:可封印的匿名共享内存

这一节回答:怎样在不落盘、不依赖 /tmp 的情况下,把一块内存变成可 fork、可传 fd、还能防止被改写的对象?

memfd_create(Linux 3.17)创建一个匿名文件,返回 fd。它没有路径,只在 /proc/PID/fd/N 下可见;可以 mmap、write、fork 继承、通过 SCM_RIGHTS 传给其他进程。加上 MFD_ALLOW_SEALING 后,还能用 fcntl 加 seal(封印),把「内容不可变」变成内核强制。

int memfd_create(const char *name, unsigned int flags);
flag含义
MFD_CLOEXECexec 时关闭
MFD_ALLOW_SEALING允许后续 F_ADD_SEALS
MFD_HUGETLB使用巨页(可配合 MFD_HUGE_2MB 等)

失败:EINVAL(未知 flag)、EMFILE/ENFILE、ENOMEM、EPERM(巨页无权限)、ENOSYS(旧内核)。

seals:让「只读」变成不可逆

int fcntl(int fd, F_ADD_SEALS, int seals);
int fcntl(int fd, F_GET_SEALS);
seal效果
F_SEAL_SEAL禁止再添加任何 seal(包括自己)
F_SEAL_SHRINK禁止 ftruncate 缩小
F_SEAL_GROW禁止 ftruncate 扩大
F_SEAL_WRITE禁止写(write 返回 EPERM;新建可写映射 EPERM)
F_SEAL_FUTURE_WRITE允许已有可写映射继续写,禁止新的写/可写映射

F_SEAL_WRITE 要求当前没有可写映射,否则 EBUSY;F_SEAL_FUTURE_WRITE 就是为「已有可写映射」准备的。加 seal 后不可撤销(除非 F_SEAL_SEAL 尚未设置时……实际上 seal 一旦加上就不能移除,F_SEAL_SEAL 只是阻止继续加)。这让它适合做「配置快照」「共享库镜像」「密封的证书包」。

/* memfd_demo.c — 创建 memfd,写入,加 seal,验证写/截断被拒 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>

#ifndef MFD_ALLOW_SEALING
#define MFD_ALLOW_SEALING 0x0002U
#endif

int main(void)
{
    int fd = memfd_create("sealed-demo", MFD_CLOEXEC | MFD_ALLOW_SEALING);
    if (fd == -1) { perror("memfd_create"); return 1; }

    const char *msg = "sealed payload";
    if (write(fd, msg, strlen(msg)) != (ssize_t)strlen(msg)) {
        perror("write"); return 1;
    }

    int seals = F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE;
    if (fcntl(fd, F_ADD_SEALS, seals) == -1) { perror("F_ADD_SEALS"); return 1; }
    printf("seals=%#x\n", fcntl(fd, F_GET_SEALS));

    errno = 0;
    ssize_t n = write(fd, "x", 1);
    printf("write after seal: n=%zd errno=%d (%s)\n", n, errno, strerror(errno));

    errno = 0;
    int rc = ftruncate(fd, 1);
    printf("ftruncate after seal: rc=%d errno=%d (%s)\n", rc, errno, strerror(errno));

    /* 只读映射仍然可以读;fork 后子进程继承同一个 memfd */
    char *p = mmap(NULL, (size_t)strlen(msg), PROT_READ, MAP_SHARED, fd, 0);
    if (p == MAP_FAILED) { perror("mmap"); return 1; }
    printf("read via mmap: %.*s\n", (int)strlen(msg), p);

    pid_t pid = fork();
    if (pid == 0) {
        printf("child sees: %.*s\n", (int)strlen(msg), p);
        _exit(0);
    }
    waitpid(pid, NULL, 0);

    munmap(p, strlen(msg));
    close(fd);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 memfd_demo.c -o memfd_demo
./memfd_demo
# seals=0xe
# write after seal: n=-1 errno=1 (Operation not permitted)
# ftruncate after seal: rc=-1 errno=1 (Operation not permitted)
# read via mmap: sealed payload
# child sees: sealed payload

seals=0xe 是 F_SEAL_SHRINK(2) | F_SEAL_GROW(4) | F_SEAL_WRITE(8);F_SEAL_SEAL(1) 只有显式添加才会出现。memfd + MFD_ALLOW_SEALING + fexecve 是「从内存执行程序、执行前校验哈希」的常用组合;注意 fexecve 需要 /proc 可用。

2.3.13 fallocate 模式全表与打洞

这一节回答:posix_fallocate 与 Linux fallocate 差在哪?怎样在不改变文件大小的前提下预分配,或把一段数据变成空洞?

int fallocate(int fd, int mode, off_t offset, off_t len);

mode 为 0 时预分配并扩展文件大小;其余模式按位组合:

mode作用限制
0分配块,文件大小至少到 offset+len空间不足 ENOSPC
FALLOC_FL_KEEP_SIZE只分配,不改 st_size后续写不会因空间不足失败
FALLOC_FL_PUNCH_HOLE把区间打成空洞必须与 KEEP_SIZE 合用
FALLOC_FL_ZERO_RANGE区间清零(可能分配块)有些文件系统用写 0 实现
FALLOC_FL_COLLAPSE_RANGE删除区间并把后面数据前移块对齐,否则 EINVAL
FALLOC_FL_INSERT_RANGE插入空洞并把后面数据后移块对齐
FALLOC_FL_UNSHARE_RANGE解除共享 extent(reflink)5.13+

失败:EOPNOTSUPP(文件系统不支持该 mode)、ENOSPC、EDQUOT、EINVAL(对齐/参数)、EFBIG、EPERM(immutable/append-only)、ETXTBSY。fallocate 不改变文件偏移,也不经过 page cache 的常规写路径。

posix_fallocate(fd, offset, len) 是 POSIX 版本,语义等价于「保证这段空间可写」,但 glibc 在没有 fallocate 的文件系统上会退化成「逐块写 0」,可能非常慢;它返回错误码而不是设置 errno(成功返回 0)。

/* fallocate_demo.c — 预分配、打洞,并用 SEEK_HOLE 验证 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

static void show(const char *tag, int fd)
{
    struct stat st;
    if (fstat(fd, &st) == -1) { perror("fstat"); return; }
    printf("%-12s size=%lld blocks=%lld (%lld KiB)\n", tag,
           (long long)st.st_size, (long long)st.st_blocks,
           (long long)st.st_blocks * 512 / 1024);
}

int main(void)
{
    const char *path = "fallocate_demo.bin";
    int fd = open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
    if (fd == -1) { perror("open"); return 1; }

    const off_t size = 4 * 1024 * 1024;
    if (ftruncate(fd, size) == -1) { perror("ftruncate"); return 1; }
    show("sparse", fd);

    /* 真正分配前 1 MiB,文件大小不变 */
    if (fallocate(fd, FALLOC_FL_KEEP_SIZE, 0, 1024 * 1024) == -1)
        printf("fallocate KEEP_SIZE: %s\n", strerror(errno));
    else
        show("prealloc", fd);

    char page[4096];
    memset(page, 'D', sizeof page);
    if (pwrite(fd, page, sizeof page, 512 * 1024) != (ssize_t)sizeof page) {
        perror("pwrite"); return 1;
    }
    show("written", fd);

    /* 把中间 1 MiB 打成洞 */
    if (fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE,
                  1024 * 1024, 1024 * 1024) == -1) {
        printf("fallocate PUNCH_HOLE: %s\n", strerror(errno));
    } else {
        show("punched", fd);
    }

    off_t off = 0;
    while (off < size) {
        off_t data = lseek(fd, off, SEEK_DATA);
        if (data == -1) {
            if (errno == ENXIO) break;
            perror("SEEK_DATA"); break;
        }
        off_t hole = lseek(fd, data, SEEK_HOLE);
        if (hole == -1) { perror("SEEK_HOLE"); break; }
        printf("data [%lld, %lld)\n", (long long)data, (long long)hole);
        if (hole <= off) break;
        off = hole;
    }

    close(fd);
    unlink(path);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 fallocate_demo.c -o fallocate_demo
./fallocate_demo
# sparse       size=4194304 blocks=0 (0 KiB)
# prealloc     size=4194304 blocks=2048 (1024 KiB)
# written      size=4194304 blocks=2056 (1028 KiB)
# punched      size=4194304 blocks=1032 (516 KiB)
# data [0, 1048576)        ← 具体分段随文件系统而异

blocks 的变化说明 fallocate 真的分配了磁盘块;PUNCH_HOLE 后又释放了一部分。不同文件系统(ext4/XFS/btrfs)对 SEEK_DATA 的粒度不同,输出可能把相邻数据段合并;不要按固定输出写测试断言。

`fallocate` 的四个坑
  1. 「fallocate 等于 ftruncate」——不。ftruncate 只改逻辑大小,可能全是空洞;fallocate 真正分配块。
  2. 「PUNCH_HOLE 可以单独用」——必须与 FALLOC_FL_KEEP_SIZE 组合,否则 EINVAL。
  3. 「posix_fallocate 一定快」——文件系统不支持时会退化为写 0,可能比普通写还慢。
  4. 「打洞后 st_size 会变小」——KEEP_SIZE 下大小不变,只是块被释放。

2.3.14 零拷贝:sendfile、splice、tee 与 copy_file_range

这一节回答:哪些「零拷贝」真的省掉了用户态拷贝?各自的 fd 类型限制和降级路径是什么?

先明确「零拷贝」省的是什么:普通 read+write 的路径是「磁盘 → page cache → 用户缓冲 → socket 缓冲 → 网卡」,零拷贝省掉的是内核 ↔ 用户态那两次拷贝,磁盘 ↔ page cache ↔ 网卡仍要走 DMA。

接口输入输出关键限制
sendfile文件(可 mmap)socket(2.6.33 起也可为文件)不能 socket→socket;in_fd 必须支持 mmap
splice至少一端是管道文件/socket/管道两端不能都是非管道
tee管道管道复制管道内容,不消耗源
vmsplice用户内存管道把用户页「借」给管道,生命周期要小心
copy_file_range文件文件同文件系统可 reflink;跨 fs 可能 EXDEV

sendfile

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • offset == NULL:从 in_fd 当前偏移读,并推进它;
  • offset != NULL:从 *offset 读,不改变 in_fd 的偏移,返回时 *offset 更新到已传最后一个字节之后;
  • 返回实际传输字节数,可能小于 count;0 表示 EOF;-1 看 errno:EINVAL(fd 类型不支持)、EFAULT、ENOSYS、EOVERFLOW、EAGAIN。

splice

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

splice 把数据从「管道的一端」移到另一端,至少一端必须是管道。flags:SPLICE_F_MOVE(提示移动页而不是复制)、SPLICE_F_NONBLOCK(不阻塞)、SPLICE_F_MORE(后续还有数据)。返回实际移动字节数,0 表示 EOF;EINVAL(两端都不是管道、偏移用于管道)、EAGAIN、EPIPE。

tee 只复制管道内容到另一个管道,源管道的数据不被消费,适合「一份输入,两路处理」。vmsplice 把用户页映射进管道,避免拷贝,但内核可能随时引用这些页,调用者不能马上修改/释放它们——除非用 SPLICE_F_GIFT 明确赠送。

copy_file_range

ssize_t copy_file_range(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out,
                        size_t len, unsigned int flags);

flags 必须为 0(否则 EINVAL)。同一文件系统上,支持 reflink 的文件系统(XFS/btrfs)可以只复制元数据;ext4 可能退化为普通复制。off_in/off_out 为 NULL 时使用并推进文件偏移。失败:EXDEV(跨文件系统,老内核)、EOPNOTSUPP(文件系统不支持)、ENOSYS(旧内核)、EBADF、EINVAL(重叠/同文件)、EFBIG。

从 Linux 5.3 起,跨文件系统复制在部分场景被允许(内核回退到通用复制),但不要把 copy_file_range 当成可靠的跨设备复制:收到 EXDEV/EOPNOTSUPP 就退回 read/write 循环。

/* zerocopy.c — splice 文件→管道→文件,再试 copy_file_range */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static int write_pattern(int fd, size_t len)
{
    char buf[4096];
    memset(buf, 'Z', sizeof buf);
    size_t done = 0;
    while (done < len) {
        size_t n = len - done < sizeof buf ? len - done : sizeof buf;
        ssize_t w = write(fd, buf, n);
        if (w == -1) {
            if (errno == EINTR) continue;
            return -1;
        }
        done += (size_t)w;
    }
    return 0;
}

static off_t splice_copy(int in, int out, size_t len)
{
    int p[2];
    if (pipe(p) == -1) return -1;
    size_t done = 0;
    off_t total = 0;

    while (done < len) {
        ssize_t n = splice(in, NULL, p[1], NULL, len - done, 0);
        if (n == -1) {
            if (errno == EINTR) continue;
            perror("splice in");
            break;
        }
        if (n == 0) break;

        size_t left = (size_t)n;
        while (left > 0) {
            ssize_t m = splice(p[0], NULL, out, NULL, left, 0);
            if (m == -1) {
                if (errno == EINTR) continue;
                perror("splice out");
                left = 0;
                break;
            }
            left -= (size_t)m;
            total += m;
        }
        done += (size_t)n;
    }
    close(p[0]);
    close(p[1]);
    return total;
}

int main(void)
{
    const char *src = "zc_src.bin", *dst = "zc_dst.bin";
    int in = open(src, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
    if (in == -1) { perror("open src"); return 1; }

    const size_t size = 1024 * 1024;
    if (write_pattern(in, size) == -1) { perror("write pattern"); return 1; }
    if (fsync(in) == -1) { perror("fsync"); return 1; }
    if (lseek(in, 0, SEEK_SET) == -1) { perror("lseek"); return 1; }

    int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
    if (out == -1) { perror("open dst"); return 1; }

    off_t n = splice_copy(in, out, size);
    printf("splice copied %lld bytes\n", (long long)n);

    /* copy_file_range 到第三个文件 */
    int out2 = open("zc_dst2.bin", O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
    if (out2 != -1) {
        if (lseek(in, 0, SEEK_SET) == -1) perror("lseek");
        errno = 0;
        ssize_t m = copy_file_range(in, NULL, out2, NULL, size, 0);
        if (m == -1)
            printf("copy_file_range: %s\n", strerror(errno));
        else
            printf("copy_file_range copied %zd bytes\n", m);
        close(out2);
    }

    close(in);
    close(out);
    unlink(src);
    unlink(dst);
    unlink("zc_dst2.bin");
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 zerocopy.c -o zerocopy
./zerocopy
# splice copied 1048576 bytes
# copy_file_range copied 1048576 bytes

splice 的循环要先 splice 到管道,再把管道内容 splice 到输出;不能假设一次就搬完。copy_file_range 返回短计数时也要继续循环;上面的示例为了简洁只调用一次,生产代码应写成与 splice_copy 类似的循环。

零拷贝的五个坑
  1. 「零拷贝完全不拷贝」——省的是用户态拷贝;DMA、page cache 仍然参与。
  2. 「sendfile 可以 socket→socket」——不能,in_fd 必须是可 mmap 的文件。
  3. 「splice 可以任意两个 fd」——至少一端是管道,否则 EINVAL。
  4. 「copy_file_range 跨文件系统一定可用」——老内核 EXDEV,部分文件系统 EOPNOTSUPP;必须回退。
  5. 「vmsplice 后可以立刻复用用户缓冲」——内核可能仍引用这些页;除非 SPLICE_F_GIFT,否则要等管道消费完。

2.3.15 文件锁的完整语义:flock、POSIX 记录锁与 OFD 锁

这一节回答:flock 与 fcntl 记录锁在继承、释放、冲突上到底差在哪?OFD 锁解决了哪个历史缺陷?

2.3.6 给了对照表。这里补三组细节:锁的归属、释放时机、冲突检测(Kerrisk 第 55 章,p918–935)。

三种锁

维度flockPOSIX 记录锁(F_SETLK)OFD 锁(F_OFD_SETLK)
范围整个文件字节区间字节区间
归属打开文件描述进程 + inode打开文件描述
fork 后子进程共享同一把锁不继承子进程共享(同一 OFD)
dup 后共享共享(同进程)共享
关闭任意 fd只有最后一个引用关闭才释放关闭该文件任意 fd 就释放全部只有该 OFD 最后一个引用关闭才释放
exec保留(除非 CLOEXEC 关掉最后一个引用)保留保留
冲突范围仅与其他 flock与其他 POSIX/OFD 锁与其他 POSIX/OFD 锁

POSIX 记录锁的「关闭任意 fd 释放全部」是历史设计缺陷:一个库函数加的锁,会被调用者关闭另一个 fd 时顺手释放。OFD 锁(Linux 3.15+,F_OFD_GETLK/F_OFD_SETLK/F_OFD_SETLKW)把归属改成打开文件描述,解决了这个问题,也让 fork 后的子进程能共享锁。

冲突与死锁

  • F_SETLK 冲突返回 -1,errno 为 EACCES 或 EAGAIN(Linux 用 EAGAIN);F_SETLKW 阻塞,可能被信号打断返回 EINTR。
  • 内核检测到死锁时让其中一个请求返回 EDEADLK;但「检测」不是万能的,复杂的环形等待仍可能靠超时/人工干预。
  • 多个读锁可能饿死一个写锁;Linux 不保证 FIFO,也不保证写者优先(Kerrisk 55.3.6,p935)。
  • F_GETLK 只报告其他进程的冲突锁,自己进程的锁不会出现在结果里;l_pid 是持锁者 PID。

/proc/locks 的字段

1: POSIX  ADVISORY  WRITE 48211 08:01:7340033 0 EOF

依次是:序号、锁类型(POSIX/FLOCK/OFDLCK)、ADVISORY/MANDATORY、READ/WRITE、PID(OFD 锁为 -1)、major:minor:inode、起始、结束(EOF 表示到文件末尾)。lslocks 输出更友好。

强制锁(mandatory locking)在 Linux 5.15 已被移除;旧代码里「setgid + 关闭 group-execute」的技巧不再有效,不要按它设计。

/* oflock.c — OFD 锁在两个 fd 间冲突;POSIX 锁在同一进程内不冲突 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static int lock_ofd(int fd, short type)
{
    struct flock fl = { .l_type = type, .l_whence = SEEK_SET,
                        .l_start = 0, .l_len = 10 };
    return fcntl(fd, F_OFD_SETLK, &fl);
}

int main(void)
{
    const char *path = "oflock_demo.lock";
    int a = open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
    int b = open(path, O_RDWR | O_CLOEXEC);
    if (a == -1 || b == -1) { perror("open"); return 1; }
    if (ftruncate(a, 100) == -1) { perror("ftruncate"); return 1; }

    /* 同一进程、两个 fd:OFD 锁互相冲突 */
    if (lock_ofd(a, F_WRLCK) == -1) { perror("OFD lock a"); return 1; }
    errno = 0;
    int rc = lock_ofd(b, F_WRLCK);
    printf("OFD lock on second fd: rc=%d errno=%d (%s)\n",
           rc, errno, strerror(errno));

    /* POSIX 锁:同一进程内再次加锁会合并,不冲突 */
    struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET,
                        .l_start = 20, .l_len = 10 };
    if (fcntl(a, F_SETLK, &fl) == -1) { perror("POSIX lock a"); return 1; }
    struct flock fl2 = fl;
    errno = 0;
    rc = fcntl(b, F_SETLK, &fl2);
    printf("POSIX lock on second fd: rc=%d errno=%d (%s)\n",
           rc, errno, strerror(errno));

    close(a);
    close(b);
    unlink(path);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 oflock.c -o oflock
./oflock
# OFD lock on second fd: rc=-1 errno=11 (Resource temporarily unavailable)
# POSIX lock on second fd: rc=0 errno=0 (Success)

同一进程的两个 fd 上,OFD 锁冲突而 POSIX 锁合并——这就是「锁归属」的可见差异。用 ./oflock & 配合 cat /proc/locks 还能看到 OFDLCK 的 PID 是 -1。

文件锁的五个坑
  1. 「flock 与 fcntl 锁会互相冲突」——Linux 上两套锁独立,混用不保证互斥;一个程序只用一套。
  2. 「关闭加锁的 fd 才释放锁」——POSIX 记录锁关闭该文件任意 fd 就释放全部;用 OFD 锁避免。
  3. 「F_GETLK 能看到自己的锁」——只报告其他进程的冲突锁。
  4. 「锁能阻止 unlink」——不能;有目录写权限就能删除文件。
  5. 「强制锁还能用」——Linux 5.15 移除了强制锁;不要依赖 setgid 技巧。

2.3.16 内核路径:mmap 建映射与缺页处理

这一节回答:mmap 之后内核到底做了什么?为什么第一次访问会慢,之后又快?

建映射

层次位置关键动作
系统调用arch/x86/entry/syscalls/syscall_64.tblmmap → __x64_sys_mmap
入口mm/mmap.cksys_mmap_pgoff → vm_mmap_pgoff
地址空间mm/mmap.cdo_mmap → mmap_region,拿 mmap_lock 写锁
VMAmm/mmap.cvma_merge/vma_link 插入 mm_struct->mm_mt(maple tree)
文件mm/mmap.ccall_mmap → file->f_op->mmap
文件系统mm/filemap.cgeneric_file_mmap 设置 vm_ops = &file_vm_ops
驱动/DAXfs/dax.cdax_mmap(持久内存)

mmap 只登记 VMA,不读文件页;MAP_POPULATE 才会在建立时预缺页。vm_area_struct 记录 vm_start/vm_end/vm_flags/vm_pgoff/vm_file/vm_ops,是后续缺页处理的依据。

缺页

handle_mm_fault(vma, address, flags, regs)
  → __handle_mm_fault
    → do_fault
      → do_read_fault
        → filemap_fault
          → page_cache_sync_readahead / filemap_read_folio
          → filemap_map_pages(fault-around,一次映射多页)

写私有页走 do_wp_page(COW):分配新页、复制旧内容、改 PTE 为可写、减旧页引用。写共享页走 do_shared_fault → ->page_mkwrite(ext4 的 ext4_page_mkwrite),把页标记为脏并等待可能进行中的回写。

msync/fsync 最终都落到 vfs_fsync_range → file->f_op->fsync → ext4 的 ext4_sync_file;mmap 的脏页由回写线程(kworker/flush)按 dirty_expire_centisecs 自动刷。

观测缺页
perf stat -e page-faults,minor-faults,major-faults ./mmap_scan
# major 需要读盘;minor 只建立 PTE 或从 page cache 映射
cat /proc/self/smaps | grep -A8 mmap_demo
# Rss/Pss/Shared_Clean/Private_Dirty/Anonymous/Swap

smaps 的 Pss 把共享页按映射者数量均摊,比 Rss 更适合判断真实内存占用。

2.3.17 两个实验:strace 看映射调用与 smaps 看驻留

这一节回答:怎样用现成工具确认「映射建了没有、页在不在、回写发生在哪」?

实验一:strace 跟踪 mmap/msync

strace -yy -e trace=mmap,munmap,mprotect,msync,madvise,mlock ./mmap_scan 2>&1 | head -20

典型输出:

$ strace -e trace=mmap,munmap,msync,madvise ./mmap_scan 2>&1 | tail -8
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, 3</tmp/mmap_demo.XXXX>, 0) = 0x7f...
madvise(0x7f..., 4096, MADV_SEQUENTIAL) = 0
msync(0x7f..., 4096, MS_SYNC) = 0
munmap(0x7f..., 4096) = 0

读法:

  • mmap 的第四个参数是 flags,MAP_SHARED 与 MAP_PRIVATE 必须出现一个;PROT_* 是第三个参数。
  • msync 的第三个参数是 MS_SYNC/MS_ASYNC/MS_INVALIDATE 的组合;MS_SYNC 会阻塞到回写完成。
  • munmap 的 length 会被向上取整到页;解除部分映射会把原 VMA 切分。
  • 没有 mmap 行但有 brk/mremap,说明内存来自 malloc 的堆或大块 mremap。

实验二:smaps 与 mincore

# 1. 看映射的驻留与共享情况
grep -A12 'mmap_demo' /proc/$$/smaps 2>/dev/null | head -15

# 2. 看进程锁住的内存
grep VmLck /proc/$$/status

# 3. 用 fincore 看文件页是否在 cache(util-linux)
fincore --pages=false /etc/hosts

smaps 的关键字段:

字段含义
SizeVMA 逻辑大小
Rss当前驻留的物理页
Pss按共享者均摊后的驻留
Shared_Clean/Shared_Dirty与其他进程共享的干净/脏页
Private_Clean/Private_Dirty本进程私有页
Anonymous匿名页(堆、栈、匿名映射)
Swap被换出的页
VmFlags如 rd wr mr mw me sd(可读/可写/可映射/可共享/软脏)

mincore 与 smaps 的 Rss 都是快照;要观察变化,用 watch -n1 grep Rss /proc/PID/smaps 或在程序里循环调用。Pss 对容器/多进程共享库的内存核算更有意义。

mmap 问题排查顺序
  1. strace -e trace=mmap,munmap,mprotect,msync 看调用是否成功、flags 是否写错。
  2. cat /proc/PID/maps 看 VMA 的权限与文件偏移,确认映射落在预期地址。
  3. grep -A12 <name> /proc/PID/smaps 看 Rss/Pss/Private_Dirty,判断是否真的驻留。
  4. 收到 SIGBUS:fstat 比对 st_size 与映射长度,检查是否有别的进程截断。
  5. 收到 SIGSEGV:检查访问是否越过 length,或 mprotect 改成了 PROT_NONE。

2.3.18 本章陷阱清单

高级文件 I/O 的十二个坑
  1. 现象:mmap 返回 (void *)-1,程序却按 NULL 判断。原因:MAP_FAILED 不是 NULL。修复:用 p == MAP_FAILED。
  2. 现象:mmap 偏移 100 返回 EINVAL。原因:offset 未按页对齐。修复:offset 取 sysconf(_SC_PAGESIZE) 的整数倍。
  3. 现象:MAP_SHARED|PROT_WRITE 用 O_RDONLY 打开的文件映射失败。原因:共享可写映射要求 fd 可写。修复:O_RDWR,或改 MAP_PRIVATE。
  4. 现象:访问映射区收到 SIGBUS。原因:文件被截断,或映射超出 EOF 整页。修复:访问前 fstat,或加锁阻止截断。
  5. 现象:msync 后另一个进程仍看不到修改。原因:用了 MAP_PRIVATE,或只调了 MS_ASYNC 且对方读的是旧缓存。修复:共享映射 + MS_SYNC,必要时 MS_INVALIDATE。
  6. 现象:madvise(DONTNEED) 后数据变 0。原因:Linux 私有匿名映射的破坏性语义。修复:不要对需要保留的数据用 DONTNEED。
  7. 现象:mlock 返回 ENOMEM。原因:超过 RLIMIT_MEMLOCK。修复:getrlimit/ulimit -l,或申请 CAP_IPC_LOCK。
  8. 现象:mremap 后旧指针崩溃。原因:MREMAP_MAYMOVE 搬家了。修复:只保存偏移,使用返回值。
  9. 现象:sendfile 返回 EINVAL。原因:in_fd 不是可 mmap 的文件,或 out_fd 不是 socket(老内核)。修复:用普通文件 + socket,或改 splice。
  10. 现象:splice 返回 EINVAL。原因:两端都不是管道。修复:至少一端用 pipe()。
  11. 现象:copy_file_range 返回 EXDEV。原因:跨文件系统或文件系统不支持。修复:回退到 read/write 循环。
  12. 现象:OFD 锁关闭一个 fd 后仍然持有。原因:锁归属打开文件描述,只有最后一个引用关闭才释放。修复:这是设计行为;要按进程释放用 POSIX 锁。

2.3.19 本章出口检查

检查项达标线没达标去哪
说出 F_SETFL 能改与不能改的标志至少说出访问模式不可改2.3.1
解释 dup2 为什么比 close+dup 安全说出原子关闭2.3.2
写出 writev 的短写循环能推进 iovec 数组2.3.3
解释 mmap 的 SIGBUS 触发条件说出「文件被截断」与整页越界2.3.4、2.3.10
说出 MAP_SHARED 与 MAP_PRIVATE 的写语义能联系到 COW 与回写2.3.9
解释 madvise(DONTNEED) 在私有映射上的后果说出「修改被丢弃」2.3.11
区分 flock、POSIX 锁与 OFD 锁至少说对归属与释放两点2.3.6、2.3.15
说出 fallocate 与 ftruncate 的差别一个分配块,一个只改大小2.3.13
写出原子发布的四步含目录 fsync2.3.7
即时自测 · 2.3

Q1 fcntl(fd, F_SETFL, flags | O_RDWR) 返回 0,但 fd 仍然只读。为什么?

Q2 两个线程共享一个 fd,都用 lseek + read。会出什么问题?换成 pread 呢?

Q3 mmap 一个 8 KiB 文件,另一个进程把它截断到 4 KiB,访问第 5 KiB 会怎样?改用 read 呢?

Q4 进程 A 用 fcntl 给文件加写锁后 fork;子进程继承这把锁吗?A 又 open 同一文件并 close,锁还在吗?

Q5 原子发布为什么临时文件必须与目标同目录?只 fsync 文件、不 fsync 目录,崩溃后可能出现什么?

答案

A1 F_SETFL 只改行为位;访问模式由 open 决定,内核静默忽略传入的 O_RDWR。所以返回 0,但读写能力没变。

A2 lseek 改共享的 f_pos:线程 1 定位到 100,线程 2 定位到 200,线程 1 再读就从 200 开始,数据错位。pread 把偏移当参数,不改 f_pos,天然无竞态。

A3 mmap 访问超出新 EOF 的整页收到 SIGBUS,默认终止进程;read 只返回 0。检测到截断后应重建映射。

A4 子进程不继承 fcntl 记录锁。A 关闭任意一个指向该文件的 fd,都会释放它在该文件上的全部记录锁,即使不是加锁用的那个。要避免,用 F_OFD_SETLK 或 flock。

A5 rename 要求源和目标在同一文件系统,否则 EXDEV。不 fsync 目录,崩溃后目录项可能没提交,出现「新内容在、名字是旧的」甚至「两个名字都丢」。

母本对应

母本章节本书落点程度
K第 4–5 章(fcntl/pread/readv/截断)、第 13 章(缓冲/O_DIRECT)、第 14 章(VFS)、第 49 章(mmap)、第 50 章(mprotect/mlock/mincore/madvise)、第 55 章(文件锁)2.3.1–2.3.18完整(mmap 缺页、后续操作、OFD 锁为重点)
K第 61 章 61.4(sendfile)2.3.14精选(splice/copy_file_range 为母本成书后新增,以 man 页为准)
C第 9 章 9.8(内存映射)、第 10 章(系统级 I/O)2.3.3、2.3.4对照(mmap 与 RIO)
O第 39–40 章(文件与目录、文件系统实现)2.3.7、2.3.8背景(inode、块分配、崩溃一致性)
G第 5 章(文件权限)、第 7 章(磁盘与文件系统)2.3.7、2.3.8背景(df/du/稀疏文件)
N第 3 章(内核与用户进程协作)—未用:本章不涉及 socket/epoll,留待 2.18

下一站:2.4 缓冲与 stdio 解释 printf 到底什么时候调用 write,以及为什么 fork 之后会看到重复输出。本章的 fflush 与 dup2 例子,在那里会得到完整解释。