往期内容回顾

        程序地址空间(进阶)

        程序地址空间

        环境变量(初识)

        进程状态的优先级和特性

        进程属性和常见进程

        进程管理

        理解计算机的软硬件管理

前言

        在操作系统的世界里,进程是资源分配和调度的最小单位。无论是服务器的多任务处理,还是桌面应用的多窗口运行,都是通过进程的创建、调度、终止来完成的。

而在 Linux 中,fork() 是创建新进程最核心的系统调用之一。掌握 fork,不仅可以让我们更深入理解父子进程、地址空间、进程管理,还为多进程编程打下坚实基础。


主要内容介绍

本文将围绕以下几个方面展开:

  1. 进程控制的核心概念(父进程、子进程、进程状态)

  2. fork() 的原理与执行流程

  3. 父子进程的地址空间关系

  4. 通过 fork 创建进程的实战示例

  5. 了解进程终止的几种方式

  6. 进程等待的概念理解

  7. 获取进程等待信息和进程回收的策略


一. 进程控制的核心概念

在 Linux 中,进程控制包括 创建进程、终止进程、等待进程、进程间通信(IPC) 等内容。

核心概念

说明

进程控制块 (PCB)

操作系统为每个进程维护的核心数据结构,包含进程状态、程序计数器、寄存器内容、内存映射、打开的文件等信息。

进程状态切换

创建、新建、就绪、运行、等待、终止等状态的转换过程。

进程调度

决定哪个进程获得 CPU 执行权,包括调度策略(先来先服务、时间片轮转、优先级等)。

进程通信 (IPC)

进程间交换数据的机制,如管道、共享内存、消息队列、信号量、套接字等。

同步与互斥

控制进程在共享资源上的访问顺序,防止竞争条件和死锁。

资源分配与回收

记录和管理进程占用的内存、I/O 设备、文件句柄等资源。

例如使用fork()来创建进程:

  • 父进程(Parent Process):调用 fork 创建新进程的进程。

  • 子进程(Child Process):由 fork 复制父进程创建的新进程,拥有独立的 PID。

  • 进程状态:就绪(Ready)、运行(Running)、阻塞(Blocked),由内核调度器控制切换。


二. fork() 的原理与执行流程

fork() 是 Linux 提供的系统调用,用于创建一个新进程。

执行特点

        分配新的内存块和内核数据(PCB)给子进程

  • fork 会复制当前进程的整个虚拟地址空间(代码段、数据段、堆、栈、文件描述符等)给子进程。

  • 父子进程的虚拟地址空间相同,但物理内存采用写时复制(Copy-On-Write, COW),只有在写入时才分配新的物理页。

  • fork 会返回两次:在父进程中返回子进程 PID,在子进程中返回 0,开始调度器调度。


回到我们之前的关于fork的使用代码:

#include <stdatomic.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int globalvalue = 100;

int main(){
    pid_t id = fork();
    int cnt = 0;
    if(id < 0){ 
        printf("fork error!/n");
    }   
    else if (id == 0) {
        while (1) {
            printf("Child process: pid = %d, ppid = %d, global value = %d, &globalvalue = %p\n",getpid(),getppid(),globalvalue,&globalvalue);
            sleep(1);
            cnt++;
            if(cnt == 10) 
            {   
                globalvalue = 300;
                printf("The Child process has change the globalvalue!\n");
            }   
        }   
    }   
    else {
        while(1){
    
            printf("Father process: pid = %d,ppid = %d, global value = %d, &globalvalue = %p\n",getpid(),getppid(),globalvalue,&globalvalue);
        sleep(2);
        }   
    }   
}

