Linux错误(5)程序fork子进程后访问内存触发缺页中断(COW)
Author: Once Day Date: 2025年3月12日
一位热衷于Linux学习和开发的菜鸟,试图谱写一场冒险之旅,也许终点只是一场白日梦…
漫漫长路,有人对你微笑过嘛…
全系列文章可参考专栏: Linux实践记录_Once_day的博客-CSDN博客
参考文章:
- 监测 Linux 内存缺页中断事件 | 陈谭军的博客 | tanjunchen Blog
文章目录
- Linux错误(5)程序fork子进程后访问内存触发缺页中断(COW)
- 1. 问题分析
- 1.1 现象介绍
- 1.2 分析原因
- 1.3 解决思路
- 1.4 解决方法
- 1.5 posix_spawn和vfork介绍
- 2. 实例验证
- 2.1 复现故障
- 2.2 使用vfork替代fork
- 2.3 使用posix_spawn
- 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()
之后,glibc
的malloc
可能会在子进程执行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。 - 优化
malloc
及mmap
行为,减少fork()
继承的页面。
1.4 解决方法
(1)预防 COW 触发,减少 fork() 继承的内存。
-
fork() 之前调用
madvise(MADV_DONTNEED)
,madvise()
可释放不必要的内存,减少fork()
继承的页面,降低 COW 触发概率。void *ptr = malloc(1024 * 1024); // 分配 1MB 内存 madvise(ptr, 1024 * 1024, MADV_DONTNEED); // 释放物理页
-
fork() 之前调用
malloc_trim()
,malloc_trim()
让glibc
释放未使用的堆,减少fork()
继承的页。#include <malloc.h> malloc_trim(0); // 释放空闲堆内存
-
避免 fork() 之后修改全局变量,
fork()
之后,尽量不要修改共享内存(如全局变量、堆变量),防止触发 COW。
(2)使用 vfork()
代替 fork()
。
-
vfork()
不会复制地址空间,子进程直接共享父进程的内存,避免 COW 触发。 -
适用于子进程立即执行
exec()
的场景。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 触发。 -
适用于创建子进程并执行新程序的场景。
#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)避免 malloc
及 mmap
影响 fork()
:
-
使用
pthread_atfork()
保护malloc()
,pthread_atfork()
可在fork()
之前锁定malloc
,防止COW
影响。pthread_atfork(lock_malloc, unlock_malloc, unlock_malloc);
-
避免在
fork()
之后调用malloc()
,malloc()
可能修改glibc
的heap
结构,导致COW触发,建议在fork()
之前预分配内存。 -
使用
mmap()
代替malloc()
,mmap()
分配的匿名映射页可以避免 COW,适用于大块内存分配。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() | 更高效,减少缺页中断 |
优化 malloc 及 mmap 行为 | 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之间父进程修改堆内存触发缺页中断的情况。
#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 号 CPUcpu_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 号 CPUcpu_set_t mask;CPU_ZERO(&mask);CPU_SET(2, &mask);sched_setaffinity(0, sizeof(mask), &mask);// 创建线程执行 writeaddrpthread_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,从下图可以看到,缺页中断不再增加,因为父进程被堵塞了。
从堵塞的时间来看,时间较触发缺页中断还要短一些:
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进行通信。