【Linux】从 fork 到进程终止:写时拷贝细节与常见退出方式

【Linux】从 fork 到进程终止:写时拷贝细节与常见退出方式

Linux 进程创建(fork)与终止(exit/kill)是操作系统中最核心、最常被考察的机制之一。
本文重点讲解 fork 的写时拷贝(Copy-On-Write, COW) 实现细节,以及进程终止的各种方式(正常/异常/强制),结合内核视角、常见陷阱与生产实践。

1. fork() 的本质与写时拷贝(COW)机制

1.1 fork() 做了什么?

fork() 是 POSIX 标准中创建新进程的系统调用,返回值:

  • 子进程:返回 0
  • 父进程:返回子进程 PID(>0)
  • 失败:返回 -1(errno)

最关键的一点:子进程是父进程的几乎完整副本(包括代码段、数据段、堆、栈、打开的文件描述符、信号处理、环境变量等)。

传统实现(很早的 Unix)会直接复制整个地址空间 → 极其昂贵(内存拷贝 + 时间)。

1.2 现代 Linux 如何优化?→ 写时拷贝(Copy-On-Write)

Linux(从很早版本开始)采用 COW 机制:

  1. fork 时
  • 不复制物理页面,而是复制页表(page table)
  • 子进程的页表项指向父进程相同的物理页面
  • 所有用户态可写页面(大多数数据/堆/栈)被标记为只读(read-only)(内核在页表中设置写保护位)
  1. 读操作:直接访问共享的物理页面 → 零拷贝,速度极快
  2. 第一次写操作(任一进程):
  • 触发页面故障(page fault)
  • 内核检测到写保护 → 分配一个全新物理页面
  • 把原页面内容复制到新页面
  • 更新当前进程的页表 → 指向新页面(可写)
  • 父/子进程各自拥有独立的副本

一句话总结
fork 后父子共享物理内存,直到其中一方写时才真正复制该页

1.3 COW 的典型表现(代码演示)

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int global_var = 100;           // 数据段
int main() {
    int stack_var = 200;        // 栈
    pid_t pid = fork();

    if (pid == 0) {             // 子进程
        printf("子进程: global=%d, stack=%d\n", global_var, stack_var);
        global_var = 999;       // 写 → 触发 COW(该页被复制)
        stack_var = 888;
        printf("子进程修改后: global=%d, stack=%d\n", global_var, stack_var);
    } else {                    // 父进程
        wait(NULL);
        printf("父进程: global=%d, stack=%d\n", global_var, stack_var);
        // 父进程看到的仍是 100 和 200(COW 后子进程修改的是自己的副本)
    }
    return 0;
}

输出

子进程: global=100, stack=200
子进程修改后: global=999, stack=888
父进程: global=100, stack=200

结论:父子进程修改的是各自独立的副本,不互相影响

1.4 COW 的优点与代价

优点

  • fork 极快(只需复制页表 + 标记写保护)
  • 内存利用率高(大量 fork 后 exec 的场景,如 shell、Apache prefork 模式,几乎不额外耗内存)

代价 / 副作用

  • 写操作会触发页面复制 → 写放大(尤其是大进程 fork 后频繁写内存时)
  • 多进程同时写同一页 → 每个进程都复制一份
  • 内存碎片可能增加

生产建议

  • 尽量 fork 后尽快 exec(经典用法:避免 COW 带来的写放大)
  • 高并发服务器避免 fork 模型 → 改用线程池 / 事件驱动 / 多进程预 fork + accept(2) 复用

2. 进程终止的常见方式

Linux 进程退出有多种路径,影响退出码资源释放信号处理atexit/on_exit 等清理函数是否执行。

方式系统调用/信号是否调用 atexit()是否发 SIGCHLD是否可捕获典型退出码备注 / 适用场景
main 返回exit()返回值最正常退出
exit() / _exit()exit_group() / exit是 / 否参数exit 调用 atexit,_exit 不调用
pthread_exit()否(仅线程)仅退出当前线程,主线程仍存活
return from mainexit()返回值等价于 exit()
SIGTERM (kill -15)是(若 handler 调用 exit)可捕获通常 143优雅终止(默认行为)
SIGKILL (kill -9)不可捕获通常 137强制杀死(不可拦截)
SIGABRT (abort())否(核心转储)可捕获通常 134断言失败等
段错误等异常SIGSEGV 等可捕获通常 139核心转储

关键区别

  • exit():调用 atexit/on_exit 注册的清理函数 → 刷新 stdio 缓冲区 → 关闭打开的文件(fclose)→ 然后调用 _exit()
  • _exit():直接系统调用 exit,不做任何用户态清理(缓冲区可能丢失)
  • SIGTERM:默认行为是终止进程,但可以捕获并做清理(调用 exit())
  • SIGKILL:内核强制杀死,无法捕获、无法清理(脏数据可能残留)

退出码约定(Shell 中 $?):

  • 正常退出:0 ~ 255(用户定义)
  • 被信号杀死:128 + 信号编号
  • SIGTERM (15) → 143
  • SIGKILL (9) → 137
  • SIGSEGV (11) → 139

3. 生产中最常见的终止流程(推荐)

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

static void cleanup(void) {
    printf("atexit 清理:关闭文件、释放资源...\n");
}

static void sigterm_handler(int sig) {
    printf("收到 SIGTERM,进行优雅退出...\n");
    // 可以做:保存状态、通知其他进程、关闭连接等
    exit(EXIT_SUCCESS);   // 或 _exit(0) 看需求
}

int main() {
    atexit(cleanup);

    signal(SIGTERM, sigterm_handler);
    // signal(SIGINT, sigterm_handler);  // Ctrl+C

    printf("进程运行中... PID=%d\n", getpid());

    while (1) {
        sleep(1);
    }

    return 0;
}

测试

# 终端1
./a.out

# 终端2
kill -TERM <pid>     # 优雅退出,调用 atexit
kill -9 <pid>        # 强制杀死,不调用 atexit

小结 & 面试/运维高频问题

  1. fork 后父子进程共享哪些资源?(文件描述符、mmap 共享内存等)
  2. COW 触发条件是什么?(第一次写)
  3. SIGTERM 和 SIGKILL 区别?(可捕获 vs 不可捕获)
  4. 为什么生产中常用 SIGTERM + 优雅退出?(避免数据丢失、脏关闭)
  5. 如何防止子进程变成僵尸(zombie)?(父进程 wait/waitpid)
  6. 多线程程序中主线程 exit() 会怎样?(整个进程退出)

想继续深入哪个方向?
A. vfork() vs fork() vs clone() 区别
B. 僵尸进程、孤儿进程的产生与处理
C. 多线程信号分发与 pthread_kill
D. 内核视角:task_struct → do_exit 流程
E. 生产事故案例(SIGKILL 导致数据损坏)

告诉我字母,我们继续深挖!

文章已创建 4138

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

相关文章

开始在上面输入您的搜索词,然后按回车进行搜索。按ESC取消。

返回顶部