1、如何理解fork()的两个返回值呢 ?

        fork 会复制当前进程的整个虚拟地址空间(代码段、数据段、堆、栈、文件描述符等)给子进程,

 复制 PCB(进程控制块),并做必要修改(如 PID、PPID, 然后让父、子从同一行代码继续执行 —— 也就是 fork() 这一行

但系统必须让父子进程能区分身份,于是 fork() 的返回值

  • 父进程中:返回 子进程的 PID(> 0)

  • 子进程中:返回 0

  • 失败时(没创建成功):返回 -1

if (id == 0) { /* 子进程执行 */ }
else if (id > 0) { /* 父进程执行 */ }

2、 如何理解fork返回之后,给父进程返回子进程的pid,给子进程返回0呢?

        这是内核在 fork() 系统调用内部主动赋值的,不是说同一个变量 magically 变成两个值。

具体过程:

  1. 父进程发起 fork() 系统调用 → 内核分配新 PCB 给子进程

  2. 在父进程返回前,内核在父进程的寄存器/栈空间写入子进程 PID

  3. 在子进程返回前,内核在子进程的寄存器/栈空间写入 0

  4. 然后分别调度两个进程继续执行 fork() 之后的代码

也就是说:

        fork() 后父子进程共享 代码段,但数据段、堆、栈独立(写时复制)

  • id 在父、子进程里是不同内存里的两个变量副本(COW 之后它们看似同一份)

  • 内核在 fork() 结束时,分别给这两份变量写了不同值

3、如何理解同一个id值,怎么可能会保存两个不同的值呢?并且让if,else if 同时执行?

关键点:id 在父进程和子进程里不是同一份内存(写时拷贝)

         fork() 时复制了整个地址空间(包括局部变量 id 的栈空间)

  • 所以父进程有它的 id,子进程有它的 id,只是初始值在内核写入前是一样的

  • 内核在 fork() 返回时修改了这两份变量的值

  • 父进程看到的 id 是子 PID → 走 else 分支

  • 子进程看到的 id 是 0 → 走 if 分支结果就是两段几乎相同的代码在两个进程里分别执行,看起来就像同一个变量能存两份值,其实是两份不同的内存。


 3、fork的常规用法,应用场景

简单创建子进程

只做简单任务分离,比如父进程打印信息,子进程执行其他操作:

父子分工

常用模式:父进程做管理,子进程做计算或处理任务:

创建守护进程 / 后台进程

通过 fork() + setsid() 可以让子进程脱离终端,常用于服务程序或后台守护进程:

多进程并行处理

当任务可以并行时,可以用 fork() 创建多个子进程同时工作


4、应用场景

  1. 任务并行化

            Web 服务器:每个请求 fork 一个子进程处理

    • 数据处理:大批量计算可拆分成多个子进程

  2. 后台守护进程

    • 日志记录服务

    • 系统监控服务

  3. 父子进程分工

    • 父进程监控、管理

    • 子进程执行具体任务

  4. 创建进程池

    • 预创建一定数量的子进程,提高任务处理效率


注意事项

  1. fork() 会复制父进程的全部内存,频繁调用可能浪费资源

  2. 需要处理 僵尸进程:父进程要调用 wait() 或 waitpid() 回收子进程

  3. 文件描述符会被子进程继承,需要注意打开/关闭


三、进程终止的概念

        进程终止是指一个进程完成了它的任务或因为异常需要退出,操作系统释放它占用的资源(内存、文件描述符、PID 等)。

进程终止通常分为两种方式:

  1. 正常终止(正常退出)

  2. 异常终止(被信号或错误终止)


二、C/C++程序中的进程终止方式

1. 正常终止

  • return 从 main() 返回

int main() {
    printf("Program running\n");
    return 0;  // 程序正常退出
}

底层行为:

  • 内核回收进程资源

  • 返回值 0 表示成功,非 0 表示异常退出

  • 内核向父进程发送 SIGCHLD 信号(父进程可调用 wait() 收集子进程状态)

  • exit() 系列函数

#include <stdlib.h>

int main() {
    printf("Program running\n");
    exit(0); // 等价于 return 0
}

特点:

  • exit() 会调用 清理函数(atexit() 注册的函数)

  • 刷新 缓冲区,关闭打开的文件描述符

  • 返回状态给父进程

  • _exit() / _Exit()

#include <unistd.h>
int main() {
    _exit(0); // 立即终止,不调用清理函数
}

区别:

  • 不调用 atexit 注册的函数

  • 不刷新标准 I/O 缓冲

  • 通常在 fork() 后的子进程中使用

如何设定c语言main函数的返回值呢?如果不关心进程退出,return0就行

如果我们是要关心进程退出码的时候,要返回特定的数据表明特定的错误

进程退出的时候,返回特定的退出码用于标定进程执行的结果是否正确?错误在哪?

如何返回特定的错误码呢?利用string.h的strerror函数查看错误码(代码跑完了
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(){
    for(int i = 0;i<200;i++){
        printf("%d:%s\n",i,strerror(errnum: i));
    }   

}
0:Undefined error: 0
1:Operation not permitted
2:No such file or directory
3:No such process
4:Interrupted system call
5:Input/output error
6:Device not configured
7:Argument list too long
8:Exec format error
9:Bad file descriptor
10:No child processes
11:Resource deadlock avoided
12:Cannot allocate memory
13:Permission denied
14:Bad address
15:Block device required
16:Resource busy
17:File exists
18:Cross-device link
19:Operation not supported by device
20:Not a directory
21:Is a directory
22:Invalid argument
23:Too many open files in system
24:Too many open files
25:Inappropriate ioctl for device
26:Text file busy
27:File too large
28:No space left on device
29:Illegal seek
30:Read-only file system
31:Too many links
32:Broken pipe
33:Numerical argument out of domain
34:Result too large
35:Resource temporarily unavailable
36:Operation now in progress
37:Operation already in progress
38:Socket operation on non-socket
39:Destination address required
40:Message too long
41:Protocol wrong type for socket
42:Protocol not available
43:Protocol not supported
44:Socket type not supported
45:Operation not supported
46:Protocol family not supported
47:Address family not supported by protocol family
48:Address already in use
49:Can't assign requested address
50:Network is down
51:Network is unreachable
52:Network dropped connection on reset
53:Software caused connection abort
54:Connection reset by peer
55:No buffer space available
56:Socket is already connected
57:Socket is not connected
58:Can't send after socket shutdown
59:Too many references: can't splice
60:Operation timed out
61:Connection refused
62:Too many levels of symbolic links
63:File name too long
64:Host is down
65:No route to host
66:Directory not empty
67:Too many processes
68:Too many users
69:Disc quota exceeded
70:Stale NFS file handle
71:Too many levels of remote in path
72:RPC struct is bad
73:RPC version wrong
74:RPC prog. not avail
75:Program version wrong
76:Bad procedure for program
77:No locks available
78:Function not implemented
79:Inappropriate file type or format
80:Authentication error
81:Need authenticator
82:Device power is off
83:Device error
84:Value too large to be stored in data type
85:Bad executable (or shared library)
86:Bad CPU type in executable
87:Shared library version mismatch
88:Malformed Mach-o file
89:Operation canceled
90:Identifier removed
91:No message of desired type
92:Illegal byte sequence
93:Attribute not found
94:Bad message
95:EMULTIHOP (Reserved)
96:No message available on STREAM
97:ENOLINK (Reserved)
98:No STREAM resources
99:Not a STREAM
100:Protocol error
101:STREAM ioctl timeout
102:Operation not supported on socket
103:Policy not found
104:State not recoverable
105:Previous owner died
106:Interface output queue is full
107:Capabilities insufficient

举个例子:

ls 21312412414
ls: 21312412414: No such file or directory
echo $?

2. 异常终止(代码没跑完)

        调用信号导致终止:如 SIGKILL、SIGTERM

#include <signal.h>
kill(getpid(), SIGTERM); // 给自己发送终止信号

未处理的异常:如除零、段错误

int main() {
    int *p = NULL;
    *p = 10;  // 段错误,进程被内核终止
}

异常终止行为:

  • 内核释放进程资源

  • 向父进程发送 SIGCHLD 信号

  • 可以在父进程通过 wait() 或 waitpid() 获取异常终止信息


3、进程如何退出的?

一、应用层触发退出

在 C/C++ 程序里,进程可以通过几种方式退出:

1、return 从 main() 返回

编译器会把 return 转换为对 exit() 的调用(隐式)。

2、显式调用退出函数

        exit(status):正常退出,执行清理动作(刷新缓冲区、调用 atexit() 注册的函数、关闭文件等)。

int sum(int num){  
    int ret = 0;  
    for(int i = 0;i<num;i++){  
        ret+=i;  
    }  
    exit(1);  
    printf("Sum(100) = %d\n",ret);  
}          
                                                                                                                        
                                                                                                                        
int main(){                                                                                                             
    int num = 100;
    sum(num: 100);                                                                                                      
    return 0;
}

不会打印最后的求和值,因为exit已经退出了

_exit(status) / _Exit(status):立即退出,不做清理,直接进入内核(多用于 fork() 后的子进程)


_exit和exit的区别?
  • exit()

    • 属于 C 标准库函数(在 stdlib.h 中声明),由 libc 提供。

    • 它不是直接的系统调用,而是库函数中封装了更多的清理逻辑,最终会调用 _exit()(或 _Exit())。

  • _exit()

    • 属于 系统调用接口(Linux 对应 sys_exit,macOS 对应 exit Mach trap)。

    • 在 <unistd.h> 中声明,直接请求内核终止进程,不经过 libc 的收尾清理逻辑。


exit终止进程,主动刷新缓冲区

_exit终止进程,不会刷新缓冲区

缓冲区:用户级的缓冲区,不在操作系统内。 


以 exit() 为例,它会做以下工作(用户态):

  1. 调用已注册的 atexit() 回调函数。

  2. 刷新所有打开的标准 I/O 缓冲区(stdout、stderr 等)

  3. 关闭所有用 fopen() 打开的文件(底层会调用 close())。

  4. 调用系统调用 _exit() 进入内核,正式结束进程。

如果直接调用 _exit(),会跳过 1-3,直接到 4

测试代码:

int main(){
    printf("Hello World!\n");
    sleep(6);
   _exit(32);
    //_exit(32);
}

3、被信号终止

  • 例如 SIGKILL、SIGTERM、SIGSEGV(段错误)等。

4、内核态的进程终止流程

当进程执行 _exit(status) 系统调用后,内核会:

  1. 释放进程占用的用户态资源

    • 释放用户空间内存(代码段、堆、栈、数据段)。

    • 释放文件描述符、socket、共享内存等。

  2. 更新进程控制块(PCB)状态

    • 把进程状态改为 ZOMBIE(僵尸状态),保留退出码和少量信息,供父进程读取。

  3. 向父进程发送 SIGCHLD 信号

    • 父进程收到信号后,可以用 wait() 或 waitpid() 获取退出状态。

  4. 等待父进程回收

    • 父进程调用 wait() → 内核释放 PCB,进程完全消失。

    • 如果父进程先退出,孤儿进程会被 init 进程(PID 1)收养,并由它回收。


三、进程等待

  1. 1、进程状态Z: 僵尸状态:

通过进程等待的方式来回收僵尸进程

一、进程为什么要“等待”?

  • 本质:等待是一种同步。父进程需要在合适的时机获取子进程的退出结果,或等一个外部事件(I/O、信号、定时器)发生,回收子进程资源

  • 目的

    资源回收:子进程退出时释放大部分资源,但其退出状态需父进程读取,否则会变成僵尸进程(zombie)

    顺序控制:需要在子进程结束后再继续下一步。
  • 并发正确性:避免竞态条件(先后次序不确定导致错误)。

测试代码:

int main(){
    pid_t id = fork();
    if(id == 0){
        int cnt = 10;
        while (cnt > 0) {
            printf("Child process, ID = %d, PID = %d, PPID = %d\n",id,getpid(),getppid());
            cnt --;
            sleep(1);
        }
    }
    sleep(15);
    pid_t ret = wait(NULL);
    if(id >0){
        printf("wait success: %d\n",ret);
    }
    sleep(5);

}


二、与进程状态的关系(调度视角)

典型用户态可感知的三态:就绪 → 运行 → 阻塞(等待)

  • 等待/阻塞(Sleeping/Waiting):进程调用诸如 wait()、read()、accept()、futex() 等阻塞式系统调用后,内核把它放到相应的等待队列,直到条件满足被唤醒

  • 可中断/不可中断睡眠:wait() 等一般是可被信号打断的睡眠;磁盘 I/O 等可能进入不可中断睡眠(D 状态)。


三、获取进程等待信息

常见“等待子进程”系统调用族:

1) 阻塞等待一个子进程(最简单)

接口

说明

pid_t wait(int *wstatus)

阻塞等待任一子进程退出

pid_t waitpid(pid_t pid, int *wstatus, int options)

等待特定子进程(或任一,取决于 pid),支持非阻塞等选项精确控制:指定 PID、同组、任意

int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options)

