首页 / 资讯中心 / 文章详情

C++中fork()函数

C++中fork()函数 ★ FEATURED ARTICLE
前言fork()是 POSIX 系统调用用于创建一个新进程——它是 Unix/Linux 一切皆进程哲学的基石。但在 C 里fork()是一个危险的函数C 程序往往带全局对象、堆分配、多线程而fork()只复制调用线程不复制其他线程。这直接导致了一类经典的疑难 bug子进程在malloc里死锁。本文从fork()的语义讲起重点说清它为什么在多线程 C 程序里必须慎用以及正确的替代方案。注意fork()是 POSIX 接口Windows 上不可用Windows 用CreateProcess。本文只讨论 Linux/Unix。一、fork() 的基本语义#include unistd.h#include sys/wait.h#include cstdioint main() {pid_t pid fork();if (pid 0) {std::perror(fork);return 1;} else if (pid 0) {// 子进程fork 返回 0std::printf(child: pid%d, ppid%d\n, getpid(), getppid());} else {// 父进程fork 返回子进程 PIDstd::printf(parent: child pid%d\n, pid);int status 0;waitpid(pid, status, 0); // 回收子进程避免僵尸}return 0;}关键点返回值含义 0创建失败如EAGAIN达到进程上限 0处于子进程中 0处于父进程中值是子进程 PID写时复制copy-on-write, COWfork()并不真的复制整个地址空间父子共享物理页直到某一方写入才复制。这让fork比想象中便宜。子进程拥有父进程地址空间的副本含堆、全局变量、文件描述符表。但文件描述符是共享的共享同一打开文件表项所以父子会争抢同一个文件偏移。子进程需要exec才有意义否则就是父进程的一份克隆容易跑出重复逻辑。父进程必须wait/waitpid否则子进程退出后变成僵尸进程zombie。二、CMake 构建cmake_minimum_required(VERSION 3.16)project(fork_demo CXX)set(CMAKE_CXX_STANDARD 17)add_executable(fork_demo main.cpp)find_package(Threads REQUIRED)target_link_libraries(fork_demo PRIVATE Threads::Threads)g -stdc17 -pthread main.cpp -o fork_demo ./fork_demo三、fork 在 C 多线程程序中的危险性这是全文的核心。问题的根源只有一条但后果很严重fork()只复制调用它的那一个线程。父进程中其他线程在子进程里不存在。3.1 为什么会导致死锁malloc/free内部用锁保护堆glibc 的 arena 锁。假设父进程有两个线程线程 A 正在malloc持有堆锁线程 B 调用fork()。fork()复制出子进程子进程里只有线程 B线程 A 不存在了——但堆锁的状态被复制过来了仍是已加锁。于是子进程里任何malloc/free包括std::string拼接、new、甚至printf内部缓冲都会在尝试获取那把永远无人释放的锁时永久阻塞。// ❌ 极其危险多线程程序里 fork 后又在子进程里分配内存#include thread#include cstdio#include unistd.h#include sys/wait.h#include vectorvoid busy_alloc() {for (;;) {auto p new char[128]; // 反复 malloc/free制造持锁窗口delete[] p;}}int main() {std::thread t1(busy_alloc);std::thread t2(busy_alloc);pid_t pid fork(); // 可能在堆锁被持有时发生if (pid 0) {// 子进程这里可能永久卡死std::string s hello; // 内部可能 malloc - 死锁std::printf(%s\n, s.c_str());_exit(0);}int st; waitpid(pid, st, 0);// ... 清理线程示意实际需停止 busy_alloct1.detach(); t2.detach();}这段代码可能有时候正常、有时候挂住因为是否死锁取决于fork那一瞬间堆锁是否恰好被持有——典型的偶发 bug。3.2 POSIX 对 fork 的约束POSIX 明确规定在多线程程序中调用fork()后子进程在调用exec之前只能调用异步信号安全async-signal-safe函数。这是因为除了调用线程外其他线程可能在任何指令处消失它们持有的锁、正在更新的数据结构都可能处于不一致状态。malloc、printf、std::string、std::cout、任何 C 标准库的分配行为都不是异步信号安全的。write()、_exit()、execve()是安全的。pid_t pid fork();if (pid 0) {// ✅ fork 后、exec 前只调用异步信号安全函数const char* msg child\n;::write(STDOUT_FILENO, msg, 6);::execlp(/usr/bin/ls, ls, -l, nullptr);_exit(127); // exec 失败必须用 _exit不能 exit()}// ❌ 绝对不要// std::cout child\n; // 非异步信号安全// std::string s ...; // 可能 malloc// exit(0); // 会跑 atexit/析构可能死锁特别注意子进程里应当用_exit()而不是exit()。exit()会触发atexit回调、刷新 stdio、析构全局对象这些都可能抢那把死锁。_exit()直接结束进程不做任何清理。3.3 fork exec 是安全的fork 干活是不安全的结论其实很清晰fork()exec*()即先复制进程再立刻换成另一个程序是安全的经典模式也是 shell 执行命令的方式。因为exec会整片替换地址空间3.1 里那些不一致状态全被丢弃。fork() 在子进程里跑 C 逻辑是危险的。子进程继承了父进程的不一致状态却继续执行复杂代码。3.4 子进程里的其他惊喜重复输出stdio 缓冲区在fork时被复制父子都 flush同一段printf可能打印两次。文件偏移共享父子共享打开文件表项一个读会移动另一个的偏移。死锁的锁不只是malloc任何父进程持有的std::mutex都会被复制成已加锁状态。线程局部存储子进程里只剩调用线程的 TLS。std::thread对象子进程里那些代表其他线程的对象永远是joinable你无从 join。四、安全替代方案方案一用posix_spawn()代替forkexec。posix_spawn是专门为创建并执行新程序设计的接口实现可以在内部做安全处理比如在 fork 前预先mmap好资源是 shell、system()的现代实现方式。#include spawn.h#include sys/wait.hextern char** environ;int run_ls() {pid_t pid;char* argv[] {const_castchar*(ls), const_castchar*(-l), nullptr};int rc posix_spawnp(pid, ls, nullptr, nullptr, argv, environ);if (rc ! 0) return rc;int status; waitpid(pid, status, 0);return status;}方案二fork之后立刻exec别做别的。如果必须用fork把新进程要干的活编译成独立可执行文件fork后马上exec它。方案三用posix_spawn的实现原理——预先分配资源。glibc 的posix_spawn会用clone(CLONE_VM|CLONE_VFORK)让父子共享地址空间直到 exec从而完全绕开复制不一致状态的问题。方案四用线程而不是进程。现代 C 里大部分想 fork 干活的场景用std::thread/ 线程池 / 协程更合适。方案五需要的是隔离而非新进程时用容器或进程池。提前 fork 好一批 worker每个 worker 从队列取任务执行——worker 在 fork 后先做完全部初始化或干脆一开始就是单线程父进程 fork 出来的避免运行期 fork。常见坑点坑 1多线程程序里 fork子进程调用malloc死锁。见 3.1这是头号大坑。父子进程都看得到的std::string、std::vector、std::cout都是雷。坑 2子进程用exit()而非_exit()。pid_t pid fork();if (pid 0) {// ❌ 触发 atexit 和全局析构可能死锁或重复 flushexit(0);// ✅ 直接退出不做清理// _exit(0);}坑 3忘记wait产生僵尸。子进程退出后若父进程不waitpid进程表项不会释放积累成僵尸最终耗尽 PID。// ✅ 要么显式 waitint st; waitpid(pid, st, 0);// ✅ 要么忽略 SIGCHLD 让内核自动回收signal(SIGCHLD, SIG_IGN);坑 4文件描述符泄漏到子进程。子进程继承了全部打开的 fd。若forkexec一个新程序旧程序的 socket、日志文件都跟着过去了造成资源占用和意外的数据写入。// ✅ 打开文件时加 O_CLOEXECexec 时自动关闭int fd open(log.txt, O_WRONLY | O_CREAT | O_CLOEXEC, 0644);// ✅ 或 fork 后 exec 前手动关闭坑 5stdio 缓冲区重复输出。fork前若有未 flush 的printf缓冲父子各 flush 一次。// ✅ fork 前手动 flushstd::fflush(nullptr); // 或 std::cout.flush();pid_t pid fork();坑 6在信号处理函数里 fork。信号处理函数只能调用异步信号安全函数fork本身安全但之后做任何分配都不安全。坑 7误以为子进程会共享父进程修改变量。int x 0;pid_t pid fork();if (pid 0) x 1; // 只改子进程的副本// 父进程的 x 仍是 0坑 8fork返回值判断顺序写错。// ❌ 先判断 pid 0 也可以但别只判断 pid 而不处理 -1// ✅ 三态都要处理尤其 pid 0EAGAIN/ENOMEM总结fork()创建进程副本基于写时复制返回值三态0失败、0子进程、0父进程。fork()只复制调用线程这是 C 多线程程序里所有fork灾难的根源其他线程持有的锁尤其malloc的堆锁会被复制成已加锁子进程一分配内存就死锁。POSIX 规定多线程程序中fork后、exec前只能调用异步信号安全函数子进程退出必须用_exit()。fork()exec()是安全的fork() 在子进程跑 C 逻辑是不安全的。首选posix_spawn()其次fork后立刻exec能用线程/线程池就别用进程fork前记得fflush、给 fd 加O_CLOEXEC、给 SIGCHLD 设处理避免僵尸。
阅读完成 · 觉得有帮助?
咨询建站