一、进程
进程由 PCB 与进程地址空间组成,地址空间自上而下依次为:堆、栈、bss(未初始化全局变量)、data(已初始化全局变量)、text(代码段)。
堆:存放通过 malloc、new 等手动申请的动态内存,其生命周期由程序员自行控制。
栈:存放函数调用过程中自动管理的数据,包括命令行参数、环境变量、函数参数、返回地址、寄存器现场以及非静态局部变量等,随函数进入和退出自动分配与释放。
进程号(pid)最小为 1。常用进程查看与操作命令如下:
top:以任务管理器方式实时查看进程。ps aux | grep a.out:查看 a.out 进程的快照,包含 pid 号与 stat 状态信息。ps -elf | grep a.out:查看子进程及其父进程(ppid)。kill -l:查询各信号序号及其含义。kill -序号 进程pid号:向指定 pid 的进程发送对应信号。killall -序号 执行文件名:无需 pid 号,按可执行文件名批量发送信号。pstree:以树形结构显示全部进程。pstree -sp 进程pid号:查看指定进程的关系链。
进程状态
- R:运行或就绪(正在运行或等待 CPU)。
- S:可中断睡眠(等待事件,可被信号唤醒)。
- D:不可中断睡眠(等待 I/O,无法被信号杀死)。
- T:暂停或跟踪(被挂起或处于调试中)。
- Z:僵尸(已结束但尚未被父进程回收)。
进程创建
下面给出一个完整的 fork() 使用示例,通过 if 分支区分父进程与子进程的执行逻辑:
#include <stdio.h> #include <unistd.h> int main(void) { pid_t pid = fork(); // 创建子进程 if (pid > 0) { // 父进程分支:fork 返回子进程的 pid 号 printf("父进程:我是父进程,我的 pid = %d,子进程的 pid = %d\n", getpid(), pid); } else if (pid == 0) { // 子进程分支:fork 返回 0 printf("子进程:我是子进程,我的 pid = %d,父进程的 pid = %d\n", getpid(), getppid()); } else { // 失败分支:返回 -1 perror("fork"); return -1; } return 0; }执行结果示例(父子进程执行顺序不确定,pid 号每次运行可能不同):
父进程:我是父进程,我的 pid = 1234,子进程的 pid = 1235 子进程:我是子进程,我的 pid = 1235,父进程的 pid = 1234说明:fork() 成功时,父进程返回子进程的 pid 号,子进程返回 0;失败时返回 -1。通过 if 分支可以让父子进程各自执行不同的代码段。
函数原型
pid_t 进程名 = fork(); // 库:unistd.h,参数:无返回值:成功时,父进程返回子进程 pid 号,子进程返回 0;失败时返回 -1,并设置 errno。
fork() 的本质是创建新进程(子进程),等价于复制调用进程(父进程)生成新进程。由于该函数的功能是复制,父子进程的代码段相同,但子进程不会再次执行从父进程复制来的创建进程代码段,而是直接返回 0。父子进程的 PCB 相互独立,而堆、栈、bss、data、text 的内容相同。
父子进程事件不独立:直接输入即可,无需通过判断区分。
父子进程事件独立:父进程中 fork 返回子进程的 pid 号,子进程中 fork 不会再次执行,而是返回 0,因此可通过分支区分:
if (进程名 > 0) { // 父进程执行内容 } else if (进程名 == 0) { // 子进程执行内容 }常用配套函数:
getpid():获得当前进程的 pid 号。getppid():获得当前进程父进程的 pid 号。printf("%d", pid):显示该 pid 号进程的相关信息。
使用 fork() 时需注意以下几点:
- 父子进程的执行顺序不确定。
- 父子进程各自拥有独立的地址空间(堆、栈、bss、data、text),相互独立。
- 每个进程各自拥有 4GB 虚拟内存空间(3GB 用户空间 + 1GB 内核空间)。
- n 次 fork 会生成 2n-1 个新进程,加上原本的进程共 2n 个。
- fork 后共同操作文件时,子进程会拥有与父进程同名的文件描述符,二者共享文件状态与文件偏移量,可以操作同一个文件。
- 为避免相互影响,可通过上述 if 判断,分别在各自进程中打开文件流进行操作。
fork + exec:用于在子进程中执行完全不同的程序。exec 函数会用新的程序镜像替换当前进程的各个段,其中 PCB 段保持不变。
程序路径可通过终端命令which ls查询(ls 为程序名称)。exec 系列函数如下:
int execl("程序路径", "程序名称", "l", ".", NULL); int execv("程序路径", args); // args 内含程序路径以外的内容 // char * const args[] = {"ls", "l", ".", NULL};exec 会替换原进程的各个段,即 PCB + [堆 | 栈 | bss | data | text]。
PATH:系统环境变量,记录当前系统各个二进制文件(可执行文件)的路径,缩写为 p。可通过echo $PATH在终端查看。
int execlp("程序名称", "ls", "l", ".", NULL); int execvp("程序名称", args); // args 内含程序名称以外的内容 // char * const args[] = {"ls", "l", ".", NULL};environment:环境变量,缩写为 e。
int execle("程序路径", "env", "NULL", environ);当程序运行时需要使用系统环境变量,可使用该函数。使用 environ 前需先声明extern char **environ;,也可自行定义环境变量,例如:
char *ev[] = {"USER = hello", "PASSWD = 12345", NULL}; execle("程序路径", "程序名称", "NULL", ev);int execvpe("程序名称", args); // args 内含程序名称以及环境变量(environ)以外的内容编译时使用gcc 文件名.c -D_GNU_SOURCE,不加该选项会告警,但不影响运行。
拆分字符串
char *str = strtok(字符串, ";;"); // 库:string.h返回值:成功时返回拆分产生的子字符串首地址,失败时返回 NULL。
原理:将输入字符串中的分隔字符全部替换为\0。若想继续拆分,再次调用该函数时将第一个参数改为 NULL 即可。
进程结束
进程结束分为正常结束与异常结束两种:
- 正常结束:从 main 函数返回,或调用 exit / _exit 函数。
- 异常结束:调用 abort 终止,或通过 signal 发送信号结束。
正常结束进程可调用:
exit(传给父进程的状态信息); // 库:stdlib.h返回值:成功时返回输入的值 & 0377,即只有最低八位有效。exit 在退出之前会刷新 I/O 缓存,并调用已注册的退出处理函数。
二、进程退出与资源回收
注册退出清理函数
atexit(退出清理的函数名); // 库:stdlib.h返回值:成功返回 0,失败返回非零值。多个清理函数的调用顺序与注册顺序相反,即先注册的后调用。
退出清理函数示例
void clean_up(void) { printf("clean_up\n"); free(malloc函数申请到的空间名称); }_exit(传给父进程的状态信息); // 库:stdlib.h与 exit 不同,_exit 调用结束后不会处理 I/O 缓存,也不会调用退出清理函数。
进程资源回收
wait(设定子进程退出的状态值); // 库:sys/wait.h,不在意可填 NULL返回值:成功返回结束子进程的 PID 号,失败返回 -1。wait 会等待子进程状态改变(退出),期间阻塞父进程执行,且一次只能处理一个子进程。
也可通过指针配合宏获取退出状态:
int *p; wait(&p); // 通过宏 WIFEXITED(p) 判断是否正常结束 // 再通过宏 WEXITSTATUS(p) 获取子进程退出状态宏
WIFEXITED():判断子进程是否正常结束。WEXITSTATUS():获取子进程退出状态。WIFSIGNALED():获取结束信号的编号。
waitpid(pid, 保存子进程退出状态值, 0 或 WNOHANG); // 库:sys/wait.h返回值:阻塞调用(0)成功时返回成功回收的子进程 pid;非阻塞调用(WNOHANG)时,若子进程状态未改变则返回 0;失败返回 -1。
pid 参数的取值含义:
pid < -1:等待进程组 id 为 pid 绝对值的组中任意子进程状态改变(常用)。pid = -1:等待当前进程的任意子进程状态改变。pid = 0:等待与父进程 pid 相同进程组中的任意子进程状态改变(常用)。pid > 0:等待指定 pid 的进程状态改变。
其中wait(&p)等价于waitpid(-1, &p, 0),均为阻塞调用。
三、线程
线程由线程 ID、程序计数器、寄存器集合和栈组成,侧重执行;同一进程内的线程共享数据与代码文件,但寄存器与栈相互独立。线程的创建与切换效率高于进程,线程依附于进程存在,而进程在隔离性上更加安全稳定。
errno为全局错误变量,库为 errno.h。
线程创建
pthread_create(线程 ID, 线程属性, (void *)线程回调函数, 传递给线程执行函数的参数); // 线程属性填 NULL 表示默认属性(资源回收方式)库:pthread.h。返回值:成功返回 0,失败返回错误码。编译时需要链接-lpthread。
例如:
pthread_t tid; // 线程 id 号使用线程时需注意:
- 主线程与子线程关系对等。
- 线程共享进程资源,包括全局变量、局部变量和堆区变量。
- 若需要多个线程共同影响同一份数据,应传参(地址)并进行解引用(*)。
- 若需要多线程共用一个初值且各自互不干扰,则需通过其他办法获得初值而不改变它。
线程结束
pthread_exit(退出状态值的地址); // 库:pthread.h,退出当前线程pthread_join(线程 ID, NULL 或 &指针); // 库:pthread.h,指针为 void* 类型,用于保存状态值地址返回值:成功返回 0,失败返回错误码。pthread_join 会等待该 ID 的线程结束,并获得其退出状态值;返回的指针在使用时需要进行强制类型转换。
如果主线程调用 pthread_exit,会结束主线程的执行流,但并不会结束整个进程,进程会等待所有子线程结束后才销毁。
取消线程
pthread_cancel(线程 ID); // 库:pthread.h返回值:成功返回 0,失败返回错误码,用于取消对应线程。需要注意,任意线程调用 exit 都会导致进程结束,线程随之不复存在,main 函数中的 return 同理。
获取线程 ID
pthread_self(); // 库:pthread.h,成功返回当前线程 ID更改线程状态
pthread_detach(线程 ID); // 库:pthread.h返回值:成功返回 0,失败返回错误码。该函数将指定 ID 的线程更改为可分离状态,推荐在线程创建后立即调用。
- 可结合线程:调用 pthread_exit 退出,调用 pthread_join 回收资源并获得退出状态值(创建时默认可结合)。
- 可分离线程:调用 pthread_detach 更改状态,线程结束后自动回收。
四、线程库——互斥锁
使用临界资源时,应遵循“先上锁、再使用、后解锁”的原则。当多个线程需要分别修改并共同影响同一份数据时,就需要使用互斥锁,库为 pthread.h。常用接口如下:
pthread_mutex_init:创建互斥锁。pthread_mutex_lock:线程上锁。pthread_mutex_unlock:线程解锁。pthread_mutex_destroy:销毁互斥锁。
销毁互斥锁
下面给出一个使用 pthread_mutex_t 保护临界资源的完整示例,两个线程对共享变量 counter 进行递增操作:
#include <stdio.h> #include <pthread.h> #include <unistd.h> #define THREAD_NUM 2 #define LOOP_NUM 100000 int counter = 0; // 共享变量(临界资源) pthread_mutex_t mutex; // 定义互斥锁 // 线程回调函数:对共享变量进行递增 void *thread_func(void *arg) { for (int i = 0; i < LOOP_NUM; i++) { pthread_mutex_lock(&mutex); // 上锁:进入临界区前加锁 counter++; // 临界区:操作共享变量 pthread_mutex_unlock(&mutex); // 解锁:离开临界区后解锁 } return NULL; } int main(void) { pthread_t tid[THREAD_NUM]; // 创建互斥锁(NULL 表示使用默认属性) pthread_mutex_init(&mutex, NULL); // 创建两个线程 for (int i = 0; i < THREAD_NUM; i++) { pthread_create(&tid[i], NULL, thread_func, NULL); } // 等待两个线程结束 for (int i = 0; i < THREAD_NUM; i++) { pthread_join(tid[i], NULL); } // 销毁互斥锁 pthread_mutex_destroy(&mutex); printf("counter = %d(期望值 %d)\n", counter, THREAD_NUM * LOOP_NUM); return 0; }编译运行:
gcc mutex_demo.c -o mutex_demo -lpthread ./mutex_demo运行结果示例:
counter = 200000(期望值 200000)说明:
- 上锁:每个线程在进入临界区前调用 pthread_mutex_lock,保证同一时刻只有一个线程能操作 counter。
- 解锁:操作完成后调用 pthread_mutex_unlock,释放锁让其他线程进入。
- 销毁:所有线程结束后调用 pthread_mutex_destroy 释放锁资源。
- 如果不加锁,两个线程同时递增 counter 会产生竞态条件,最终结果会小于期望值 200000。
各接口的详细用法如下:
// 定义锁 pthread_mutex_t 锁的名称; // 创建互斥锁,锁的类型填 NULL 表示普通锁 pthread_mutex_init(锁的地址, 锁的类型); // 返回值:成功 0,失败 错误码 // 线程上锁 pthread_mutex_lock(应用的锁的地址); // 返回值:成功 0,失败 错误码 // 线程解锁 pthread_mutex_unlock(需要解除的锁的地址); // 返回值:成功 0,失败 错误码 // 销毁互斥锁 pthread_mutex_destroy(需要销毁的锁的地址); // 返回值:成功 0,失败 错误码控制顺序
信号量可用于控制线程或进程的执行顺序,库为 semaphore.h。
sem_t 信号资源名称; // 为相应线程/进程定义信号资源信号资源
sem_init(信号资源名称的地址, 0 或 !0, 0 或 1); // 第二个参数:0 表示线程信号,!0 表示进程信号 // 第三个参数:0 表示没有资源时保持阻塞,1 表示拥有资源继续执行返回值:成功返回 0,失败返回 -1,用于初始化信号资源。
申请资源
sem_wait(信号资源名称的地址);资源为 1 时先申请后减 1;资源为 0 时保持阻塞,等待资源变为非 0。返回值:成功返回 0,失败返回 -1。
释放资源
sem_post(信号资源名称的地址); // 资源加 1,不会阻塞返回值:成功返回 0,失败返回 -1。
销毁资源
sem_destroy(信号资源名称的地址); // 销毁资源变量返回值:成功返回 0,失败返回 -1。
死锁
死锁的产生需要同时满足四个条件:互斥、不可剥夺、请求保持、循环等待。
五、进程通信
同一主机内
- 传统方式:无名管道、有名管道、信号。
- System V 方式:共享内存、消息队列、信号量。
不同主机间
不同主机之间通过网络通信实现进程间数据交换。
无名管道
无名管道用于具有亲缘关系的进程间通信。
int 管道名称[2]; pipe(管道名称); // 库:unistd.h,[0] 为读端,[1] 为写端返回值:成功返回 0,失败返回 -1 并设置 errno。
使用要点:
- pipe 会在内核中创建一条管道,不需要的读端或写端可通过
close(管道名称[0/1])关闭。 - 管道是半双工的:同一时刻只能单向传输,遵循 FIFO 队列(先进先出,读走即删除),不支持 lseek 定位。
- I/O 操作包括 open(很少应用)、read、write、close,库为 unistd.h。
- 容量:Linux 下实际大小约为 64KB,超出后可能触发管道破裂。
- 读端存在时:若写满则阻塞,若为空则可写;读端关闭后,写操作会直接异常终止程序(触发 SIGPIPE 信号)。
- 写端存在时:有数据则读取,为空则阻塞;写端关闭后,有数据则读完,为空则 read 返回 0,不阻塞。
- 若要实现全双工通信,需要使用两个管道。
有名管道
有名管道可用于任意两个进程之间的通信,本质是存在于系统内核中的特殊文件,使用步骤为:打开文件、读写、关闭。
mkfifo(管道名称, 创建权限(0666)); // 库:sys/types.h 和 sys/stat.h返回值:成功返回 0,失败返回 -1 并设置 errno。
使用要点:
- 创建有名管道时需确认
errno != EEXIST(该宏表示文件已存在),否则会报错。 - 若只打开管道的一端则会阻塞,直至另一端以不同类型的 open 打开后解除阻塞;若使用
open(文件名, O_RDWR)则不会阻塞。 - 读端:
open(文件名, O_RDONLY);写端:open(文件名, O_WRONLY);O_RDWR 表示可读可写。 - 删除文件:
unlink(文件名),库为 unistd.h。
信号处理
signal(信号, SIG_DFL / SIG_IGN / 自定义信号处理函数名称); // 库:signal.h返回值:成功返回上一次的处理函数,失败返回 SIG_ERR。
SIG_DFL:默认处理(终止、忽略、core dump)。SIG_IGN:忽略信号。func_ptr:自定义信号处理函数。
signal 用于注册信号处理函数,以应对信号产生的结果。其中 SIGCHLD 信号表示子进程状态发生改变,可在自定义函数中调用 wait / waitpid,实现不阻塞主进程即可回收资源。例如:
void 函数名(int signo) { // 处理逻辑 }kill(pid号, 信号); // 库:sys/types.h 和 signal.h返回值:成功返回 0,失败返回 -1,用于向指定 pid 号的进程发送信号。例如:
kill(pid, SIGKILL); // 让该 pid 号的进程结束 kill(getpid(), SIGKILL); // 结束自己所在的进程pause(); // 库:unistd.h,用于等待信号返回值:成功时表示捕获信号的函数成功取得信号,失败返回 -1。
共享内存
共享内存类似管道,但数据无需多次复制,因此效率更高。
获取共享内存的 key 值
key_t KEY = ftok("文件路径", '项目标识符'); // 库:sys/types.h 和 sys/ipc.h,项目标识符低八位有效ftok 函数将路径名和项目标识符转换成一个 key 值,用于标识共享内存段。返回值:成功返回 key_t 类型值,失败返回 -1 并设置 errno。
创建共享内存
int 共享内存ID = shmget(KEY, 共享内存大小, 创建权限); // 库:sys/ipc.h权限中若添加 IPC_CREAT 则创建共享内存,添加 IPC_EXCL 则表示若共享内存已存在则返回错误。返回值:成功返回共享内存 ID,失败返回 -1 并设置 errno。
进程通信方式对比
| 通信方式 | 通信方向 | 是否亲缘关系 | 数据量 | 是否阻塞 | 典型应用场景 |
|---|---|---|---|---|---|
| 无名管道 | 半双工(单向,需两个管道实现全双工) | 仅亲缘关系(父子进程) | 小(Linux 默认 64KB) | 读写端存在时,写满或读空会阻塞 | 父子进程间简单数据传递、命令管道 |
| 有名管道 | 半双工(单向) | 任意进程 | 小 | 打开一端会阻塞,直至另一端打开 | 无亲缘关系的本地进程间通信 |
| 信号 | 单向(异步通知) | 任意进程 | 极小(仅信号编号) | 非阻塞(异步) | 进程控制、异常通知、终止进程 |
| 共享内存 | 双向(读写同一块内存) | 任意进程 | 大 | 非阻塞(需配合信号量同步) | 大数据量、高频数据交换 |
| 消息队列 | 双向(按消息类型读写) | 任意进程 | 中等 | 读空或写满时可能阻塞 | 进程间结构化消息传递、解耦通信 |
| 信号量 | 单向(计数同步) | 任意进程 | 极小(计数值) | 资源不足时阻塞等待 | 进程/线程同步、互斥访问共享资源 |
六、映射共享内存地址到进程的私有空间
void* 共享内存地址 = shmat(共享内存ID, NULL, 读写权限); // 库:sys/ipc.h // 第二个参数填 NULL 表示由系统指定地址返回值:成功返回共享内存地址,失败返回 -1 并设置 errno。判断映射是否成功时,需要将返回值强转为(void *)-1进行比较。
解除共享内存映射
shmdt(共享内存地址); // 库:sys/ipc.h返回值:成功返回 0,失败返回 -1 并设置 errno。共享内存地址必须使用 void* 类型的原地址,推荐将源地址保存下来,或直接用所需类型的地址变量承接该地址。
删除共享内存
shmctl(共享内存ID, IPC_RMID, NULL); // 库:sys/ipc.h,第三个参数填 NULL 表示不使用返回值:成功返回 0,失败返回 -1 并设置 errno。