进程的创建
如何创建新进程?
fork
系统调用
fork
系统调用可以用来创建新进程,它会创建一个与原进程几乎完全相同的子进程。子进程会复制父进程的所有内容,包括内存、文件描述符等,但它们有不同的 PID 。
pid_t pid = fork();
if (pid < 0) {
// fork failed
} else if (pid == 0) {
// Child process
} else {
// Parent process
}
vfork
系统调用
vfork
与 fork
类似,但它在创建子进程时不会复制父进程的地址空间,而是让子进程共享父进程的地址空间。这种方法减少了资源开销,但容易出现错误,使用时需要格外谨慎。
pid_t pid = vfork();
if (pid < 0) {
// vfork failed
} else if (pid == 0) {
// This is the child process
execl("/bin/ls", "ls", NULL);
_exit(0); // If exec fails, call _exit to avoid corrupting parent's memory
} else {
// This is the parent process
}
clone
系统调用
clone
系统调用提供了更细粒度的控制,它允许自定义指定创建的新进程是否共享父进程的地址空间、文件描述符、信号处理等。clone
是 pthread
库用来实现线程的基础。
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);
clone
使用示例:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int child_func(void *arg) {
printf("Hello from the child process!\n");
return 0;
}
int main() {
char *stack = malloc(1024 * 1024);
if (!stack) {
perror("malloc");
exit(EXIT_FAILURE);
}
pid_t pid = clone(child_func, stack + 1024 * 1024, SIGCHLD, NULL);
if (pid == -1) {
perror("clone");
exit(EXIT_FAILURE);
}
printf("Child process created with PID %d\n", pid);
wait(NULL);
free(stack);
return 0;
}
创建进程时系统做了什么?
当一个进程被创建时,系统会进行一系列操作来初始化新进程。
- 分配进程描述符:内核首先会为新进程分配一个进程描述符(task_struct),用于存储进程的所有信息,如 PID、状态、优先级、内存管理信息等。
- 复制进程上下文:根据创建方法的不同,内核会复制或共享父进程的资源。
- 地址空间:
fork
会复制整个地址空间,vfork
共享父进程地址空间,直到子进程执行exec
系统调用。 - 文件描述符表:新进程通常继承父进程的文件描述符表,可以选择共享或复制。
- 信号处理表:子进程通常继承父进程的信号处理设置。
- 线程信息:如果使用
clone
,可以选择共享线程信息。
- 地址空间:
- 分配唯一的 PID:接下来内核会为新进程分配一个唯一的进程 ID ,即 PID 。PID 是进程在系统中的唯一标识。
- 初始化内核栈:每个进程都有自己独立的内核栈,用于在内核态运行时保存上下文信息。内核会自动为新进程分配和初始化内核栈。
- 设置 CPU 上下文:接下来内核会设置新进程的 CPU 上下文,如程序计数器、寄存器等。对于
fork
,新进程的程序计数器会指向父进程调用fork
后的下一条指令。 - 将进程加入调度队列:最后,内核会将新进程加入调度队列,等待被调度运行。
可执行程序的加载
如何加载可执行程序?
加载可执行程序主要使用的系统调用是 exec
系列,包括 execl
、execp
、execle
、execv
、execve
等。该系列系统调用具有不同的参数形式,但核心功能相同,即将新的可执行文件加载到当前进程的地址空间,并执行它。
int execl(const char *path, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execlp(const char *file, const char *arg, ...);
int execv(const char *path, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
系统如何完成加载?
当进程调用 exec
系列系统调用时,系统会执行一系列步骤来加载并启动新的可执行程序。
- 验证可执行文件:系统首先检查提供的文件路径,确保可执行文件存在并且进程有权限访问和执行它。如果检查过程出现失败,
exec
调用会返回错误。文件系统负责查找和读取可执行文件,内核通过文件系统接口(VFS,虚拟文件系统)执行路径解析,检查文件权限,以及后续的读取文件头部信息。 - 读取可执行文件头:系统首先读取可执行文件的头部,确定文件类型和格式。Linux 主要支持 ELF(Executable and Linkable Format)格式。
- 释放当前进程资源:在加载新程序前,系统会释放当前进程的资源,包括:
- 当前的内存映射(即卸载当前地址空间)。
- 关闭不需要的文件描述符。
- 解除现有的共享内存段、文件映射等。
- 创建新的地址空间:接下来系统会为新的可执行文件创建新的地址空间,主要包括以下几个部分:
- 代码段:加载程序的代码段到内存。
- 数据段:加载初始化的数据段。
- BSS 段:为未初始化的数据段分配内存。
- 堆栈段:设置用户堆栈。
- 映射共享库:如果可执行文件依赖共享库(动态链接),系统就会加载这些共享库,并将它们映射到进程的地址空间。动态链接器(
ld.so
)负责解析和绑定这些库函数的地址。 - 设置程序入口点:接下来系统会根据可执行文件头部信息设置程序的入口点,即将程序计数器设置为入口点地址。这样就能确保当控制转移到用户态时,CPU 能从正确位置开始执行新程序。
- 传递参数和环境变量:最后系统会将
exec
调用传递的参数和环境变量复制到新进程的用户堆栈中。这些参数通常包括命令行参数(argv
)和环境变量(envp
)。
程序的执行
当程序被加载后,CPU 从可执行文件的入口点开始执行程序指令。这个过程由操作系统内核管理,确保程序能正确执行。
用户模式与内核模式
Linux 进程在两种模式下运行:用户模式和内核模式。用户模式用于执行用户应用程序,而内核模式用于执行系统调用和操作系统服务。通过中断和系统调用,进程可以从用户模式切换到内核模式。
进程的调度
调度算法
Linux 内核中最常用的调度算法是完全公平调度器(Completely Fair Scheduler, CFS)。
顾名思义,CFS 的设计理念是每个进程应当平等地占有 CPU 时间,其核心概念是虚拟运行时间(vruntime),即每个进程已运行的时间加权值。CFS 基于进程的 vruntime 进行调度,优先选择 vruntime 最小的进程运行。
CFS 使用红黑树来维护所有可运行进程的 vruntime 。红黑树是一种自平衡二叉搜索树,能够高效地执行插入、删除和查找操作。
vruntime 是根据实际运行时间 runtime 计算的,并且根据进程的优先级进行加权。优先级较高进程的 vruntime 增长较慢,优先级较低进程的 vruntime 增长较快。具体计算公式如下:
vruntime += delta_exec * load_weight / load_avg;
delta_exec
:进程实际运行的时间片。load_weight
:进程的权重。load_avg
:系统的平均负载。
上下文切换
概述
在进行进程调度时,一定会涉及上下文切换。上下文切换是指 CPU 从一个进程切换到另一个进程的过程,涉及保存当前进程的状态,与加载下一个进程的状态。
上下文切换触发条件
- 进程时间片耗尽
- 进程阻塞或等待 I/O
- 可抢占条件下高优先级进程唤醒
- 进程终止
上下文切换的步骤
- 保存当前进程状态
- 保存 CPU 寄存器:包括通用寄存器、程序计数器(PC)、堆栈指针(SP)等。
- 保存内核栈:保存当前内核栈中的内容。
- 保存页表:保存当前进程的页表信息。
- 切换内核栈:每个进程有自己独立的内核栈,因此要对内核栈进行切换。切换内核栈是通过修改 CPU 的堆栈指针实现的。
- 加载下一个进程状态
- 加载 CPU 寄存器:加载下一个进程的通用寄存器、程序计数器、堆栈指针等。
- 加载内核栈:加载下一个进程的内核栈内容。
- 加载页表:加载下一个进程的页表信息,使 CPU 正确访问进程地址空间。
进程终止
进程何时终止?
进程可以通过多种方式终止,包括正常终止和异常终止。
正常终止
进程正常终止的方式包括:
-
从主函数返回
int main() { // Program code return 0; // 或者 return 1; 表示退出状态 }
-
调用
exit
函数int main() { // Program code exit(EXIT_SUCCESS); // 或者 exit(EXIT_FAILURE); }
-
调用
_exit
或_Exit
函数:与exit
类似,但不执行标准 I/O 缓冲区的刷新和其他清理操作。int main() { // Program code _exit(EXIT_SUCCESS); }
异常终止
进程也可能因异常情况而终止,包括:
-
收到终止信号:如
SIGKILL
、SIGSEGV
等。kill(pid, SIGKILL); // 发送SIGKILL信号
-
运行时错误:如段错误、非法指令等。
-
调用
abort
函数:通常用于在程序检测到严重错误时终止自身。int main() { // Program code abort(); // 终止进程 }
进程终止后系统会做什么?
当进程终止时,系统会进行一系列操作来清理进程资源,并更新系统状态。主要步骤包括:
- 关闭文件描述符:系统会关闭进程打开的所有文件描述符,包括普通文件、套接字、管道等。
- 释放内存:系统会释放进程使用的所有内存资源,包括:
- 用户空间内存:堆、栈、数据段、代码段等。
- 内核空间资源:内核栈、内核数据结构等。
- 删除进程描述符:系统会从进程表中删除进程描述符,并释放相关数据结构。
- 发送信号:若有父进程,系统会向进程的父进程发送
SIGCHLD
信号,通知父进程子进程已终止。 - 进程僵尸状态:在父进程调用
wait
系列系统调用之前,子进程的终止状态和部分信息会保留在系统中,这时进程处于僵尸状态。这样可以确保父进程能够获取子进程的退出状态。 - 清理和回收:父进程调用
wait
或waitpid
获取子进程的退出状态后,系统会彻底清理和回收子进程的所有资源。