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进行通信。

相关推荐
武帝为此11 分钟前
【计算机网络之以太网详解】
服务器·网络·计算机网络
藍海琴泉12 分钟前
Linux命令大全:从入门到高效运维
linux·运维·服务器
余华余华19 分钟前
计算机等级考试数据库三级(笔记2)
java·服务器·数据库
键盘上的GG小怪兽GG38 分钟前
CentOS 安装LAMP全过程 - 完整步骤与最佳实践
linux·运维·centos
阿俊仔(摸鱼版)39 分钟前
自动化构建攻略:Jenkins + Gitee 实现 Spring Boot 项目自动化构建
运维·ci/cd·gitee·自动化·jenkins
c无序1 小时前
【Linux加餐-验证UDP:TCP】-windows作为client访问Linux
linux·tcp/ip·udp
香吧香1 小时前
netstat 与 ss 比较
linux
h^hh1 小时前
六十天Linux从0到项目搭建(第五天)(file、bash 和 shell 的区别、目录权限、默认权限umask、粘滞位、使用系统自带的包管理工具)
linux
AWS官方合作商1 小时前
AWS CloudWatch 实战:构建智能监控与自动化运维体系
运维·自动化·aws