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() 退出。
避免 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 号 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,从下图可以看到,缺页中断不再增加,因为父进程被堵塞了。
从堵塞的时间来看,时间较触发缺页中断还要短一些:
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进行通信。
评论前必须登录!
注册