更细粒度,直接给出 siginfo_t,可等待退出/停止/继续

兼容接口 wait3/wait4

非标准或历史接口,附带资源使用统计

waitpid 的 pid 语义:

  • > 0:等待这个 确切 PID

  • -1:等待 任意 子进程(等价于 wait,但可选非阻塞/更多选项)

  • 0:等待 同进程组 的任意子进程

  • < -1:等待 进程组 ID = -pid 的任意子进程

返回值与状态宏(最常用的几组)

  • 返回值:

    • > 0 :被回收的子进程 PID

    • 0 :仅在 waitpid(..., WNOHANG) 且当前无状态变化时返回

    • -1:出错(errno 判因,如 ECHILD 没有子进程、EINTR 被信号中断)

pid_t pid = fork();
if (pid == 0) {                 // child
    _exit(42);
} else if (pid > 0) {           // parent
    int status;
    pid_t r = waitpid(pid, &status, 0);   // 阻塞直到该子进程退出
    if (r == pid && WIFEXITED(status)) {
        printf("child exit code=%d\n", WEXITSTATUS(status));
    }
}

2) 非阻塞轮询(WNOHANG)

int status;
pid_t r = waitpid(-1, &status, WNOHANG);  // -1 表示任意子进程
if (r == 0) {
    // 目前还没有已退出的子进程,做点别的事再来查
} else if (r > 0) {
    // 处理退出结果
}
适合需要持续做其他工作的父进程。常见搭配:事件循环或定时器重复检查。

