Linux错误(5)程序fork子进程后访问内存触发缺页中断(COW)

Linux错误(5)程序fork子进程后访问内存触发缺页中断(COW)

Author: Once Day Date: 2025年3月12日

一位热衷于Linux学习和开发的菜鸟,试图谱写一场冒险之旅,也许终点只是一场白日梦...

漫漫长路,有人对你微笑过嘛...

全系列文章可参考专栏: Linux实践记录_Once_day的博客-CSDN博客

参考文章:


文章目录

  • Linux错误(5)程序fork子进程后访问内存触发缺页中断(COW)
        • [1. 问题分析](#1. 问题分析)
          • [1.1 现象介绍](#1.1 现象介绍)
          • [1.2 分析原因](#1.2 分析原因)
          • [1.3 解决思路](#1.3 解决思路)
          • [1.4 解决方法](#1.4 解决方法)
          • [1.5 posix_spawn和vfork介绍](#1.5 posix_spawn和vfork介绍)
        • [2. 实例验证](#2. 实例验证)
          • [2.1 复现故障](#2.1 复现故障)
          • [2.2 使用vfork替代fork](#2.2 使用vfork替代fork)
          • [2.3 使用posix_spawn](#2.3 使用posix_spawn)
        • [3. 总结](#3. 总结)
1. 问题分析
1.1 现象介绍

在一个多线程程序中,使用 popen() 创建子进程后,系统出现了大量缺页中断(Page Fault),导致瞬间突发耗时(约 50ms)。由于 popen() 本质上调用了 fork(),而 fork() 在多线程环境下可能会触发写时拷贝(Copy-On-Write, COW),进而导致内存页复制,引发性能抖动。

1.2 分析原因

在多线程程序中调用 popen(),其内部会执行 fork() 创建子进程,而 fork() 后的子进程会继承父进程的地址空间(COW 机制)。如果在 fork() 之后,父进程或子进程修改了共享内存页,则会触发 COW,导致大规模页复制,从而引发缺页中断和性能下降。

(1)COW 触发大量页复制

  • fork() 之后,父子进程共享相同的页面,并标记为写时拷贝(COW)。
  • 如果父进程或子进程修改这些页面,就会触发COW 机制,导致内核分配新页面并复制数据,进而引发缺页异常和额外的 CPU/内存开销。

(2)多线程环境导致 fork() 继承大量页

  • 多线程程序的堆(heap)、栈(stack)等数据结构较为复杂,fork() 复制的页表较多,增加了COW 触发的概率。
  • malloc() 可能在 fork() 之前分配了大量内存,而 fork() 之后,glibcmalloc 可能会在子进程执行 exec() 之前触发COW。

(3)TLB(Translation Lookaside Buffer)失效

  • fork() 之后,子进程对共享的内存进行写操作,导致TLB 失效,进而影响性能。

(4)popen() 内部实现使用了 fork()

  • popen() 本质上是 fork() + exec() + pipe(),导致fork() 继承了父进程的所有地址空间,增加了COW 触发可能性。
1.3 解决思路

要减少 fork() 触发的COW 及缺页中断,可以从以下四个角度进行优化:

  • 避免 fork() 之后的内存写入,减少 COW 触发。
  • 使用 vfork() 代替 fork(),减少页表复制。
  • 使用 posix_spawn() 代替 fork()+exec(),避免 COW。
  • 优化 mallocmmap 行为,减少 fork() 继承的页面。
1.4 解决方法

(1)预防 COW 触发,减少 fork() 继承的内存

  • fork() 之前调用 madvise(MADV_DONTNEED)madvise() 可释放不必要的内存,减少 fork() 继承的页面,降低 COW 触发概率。

    c 复制代码
    void *ptr = malloc(1024 * 1024); // 分配 1MB 内存
    madvise(ptr, 1024 * 1024, MADV_DONTNEED);  // 释放物理页
  • fork() 之前调用 malloc_trim()malloc_trim()glibc 释放未使用的堆,减少 fork() 继承的页。

    c 复制代码
    #include <malloc.h>
    malloc_trim(0);  // 释放空闲堆内存
  • 避免 fork() 之后修改全局变量,fork() 之后,尽量不要修改共享内存(如全局变量、堆变量),防止触发 COW。

(2)使用 vfork() 代替 fork()

  • vfork()不会复制地址空间,子进程直接共享父进程的内存,避免 COW 触发。

  • 适用于子进程立即执行 exec()的场景。

    c 复制代码
    pid_t pid = vfork();
    if (pid == 0) {
        execlp("ls", "ls", NULL);  // 立即 exec(),避免修改内存
        _exit(1);  // 失败退出
    }
  • 注意: vfork() 会阻塞父进程,适用于 exec() 立即替换进程的场景。

(3)使用 posix_spawn() 代替 fork()+exec()

  • posix_spawn()底层可避免 fork() 继承大量页面,减少 COW 触发。

  • 适用于创建子进程并执行新程序的场景。

    c 复制代码
    #include <spawn.h>
    extern char **environ;
    pid_t pid;
    posix_spawn(&pid, "/bin/ls", NULL, NULL, (char *const[]){"ls", NULL}, environ);
  • fork()+exec() 更快,避免 fork() 复制页面。减少缺页中断,提高创建子进程的效率。

(4)避免 mallocmmap 影响 fork()

  • 使用 pthread_atfork() 保护 malloc()pthread_atfork() 可在 fork() 之前锁定 malloc,防止 COW 影响。

    c 复制代码
    pthread_atfork(lock_malloc, unlock_malloc, unlock_malloc);
  • 避免在 fork() 之后调用 malloc()malloc() 可能修改 glibcheap 结构,导致COW触发,建议在 fork() 之前预分配内存。

  • 使用 mmap() 代替 malloc()mmap() 分配的匿名映射页可以避免 COW,适用于大块内存分配。

    c 复制代码
    void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
优化点 优化方法
减少 fork() 继承的内存 madvise(MADV_DONTNEED), malloc_trim(0)
避免 fork() 后 COW 触发 避免修改全局变量,避免 malloc()
改用 vfork() 避免 COW vfork() 适用于 exec() 场景
使用 posix_spawn() 代替 fork() 更高效,减少缺页中断
优化 mallocmmap 行为 pthread_atfork(), mmap()
1.5 posix_spawn和vfork介绍

posix_spawn() 是创建子进程并执行新程序的高效方法,通常用于替代 fork()+exec() 组合。

  • 避免 fork() 继承大量地址空间,减少写时拷贝(COW)和缺页中断。
  • 实现方式因系统不同,在Linux,posix_spawn() 可能使用 vfork() 进行优化。在macOS,posix_spawn() 是系统调用,比 fork()+exec() 更高效。
  • 适用于创建子进程并立即执行新程序的场景。

vfork()fork() 的优化版本,子进程直接共享父进程地址空间,不会复制页表。

  • 子进程与父进程共享地址空间,避免 fork() 的COW 机制和TLB 失效。
  • 子进程执行exec()之前,不能修改内存,否则可能影响父进程。
  • 父进程会被阻塞,直到子进程exec()_exit() 退出。
特性 posix_spawn() vfork()
避免 fork() 页表复制 ✅ 是 ✅ 是
子进程共享父进程地址空间 ❌ 否 ✅ 是
父进程是否被阻塞 ❌ 否 ✅ 是
适用于 exec() 之后的场景 ✅ 是 ✅ 是
适用于复杂子进程启动 ✅ 是 ❌ 否
实现方式 fork()vfork()(平台相关) 直接共享地址空间

posix_spawn() 适用于一般 fork()+exec() 替代方案,避免 fork() 继承大量内存。

vfork() 适用于 exec() 立即执行的情况,但不适合复杂子进程逻辑。

2. 实例验证
2.1 复现故障

使用下述的代码可以复现fork和exec之间父进程修改堆内存触发缺页中断的情况。

c 复制代码
#define _GNU_SOURCE
#define __USE_GNU

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <pthread.h>
#include <sched.h>
#include <sys/mman.h>

volatile int thread_stop  = 0;
int          enable_write = 0;

void writeaddr(char *addr)
{
    for (int i = 0; i < 4096; i++) {
        addr[i] = i;
    }
}

void *thread_func(void *arg)
{
    // 绑定线程 3 号 CPU
    cpu_set_t mask;
    CPU_ZERO(&mask);
    CPU_SET(3, &mask);
    pthread_setaffinity_np(pthread_self(), sizeof(mask), &mask);

    printf("thread_func\n");

    // char *p2 = malloc(4096 * 100);
    // memset(p2, 0x0, 4096 * 100);

    // sleep();
    while (!thread_stop) {
        for (int i = 0; i < 100; i++) {
            if (enable_write) {
                writeaddr((char *)arg + i * 4096);
            }
        }
    }
    return NULL;
}

int main(int argc, char *argv[])
{
    // argv[1] 为 1 时,测试缺页中断(COW)
    int enable_fork = 0;
    if (argc > 1) {
        enable_fork = 1;
    }

    if (argc > 2) {
        enable_write = 1;
    }

    char *p = malloc(4096 * 100);
    memset(p, 0x0, 4096 * 100);

    // 绑定 2 号 CPU
    cpu_set_t mask;
    CPU_ZERO(&mask);
    CPU_SET(2, &mask);
    sched_setaffinity(0, sizeof(mask), &mask);

    // 创建线程执行 writeaddr
    pthread_t tid;
    pthread_create(&tid, NULL, thread_func, p);

    // 等待同步
    sleep(1);

    // fork 一个子进程
    if (enable_fork) {
        int pid = fork();
        if (pid == 0) {
            execl("/bin/echo", "echo", NULL);
            exit(0);
        }
    }

    if (enable_fork) {
        // 等待子进程结束
        wait(NULL);
    }

    thread_stop = 1;
    // 等待线程结束
    pthread_join(tid, NULL);

    return 0;
}

从perf stat计数可以明显看出,fork子进程后,父进程的工作线程读写堆内存,会触发缺页中断,大概刚好100+(一个页面4KB)。

2.2 使用vfork替代fork

这里使用vfork替代fork,从下图可以看到,缺页中断不再增加,因为父进程被堵塞了。

从堵塞的时间来看,时间较触发缺页中断还要短一些:

c 复制代码
max_time: 1556610 ns => 触发缺页中断 fork
max_time: 1121580 ns => 不触发缺页中断 vfork

并且,只有调用vfork的线程会被堵塞,其他线程并未被堵塞。

2.3 使用posix_spawn

使用posix_spawn的效果与vfork类似,如下:

3. 总结

在Linux环境下,如果一个程序需要创建子进程,如果这个程序自身是一个复杂的多线程程序,最好不要通过popen等接口运行脚本,因为这可能造成父进程中其他线程触发缺页中断,造成服务波动。

如果需要创建子进程,最好通过vfork或者posix_spawn接口,指定子进程的属性,比如避免复制页表,直接共享内存空间,然后子进程快速执行exec切换内存空间。

对于时延敏感性应用,更合适的做法是通过一个代理进程来执行shell或者创建子进程,然后通过RPC进行通信。

相关推荐
此生只爱蛋16 分钟前
【Linux】正/反向代理
linux·运维·服务器
qq_54702617923 分钟前
Linux 基础
linux·运维·arm开发
zfj32128 分钟前
sshd除了远程shell外还有哪些功能
linux·ssh·sftp·shell
废春啊35 分钟前
前端工程化
运维·服务器·前端
我只会发热38 分钟前
Ubuntu 20.04.6 根目录扩容(图文详解)
linux·运维·ubuntu
爱潜水的小L1 小时前
自学嵌入式day34,ipc进程间通信
linux·运维·服务器
保持低旋律节奏1 小时前
linux——进程状态
android·linux·php
zhuzewennamoamtf1 小时前
Linux I2C设备驱动
linux·运维·服务器
zhixingheyi_tian1 小时前
Linux 之 memory 碎片
linux
邂逅星河浪漫1 小时前
【域名解析+反向代理】配置与实现(步骤)-SwitchHosts-Nginx
linux·nginx·反向代理·域名解析·switchhosts