Linux - 进程控制
一、进程创建
在 Linux 系统中,创建进程通常通过 fork()
系统调用实现。fork()
会创建一个新的进程,称为子进程,它与父进程共享几乎所有的资源(如文件描述符、环境变量等),但具有独立的地址空间。子进程最初的内容是父进程的精确副本。
fork
详细见Linux - 进程.md
二、进程等待
1) 进程等待的必要性
在 Linux 系统中,进程等待是父进程为了管理和回收子进程资源所采取的一种机制。这种机制的重要性主要体现在以下几个方面:
-
防止僵尸进程:当子进程结束后,内核会保留其退出状态等信息,直到父进程读取为止。如果父进程不等待并收集这些信息,子进程的资源无法完全释放,导致形成僵尸进程。僵尸进程虽然不再占用 CPU,但仍然保留进程表项,占用内存资源。过多的僵尸进程会导致系统进程表满溢,影响系统的资源分配。
-
释放内存,避免资源泄漏:僵尸进程残留的内存、描述符等资源没有释放,可能逐渐累积而造成内存泄漏。尽管进程退出时大部分内存会被内核回收,但某些操作系统资源,尤其是内核管理表项,需要父进程通过等待机制显式回收。
-
获取子进程的执行状态和结果:父进程可以通过等待机制获取子进程的退出状态信息,如子进程是否正常退出、是否有错误发生等。这对于多进程应用尤为重要,父进程可以根据子进程的执行结果来决定下一步操作。例如,在多任务并发的服务器中,父进程可以检查子进程是否成功处理了请求。
2) 进程等待接口(wait
)
pid_t wait(int *wstatus)
是一个用于进程控制的系统调用,它让父进程暂停执行,直到某个子进程结束并且回收该子进程的状态信息。通常,wait()
用于防止产生僵尸进程(zombie processes),并获取子进程的退出状态。
-
status
是一个指向整数的指针,用于存储子进程的退出状态。如果你不关心子进程的退出状态,可以将其设置为NULL
。(详见后面的waitpid
) -
返回值:
- 如果成功,
wait()
返回已终止的子进程的 PID。 - 如果发生错误,返回
-1
,并设置errno
。
- 如果成功,
工作原理:
- 父进程调用
wait()
后,会阻塞,直到子进程终止。也就是说,父进程会被挂起,直到子进程结束并返回它的状态。- 一旦子进程终止,父进程将收到其终止的通知,并可以通过
status
参数查看子进程的退出状态。- 子进程在退出时会发送一个终止信号给父进程,父进程通过调用
wait()
接收这个信号并回收子进程资源,防止子进程变为僵尸进程。
3) 进程等待接口(waitpid
)
pid_t waitpid(pid_t pid, int *wstatus, int options)
是 Linux 和类 Unix 系统中的一个系统调用,它与 wait()
类似,但比 wait()
更加灵活和可控。waitpid()
允许父进程等待一个特定的子进程,或者控制等待行为的方式。它是 wait()
的一个增强版本。
-
pid_t pid
:这是父进程希望等待的子进程的进程 ID(PID)。pid > 0
:等待指定 PID 的子进程。如果指定的 PID 是当前进程的子进程,则waitpid()
会阻塞直到该子进程退出。pid == 0
:等待任何与调用进程在同一进程组中的子进程。pid == -1
:等待任何子进程,等同于wait()
。pid < -1
:等待指定进程组中的任何子进程(pid
取值的绝对值指定了进程组 ID)。
-
int *status
:这是一个指向整数的指针,用于存储子进程的退出状态。状态信息可以通过一些宏来解码,如WIFEXITED()
、WEXITSTATUS()
、WIFSIGNALED()
等。如果status
为NULL
,则父进程不会获取子进程的状态信息。 status由32个bit位构成,不同bit表示不同信息:- 高 8 位(位 31-24):这是用于标识子进程是否因为信号而终止的信息。
- 中间的 7 位(位 23-17):这些位通常用于标识一些系统特定的细节,如进程是否因特定的信号被暂停、是否为僵尸进程等。
- 低 16 位(位 15-0):这些位用于存储子进程的退出码(退出状态),具体根据子进程的退出方式(正常退出、信号终止等)有所不同。
对于status的低16位:
- 如果程序正常退出,低七位全零(表示子进程成功退出),高八位表示进程退出状态,存储子进程的退出状态码(子进程的
exit()、return
的结果)。 - 如果程序因信号终止,低七位会包含导致子进程终止的信号编号,高八位无用。
WIFEXITED(status)
:Wait IF EXITED- 系统提供的宏,对应
!(status & 0x7F)
,判断是否正常退出。
WEXITSTATUS(status)
:Wait EXITSTATUS- 系统提供的宏,对应
(status>>8) & 0x7F
,获取退出码。
-
int options
:这是一个控制等待行为的标志参数。常见的选项包括:0
: 无设置。WNOHANG
:如果没有子进程退出,则不阻塞,立即返回。WUNTRACED
:如果子进程处于暂停状态(由信号停止),也会返回。WCONTINUED
:如果子进程因SIGCONT
信号被恢复执行,也会返回。WEXITSTATUS(status)
、WTERMSIG(status)
等宏依然可以用于检查状态。
-
返回值:
- 子进程已退出:返回值是子进程的 PID,父进程可以通过
status
获取子进程的退出状态。 - 子进程尚未退出:返回
0
,如果设置了WNOHANG
选项且没有子进程退出。 - 错误:返回
-1
,并且会设置errno
以指示发生了什么错误。
- 子进程已退出:返回值是子进程的 PID,父进程可以通过
waitpid()
与wait()
的区别:
灵活性:
waitpid()
允许父进程选择等待特定的子进程,而不是默认等待任何子进程。通过设置pid
参数,父进程可以选择等待某个子进程,或等待一组子进程,或等待任何子进程(pid == -1
)。非阻塞等待:
waitpid()
支持WNOHANG
选项,可以让父进程在没有任何子进程退出时不阻塞,立即返回。wait()
没有类似的选项,它总是会阻塞,直到至少有一个子进程退出。返回信息:
waitpid()
可以通过status
返回子进程的退出状态,并且可以选择是否处理子进程的状态信息。如果不关心退出状态,status
可以设置为NULL
。选择性控制:
waitpid()
提供了更多的选项(如WUNTRACED
、WCONTINUED
),使得父进程可以更精确地控制等待的行为。
4) 非阻塞轮询(Non-blocking Polling):
指在程序中,使用轮询技术来检查某个条件或事件是否发生,但与阻塞方式不同,非阻塞轮询不会让程序停下来等待事件的发生,而是通过不断地检查条件或事件的状态,如果事件没有发生,程序会继续执行其他任务,或者以某种方式休息再继续检查。
当waitpid
的option
参数设置为WNOHANG
选项时,waitpid()
会检查子进程的状态,如果子进程已经退出,它会返回子进程的状态;如果子进程还没有退出,它会立即返回而不会让父进程阻塞。这使得父进程可以在等待子进程结束时继续做其他事情,而不是停下来等待。通过和循环代码的结合就能完成非阻塞轮询。
-
非阻塞轮询的基本原理:
-
轮询:轮询是一种周期性检查某些条件或事件是否发生的技术。例如,在某些资源或输入设备的情况下,程序通过定期检查它们的状态来了解是否发生了某些变化。
-
非阻塞:与阻塞的不同之处在于,阻塞操作会让程序停下来等待条件发生,直到它返回结果。而非阻塞操作不会让程序停止,它会立即返回,无论条件是否满足。
-
在非阻塞轮询中,程序会在循环中不断检查某个状态(例如输入数据是否准备好,或者某个文件是否可用),而不会被阻塞或进入等待状态。如果条件未满足,程序通常会继续做其他工作,或者以某种方式推迟再次检查的时间。
-
-
典型应用场景:
- 网络编程:在网络编程中,非阻塞IO通常用于检查数据是否可读或可写,而不让程序被等待阻塞。比如使用
select()
、poll()
、epoll()
等技术,可以在多个网络连接上轮询,处理已就绪的连接。 - 事件驱动编程:在GUI应用程序或服务器程序中,非阻塞轮询常用于检查用户输入或网络事件。如果没有事件发生,程序可以继续处理其他任务,而不会被卡住等待某个事件的发生。
- 设备轮询:在嵌入式系统中,硬件设备(如传感器或外部设备)的状态常常以非阻塞方式进行轮询。设备驱动程序在不停地检查设备状态,获取数据或者处理中断。
- 网络编程:在网络编程中,非阻塞IO通常用于检查数据是否可读或可写,而不让程序被等待阻塞。比如使用
三、进程程序替换
1)替换原理
在 UNIX 和 Linux 系统中,进程程序替换是指进程调用 exec
系列函数,将当前的程序代码、数据、堆栈等完全替换为新程序的内容。替换的核心原理在于,新程序的代码和数据加载到当前进程的地址空间中,而保留进程的标识信息(如 PID)。以下是进程程序替换的详细原理:
-
地址空间清理与重建:
-
当进程调用
exec
时,操作系统会清空当前进程的地址空间,包括代码段、数据段、BSS 段和堆栈段。 -
接着,新的可执行程序会根据其段信息(如代码段、数据段等)加载到进程的虚拟地址空间中,覆盖掉旧的内容。
-
-
进程 ID 不变:
-
exec
只是替换进程的内容,而不生成新的进程,因此不会改变当前进程的 PID。 -
这意味着
exec
调用前后的 PID 是相同的,使得进程可以保持其在系统中的唯一标识。
-
-
文件描述符继承:
-
大多数文件描述符会被保留(除非设置了
close-on-exec
标志),使新程序可以继续使用现有的文件描述符,如标准输入、标准输出等。 -
这种机制让
exec
非常适用于 shell 等程序,它们通常需要将输入和输出重定向给新进程。
-
-
资源的重新分配与初始化:
-
旧程序的动态内存(如堆)、信号处理器等在替换过程中会被释放或重置。新程序从起始位置(
main
函数)开始执行,并根据新的初始化代码配置自身的运行环境。 -
系统资源如环境变量会在地址空间重建后重新载入。
-
-
保留父子关系:
- 替换程序后,进程的父子关系不变。父进程可以继续监控和等待子进程的状态,从而获得新程序的执行结果或退出码。
-
重新开始执行:
- 在
exec
完成后,进程从新程序的入口点(通常是main
函数)开始执行。旧程序的内容已不复存在,当前进程完全成为了一个新程序的执行体。
- 在
exec
系列函数只有执行失败才有返回值,执行成功时没有返回值。程序替换中,环境变量不会被替换
2) 程序替换接口
程序替换接口指的是 exec
系列函数,用于将当前进程的地址空间替换为新程序的地址空间。调用 exec
函数后,当前进程的代码段、数据段、堆栈段等将被新程序替换,并从新程序的入口开始执行,其本质是一个代码级别的加载器。exec
系列函数的主要接口如下:
-
execl
- 以可变参数形式传递程序参数。int execl(const char *path, const char *arg, ...)
execl("/bin/ls", "ls", "-l", NULL);
-
execlp
- 自动在PATH
中查找可执行文件,以可变参数形式传递程序参数。int execlp(const char *file, const char *arg, ...)
execlp("ls", "ls", "-l", NULL);
-
execle
- 以可变参数形式传递程序参数,并指定环境变量。int execle(const char *path, const char *arg, ..., char *const envp[])
// 自定义环境变量 char *envp[] = { "MYVAR=hello", "PATH=/bin:/usr/bin", NULL }; execle("/bin/ls", "ls", "-l", NULL, envp); // 直接用系统调用environ execle("/bin/ls", "ls", "-l", NULL, environ);
-
execv
- 以数组形式传递程序参数。int execv(const char *path, char *const argv[])
char *args[] = {"ls", "-l", NULL}; execv("/bin/ls", args);
-
execvp
- 自动在PATH
中查找可执行文件,以数组形式传递程序参数。int execvp(const char *file, char *const argv[]);
char *args[] = {"ls", "-l", NULL}; execvp("ls", args);
-
execve
- 以数组形式传递程序参数,并指定环境变量(底层实现,其他接口都基于此函数)。int execve(const char *path, char *const argv[], char *const envp[])
char *args[] = {"ls", "-l", NULL}; char *envp[] = { "MYVAR=hello", "PATH=/bin:/usr/bin", NULL }; execve("/bin/ls", args, envp);
在
exec
系列函数中,字母l
和v
表示参数传递方式的不同:
l
(list):参数以可变参数列表的形式逐个传递,类似于execl
、execlp
。在这种形式中,程序参数依次列出,最后需要用NULL
表示结束。例如,execl("/bin/ls", "ls", "-l", NULL);
。
v
(vector):参数以数组(向量)的形式传递,类似于execv
、execvp
。参数数组是一个以NULL
结尾的指针数组,每个元素指向一个参数字符串。例如,execv("/bin/ls", args);
,其中args
是char *args[] = {"ls", "-l", NULL};
。有了程序替换(通过
exec
系列函数实现)就可以实现跨语言调用程序。这是因为在程序替换后,进程会从一个全新的程序开始运行,而不受先前语言或环境的限制。这种机制使得用一种编程语言编写的程序能够启动并运行其他语言编写的程序。当调用传递环境变量的
exec
时,是彻底替换,而不是新增。如果想新增可以用如下实现:putenv("MYVALUE=123456") execle("/bin/ls", "ls", "-l", NULL, environ);
在
man
手册中查找execve
函数时,可能会发现它并不在man exec
的描述中,而是在man execve
这一条目中有详细记录。这是因为execve
是底层的、系统直接调用的一个接口函数。而其他exec
函数(如execl
、execvp
等)都是基于execve
实现的包装函数。通过分开记录,便于提供系统调用的独立说明。
四、进程中止
1) 退出场景
程序的退出场景通常有以下几种情况:
- 正常退出:程序按预期执行完毕后结束,返回错误码 0,表明一切正常。
- 错误退出:代码执行完毕但结果不符合预期,通常返回一个非零错误码。这个错误码可以帮助父进程判断错误原因。
- 异常终止:进程由于未捕获的信号(如分段错误)或其他致命错误被系统强制终止。
进程结束本质上可能是代码没有跑完,此时我们不关心退出码,而是关注为什么异常和发生了什么异常。因此我们对于程序的正常判断应当先判断代码是否异常,然后判断代码是否正常退出。
进程退出时的PCB会包含退出信息,在父进程调用
waitpid
时,其输出参数status
就会根据子进程PCB内的保存的退出信息得到对应信息,从而将子进程的退出信息交给父进程。
2) 退出码
**退出码(Exit Code)**是指一个程序在结束运行时返回给操作系统的整数值,用于描述程序的终止状态。这些值通常用来指示程序的执行结果、是否遇到了错误、甚至具体的错误原因。退出码可以帮助操作系统、脚本、或其他程序了解运行结果,并决定下一步要做什么。
int main() {printf("process\n");exit(0); // 返回退出码0,由父进程接收。
}
-
echo $?
:查询最近的退出码。
# ? 里保存了最近的退出码内容,echo $? 就查询了其中的内容。 (base) Raizeroko@bciserver:~/code$ echo $? 0
-
char *strerror(int errnum)
:strerror
是一个标准 C 库函数,用于将错误代码转换为对应的错误消息字符串。该函数的常见用法是将系统调用或库函数返回的错误代码(通常保存在errno
中)转化为人类可读的描述信息。# ls访问不存在的文件时,本质是其main返回了退出码2,并打印了对应的错误信息 (base) Raizeroko@bciserver:~/code$ ls test.txt ls: 无法访问 'test.txt': 没有那个文件或目录 (base) Raizeroko@bciserver:~/code$ echo $? 2
-
errno
:errno
是一个全局变量,用于存储最近一次系统调用或标准库函数的错误代码。每当函数调用失败时(返回-1
或NULL
),通常会设置errno
以标识出错的具体原因。errno
是标准 C 库的一部分,在操作系统和编程中非常常见,用来帮助程序判断和处理错误情况。
3) 常见退出方法
-
return
: 在main
函数结束时return
一个状态码,用于表示进程执行状态。main
函数的返回值本质表示进程运行完成时是否是正确的结果,如果不是,可以用不同的退出码表示不同的出错原因。 -
exit(int status)
:该函数进行清理工作(如刷新文件缓冲区,关闭文件描述符等),然后返回退出码,供父进程查询。 -
_exit(int status)
:立即终止进程,跳过清理工作,直接释放进程资源,通常用于 fork 后的子进程在执行 exec 失败后的快速退出。
_exit()
与exit()
的区别:
调用用户注册的清理函数:
exit
通过atexit()
注册的退出处理函数。程序中每次调用atexit()
注册的函数会在进程退出时自动调用,顺序为“后注册的先执行”(LIFO 顺序)。这些函数可以用于执行一些特定的清理操作,如释放资源、输出日志等。_exit
不进行操作。刷新,关闭所有标准 I/O 流的缓冲区:
exit
确保所有stdout
、stderr
等缓冲区中的数据都被写入目标文件或显示设备中,以避免丢失数据。_exit
不进行操作。应用场景不同:
exit
适合在一般情况下正常退出,确保资源和 I/O 缓冲得到妥善处理。_exit
适用于需要快速终止进程的场景,如fork()
后的子进程遇到错误时。本质上
_exit
是被exit
所调用的关系,只不过库j给予了我们能调_exit
和exit
两个接口的选择。
4) 异常终止
进程的异常终止确实本质上是因为收到了某种信号。信号(Signal)是操作系统用来通知进程发生异步事件的一种机制,例如非法操作、用户干预等。当进程收到这些信号并无法处理时,就会被系统终止。典型的异常终止信号包括:
- SIGKILL (
9
):强制终止信号,直接由内核处理,不允许进程捕获或忽略,进程会立即终止。例如通过kill -9 [PID]
发出的信号。 - SIGTERM (
15
):请求终止信号,通常用于程序的正常关闭。程序可以捕获该信号并在退出前执行清理操作。 - SIGSEGV (
11
):段错误信号,通常因进程试图访问无效内存地址引发,例如访问空指针或越界访问。 - SIGINT (
2
):来自键盘的中断信号,通常由用户按下Ctrl+C
触发,允许进程捕获并决定如何处理。 - SIGABRT (
6
):由abort()
函数引发的信号,通常用于异常终止程序,例如发生了严重错误时。