3) 信号驱动(处理 SIGCHLD)

static void on_sigchld(int sig) {
    int saved = errno;
    while (1) {                     // 可能一次信号对应多个子进程退出
        int status;
        pid_t r = waitpid(-1, &status, WNOHANG);
        if (r <= 0) break;
        // 处理 r 的退出状态...
    }
    errno = saved;
}

int main() {
    struct sigaction sa = {0};
    sa.sa_handler = on_sigchld;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // 可选:不关心 STOP/CONT
    sigaction(SIGCHLD, &sa, NULL);
    // 正常业务逻辑...
}
优点:无需主动轮询; 要点:handler 里用  循环 把所有已退出的子进程都 waitpid(-1, …, WNOHANG) 掉,避免遗漏。

4) 等待所有子进程

int status;
pid_t r;
while ((r = wait(&status)) > 0) {
    // 每迭代回收一个
}
if (r == -1 && errno == ECHILD) {
    // 没有子进程了
}

四、避免僵尸进程的几种策略

  1. 父进程始终调用 wait/waitpid 回收

  2. 安装 SIGCHLD 处理器,在其中循环 waitpid(-1, …, WNOHANG)

  3. 对于短命后台子进程,双重 fork(double-fork) 把最终子进程交给 init/systemd 收养

  4. 在某些场景可用 SIGCHLD 的 SA_NOCLDWAIT 标志(或 prctl(PR_SET_CHILD_SUBREAPER) 进程子收割器),不过要明白副作用并确保可移植性

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