ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux 进程、线程与进程通信核心知识笔记

Linux 进程、线程与进程通信核心知识笔记

一、进程

进程由 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。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进