一、为什么需要进程与线程
程序与进程的关系:程序是进程的静态基础,进程是程序运行的动态过程。操作系统运行的本质就是让进程占用内存与CPU资源完成计算任务。
|
对比项 |
程序 |
进程 |
|
存在形式 |
静态:存储在硬盘中的代码与数据的集合 |
动态:程序执行的过程(创建、调度、消亡) |
|
生命周期 |
永久存在 |
暂时存在 |
|
状态变化 |
无 |
有(创建、就绪、运行、阻塞、消亡) |
|
并发性 |
无 |
进程可以并发 |
|
资源占用 |
不占用运行资源 |
进程间竞争计算机资源 |
|
对应关系 |
一个程序可运行多次,变成多个进程 |
一个进程可以运行一个或多个程序 |
但一个CPU同一时刻只能执行一个任务,而系统中需要运行的任务有多个。要实现"在一个时间段内同时运行多个任务"(并发),就需要操作系统对进程、线程进行合理的管理与调度。
- 进程是系统中最小的资源分配单位;
- 线程是系统中最小的执行单位;
- 进程之间资源隔离,互相竞争计算机资源。
二、总体分类
|
类别 |
核心内容 |
关键函数 |
|
进程控制 |
PCB、虚拟内存、进程状态、fork/退出/回收 |
fork、getpid、exit、wait/waitpid、exec族 |
|
线程控制 |
pthread创建/退出/回收、互斥锁、信号量、死锁 |
pthread_create、pthread_exit、pthread_join、pthread_mutex_、sem_ |
|
IPC进程间通信 |
管道、信号、共享内存 |
pipe、mkfifo、kill、signal、ftok、shmget、shmat |
三、进程
1. PCB(进程控制块)
进程是一个程序执行的过程,执行时要分配内存资源与CPU资源。内核用PCB(Process Control Block)这个结构体描述正在运行的进程的全部信息。
PCB中的主要内容包括:
- PID:进程标识符;
- 当前工作路径(可用chdir修改);
- umask(默认0002);
- 进程打开的文件列表;
- 信号相关设置(处理异步IO);
- 用户id、组id;
- 进程的资源限制(打开文件个数1024、栈大小8M等)。
2. 虚拟内存与MMU
Linux给每个进程一套虚拟内存,由MMU(内存管理单元)完成虚拟地址到物理地址的转换,主要为了两点:
3. 进程分类与调度
- 交互式进程:运行后需要用户输入,再给出对应输出;
- 批处理进程:如shell脚本,批量执行命令;
- 守护进程:自动运行,默认不需要输入,平时处于休眠状态,特定条件满足后自行开始工作(如系统更新、杀毒软件)。
调度:系统中需要运行的任务多而CPU只有一个,操作系统需要合理安排下一时刻由哪个进程运行。常见调度算法:时间片轮转、短任务优先、进程优先级、完全公平调度算法(CFS)。
宏观并行:一个时间段内每个进程都在运行;微观串行:某一时刻只有一个进程在运行。
进程上下文切换:当a.out的CPU时间片耗尽需要运行b.out时,a.out要把自己的状态信息(PCB、硬件寄存器、PC、内存相关数据)缓存保存;b.out恢复自己之前缓存的数据后开始运行。
4. 进程相关命令
|
命令 |
作用 |
|
ps aux |
显示所有进程相关信息 |
|
top |
Linux中的任务管理器 |
|
kill -9 pid |
关闭指定pid的进程 |
|
killall -9 a.out |
关闭所有名为a.out的进程 |
5. fork创建子进程
pid_t fork(void);
功能:从当前进程克隆一个子进程。一次调用,返回两次。
- 子进程复制父进程的0~3G空间与内核中的PCB,但pid不同;
- 子进程的执行过程从fork函数之后开始,与父进程具有相同的代码逻辑;
- 父进程中:成功返回子进程pid(>0),失败返回-1;
- 子进程中:成功返回0。
fork后父子进程谁先运行顺序不确定,且变量不共享。
相关函数:
pid_t getpid(void); /* 获得调用进程的pid */
pid_t getppid(void); /* 获得调用进程的父进程pid */
6. 写时复制
子进程是父进程的复制品。Linux 2.6之后(如Ubuntu 18.04,内核5.4),子进程复制父进程内存空间时采用写时复制:
刚fork完毕,子进程使用的内存空间全部共享父进程的;随着时间推移,父子进程修改某个变量或内存区域时,才真正开辟对应的内存空间。这项技术避免了不必要的复制开销。
7. 进程的终止:8种情况
|
类型 |
情况 |
|
正常终止 |
main中return |
|
正常终止 |
exit():C库函数,执行IO库清理、关闭所有流和已打开文件、执行atexit注册的清理函数 |
|
正常终止 |
_exit/_Exit:关闭所有已打开文件,不执行清理函数 |
|
正常终止 |
主线程退出 |
|
正常终止 |
主线程调用pthread_exit |
|
异常终止 |
abort() |
|
异常终止 |
signal:如kill pid |
|
异常终止 |
最后一个线程被pthread_cancel |
exit与_exit对比:
|
对比项 |
exit |
_exit |
|
属性 |
C库函数 |
系统调用 |
|
刷新缓冲区 |
刷新 |
不刷新 |
|
atexit注册的清理函数 |
执行 |
不执行 |
|
关闭文件 |
关闭所有流及已打开文件 |
关闭所有已打开文件 |
void exit(int status); /* 库函数:退出并刷新缓冲区 */
void _exit(int status); /* 系统调用:退出不刷新缓冲区 */
exit的完整流程:exit -> 刷新缓冲区 -> atexit注册的退出函数 -> _exit。
return在main函数中可结束进程,在其他函数中只结束该函数。
8. 僵尸进程与孤儿进程
僵尸进程与孤儿进程对比:
|
对比项 |
僵尸进程 |
孤儿进程 |
|
先消亡的一方 |
子进程先消亡 |
父进程先消亡 |
|
用户内存空间 |
已释放 |
由新父进程接管 |
|
内核PCB |
未释放,残留 |
正常回收 |
|
危害 |
大量堆积导致内核内存不足、系统不稳定甚至崩溃 |
无需过多关注 |
|
回收方式 |
父进程调用wait/waitpid回收 |
被子进程寻找的新父进程收养并回收 |
可用ps或top观察进程状态。
9. 进程回收:wait与waitpid
pid_t wait(int *status);
功能:阻塞等待任意子进程退出并回收该进程的状态,只能由父进程调用。
- status:子进程退出时的状态,不关心就传NULL;
- 返回值:成功返回回收的子进程pid,失败返回-1。
常用状态宏:
|
宏 |
作用 |
|
WIFEXITED(status) |
判断子进程是否正常结束 |
|
WEXITSTATUS(status) |
获取正常结束时的返回值 |
|
WIFSIGNALED(status) |
判断是否被信号终止 |
|
WTERMSIG(status) |
若是信号终止,获取信号编号 |
pid_t waitpid(pid_t pid, int *status, int options);
waitpid(-1, status, 0) 等价于 wait(status)。
- pid:-1表示所有子进程;
- options:0表示阻塞等待;WNOHANG表示非阻塞回收,函数不等待,需要判断返回值决定是否回收成功(通常外面套死循环反复尝试);
- 返回值:成功返回回收的子进程pid;0表示子进程尚未退出,需下次再试;-1表示错误。
10. exec族函数
功能:执行本机上的任意一个可执行文件。一般与fork搭配,在子进程中执行exec。执行exec后,进程的代码段被新程序替换,新程序执行结束则整个进程结束。
命名规则:
- l(list):参数一个个列出;
- v(vector):参数存放在数组中;
- p(PATH):在系统环境变量PATH中查找文件。
int execl(const char *path, const char *arg, …);
int execlp(const char *file, const char *arg, …);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
- path:新程序的路径+文件名;
- file:新程序文件名(默认在PATH中查找);
- arg/argv:新程序的参数列表,最后一个参数必须是NULL。
需要调用自己编译的可执行程序时,第一个参数按"路径+文件名"填写即可。
int system(const char *command);
功能:执行一个shell命令。注意不能是需要修改父进程状态的命令,多为信息输出、文件相关操作。内部实现是fork+exec。
11. 工作目录函数
char *getcwd(char *buf, size_t size); /* 获取当前工作路径 */
int chdir(const char *path); /* 改变当前工作路径,成功0,失败-1 */
- buf:存储路径的字符数组;size:数组最大长度;
- getcwd返回指向buf的指针,失败返回NULL。
四、线程
1. 线程概念
Linux中,线程是轻量级的进程,线程属于某个进程。进程内线程与线程是平级关系,进程默认有一个主线程。
线程与进程的区别:
|
对比项 |
进程 |
线程 |
|
资源 |
资源独立,最小资源分配单位 |
共享进程资源,最小执行单位 |
|
稳定性 |
相对稳定 |
不稳定:多线程中一个崩溃,整个进程崩溃 |
|
创建开销 |
需创建约3GB空间 |
在进程空间中开辟一个新栈区(8MB) |
|
并发度 |
较低 |
高于进程 |
每个线程有各自的栈区,这个区域是独立的。
2. 线程编程步骤
创建多线程 -> 线程空间操作 -> 线程资源回收(线程退出后默认栈区不释放)。
查看线程信息的命令:
ps -eLo pid,ppid,lwp,stat,comm
ps -eLf
3. 线程相关函数
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);
功能:创建一个指定线程。
- thread:线程id,预先定义,由该函数返回;
- attr:线程属性,一般NULL表示默认属性;
- start_routine:函数指针,线程的执行空间(回调函数);
- arg:传给回调函数的参数;
- 返回值:成功0,失败返回错误码。
pthread_t pthread_self(void); /* 获取当前线程id,打印用%lu */
void pthread_exit(void *retval);
功能:子线程自行退出。retval是退出时的返回状态("临终遗言")。
int pthread_cancel(pthread_t thread);
功能:请求结束一个线程。成功0,失败非0。
int pthread_join(pthread_t thread, void **retval);
功能:回收指定线程的资源,具有阻塞等待功能:线程没结束,回收线程就阻塞。
- thread:要回收的子线程tid;
- retval:回收的返回值,对应pthread_exit传入的值;
- 返回值:成功0,失败非0值。
int pthread_detach(pthread_t thread);
分离属性:设置了分离属性的线程退出后,栈区资源由系统自动回收,创建者不能再join回收。
4. 互斥锁
概念:在多线程中对临界资源的排他性访问。临界资源是多线程中会被读写的资源(全局变量、文件、设备),排他访问指同一时刻只能有一个线程操作它。
为什么需要:线程并发运行时代码穿插执行。以A++为例,它翻译成汇编至少3句,若线程A执行了前2句就被切换到线程B,执行完再切回来,就会产生数据一致性问题。解决方案是互斥。
使用步骤:定义互斥锁 -> 初始化锁 -> 加锁 -> 解锁 -> 销毁。
pthread_mutex_t mutex; /* 1.定义 */
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
/* 2.初始化:mutex要初始化的锁;attr一般NULL默认锁;成功0,失败非零 */
int pthread_mutex_lock(pthread_mutex_t *mutex);
/* 3.加锁:加锁到解锁之间的代码构成原子操作,其他线程不能操作;
锁被占用时本函数阻塞;成功0,失败非零 */
int pthread_mutex_unlock(pthread_mutex_t *mutex);
/* 4.解锁:解锁后代码不再排他访问,一般加锁解锁成对出现;成功0,失败非零 */
int pthread_mutex_destroy(pthread_mutex_t *mutex);
/* 5.销毁:使用完毕后销毁互斥锁;成功0,失败非零 */
原子操作:在线程的一次调度中必须完整执行、不能发生线程切换的代码段。
5. 同步与信号量
概念:有一定先后顺序的对资源的排他性访问。互斥包含同步,同步是互斥的一个特例。
与互斥锁的区别(互斥包含同步,同步是互斥的一个特例):
|
对比项 |
互斥锁 |
信号量(同步) |
|
加锁/释放关系 |
加锁和解锁是同一个线程 |
交叉释放:th1释放th2,th2释放th1 |
|
临界区要求 |
不要休眠、不要大耗时操作,代码短小精悍 |
可适当休眠、小的耗时操作 |
|
初值 |
二值(0/1) |
计数信号量,初值可大于1(如3、5),用于资源数不唯一的情况 |
使用步骤:信号量定义 -> 初始化 -> PV操作 -> 销毁。
sem_t sem; /* 1.定义 */
int sem_init(sem_t *sem, int pshared, unsigned int value);
/* 2.初始化:pshared为0表示线程间使用;非0表示进程间使用;
value信号量初值,无名信号量一般是0/1二值信号量;
0表示红灯(阻塞),1表示绿灯(通过);成功0,失败-1 */
int sem_wait(sem_t *sem);
/* 3.P操作(申请资源):sem有资源(==1)则申请并继续,sem为0则阻塞等待;
申请后自动执行 sem = sem – 1;成功0,失败-1 */
int sem_post(sem_t *sem);
/* 3.V操作(释放资源):释放信号量,自动执行 sem = sem + 1;
不会阻塞;成功0,失败-1 */
int sem_destroy(sem_t *sem);
/* 4.销毁:成功0,失败-1 */
6. 死锁
概念:由于锁资源安排不合理(锁的申请和释放逻辑不对),导致进程/线程无法正常继续推进的现象。
产生死锁的四个必要条件:
五、IPC进程间通信
1. 为什么需要IPC
进程空间都是独立的(虚拟内存隔离),但进程之间有数据共享或交换的需求,所以需要IPC(interprocess communication)。
2. IPC分类
|
类别 |
典型方式 |
说明 |
|
古老的通信方式 |
无名管道、有名管道、信号 |
简单、历史长 |
|
IPC对象通信(System V) |
消息队列、共享内存、信号量集 |
通过key/id管理 |
|
socket通信 |
网络通信 |
不同主机间交互 |
六、无名管道(pipe)
1. 概念
无名管道通过pipe()创建,只能给有亲缘关系的进程(典型:fork出的父子进程)通信。管道的底层实现是队列。
2. 特点
- 管道是半双工工作模式,实际编程中当作单工使用;
- 所有管道都是特殊文件,不支持定位操作(lseek/fseek);
- 读写首选文件IO:open、read、write、close(标准IO带缓冲区也可选)。
3. 阻塞规则
|
情况 |
结果 |
|
读端存在,一直写超过64K |
写阻塞 |
|
写端存在,管道为空时读 |
读阻塞 |
|
读端关闭,继续写 |
管道破裂,写进程退出 |
|
写端关闭且管道无内容 |
read返回0,通信结束 |
4. 接口
int pipe(int pipefd[2]);
- pipefd[0]:无名管道的固定读端;
- pipefd[1]:无名管道的固定写端;
- 返回值:成功0,失败-1。
5. 编程顺序
创建管道 -> fork -> 父子进程各自读写管道 -> 关闭管道
6. 示例框架
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
int fd[2];
pipe(fd);
pid_t pid = fork();
if (pid == 0) { /* 子进程:读 */
close(fd[1]);
char buf[128] = {0};
read(fd[0], buf, sizeof(buf) – 1);
printf("child: %s\\n", buf);
close(fd[0]);
} else if (pid > 0) { /* 父进程:写 */
close(fd[0]);
const char *msg = "hello pipe";
write(fd[1], msg, strlen(msg));
close(fd[1]);
wait(NULL);
} else {
perror("fork");
return 1;
}
return 0;
}
七、有名管道(FIFO)
1. 概念
有名管道通过mkfifo()创建,可以给任意单机进程通信,有文件名称,在文件系统中可见。
2. 特性
- 与匿名管道特性一致(半双工、不支持定位、阻塞规则相同);
- 额外特性:如果有一端没有打开,另一端默认在open函数处阻塞。
3. 编程顺序
创建有名管道 -> 打开有名管道 -> 读写管道 -> 关闭管道 -> 卸载有名管道
4. 接口
int mkfifo(const char *pathname, mode_t mode);
- pathname:要创建的有名管道路径+名称;
- mode:8进制文件权限(如0666);
- 返回值:成功0,失败-1。
示例:
mkfifo("/tmp/myfifo", 0666);
int fd = open("/tmp/myfifo", O_RDONLY);
read(fd, buf, sizeof(buf));
close(fd);
unlink("/tmp/myfifo");
八、信号通信
1. 概念
信号是异步通知机制,用于告知进程发生了某个事件。应用场景:用户按键(Ctrl+C)、定时器超时、子进程结束、非法内存访问、其他进程主动通知等。
2. 信号发送和接收的过程
3. 信号相关函数
int kill(pid_t pid, int sig);
功能:给pid进程发送编号为sig的信号。信号编号可用kill -l查看。成功0,失败-1。
sighandler_t signal(int signum, sighandler_t handler);
功能:设置信号的处理方式。handler有三种取值:
- SIG_DFL:默认处理;
- SIG_IGN:忽略处理;
- 自定义函数:信号到达时执行。
注意:SIGKILL和SIGSTOP不能被捕获、阻塞或忽略。
九、共享内存
1. 概念
共享内存是System V提供的一种IPC方式:把同一块物理内存映射到多个进程的虚拟地址空间,双方直接读写,是速度最快的IPC之一。
2. 特点
- 通信双方都可以读写;
- 没有读阻塞、没有写阻塞,必须搭配其他IPC(信号或信号量集)做同步;
- 数据不删除就一直保持(像一块公共字符数组);
- 空间可自定义(4K×n),适合大块数据。
3. 使用流程
生成key -> 申请共享内存 -> 映射共享内存 -> 读写共享内存 -> 撤销映射 -> 删除共享内存
4. 接口
key_t ftok(const char *pathname, int proj_id);
功能:用pathname指定路径和proj_id生成唯一键值。pathname可以是任意文件,只要不会被删除重建即可;proj_id一般用ASCII单字符。成功返回唯一键值,失败-1。
int shmget(key_t key, size_t size, int shmflg);
功能:用key向内核申请共享内存。
- key:ftok生成的键值;
- size:申请大小(字节);
- shmflg:权限(八进制),第一个申请加IPC_CREAT,检测是否已存在用IPC_EXCL;
- 返回值:成功返回共享内存id(shmid),失败-1。
void *shmat(int shmid, const void *shmaddr, int shmflg);
功能:将shmid对应的共享内存映射到本地。
- shmaddr:本地可用地址,不确定传NULL由系统分配;
- shmflg:0读写;SHM_RDONLY只读;
- 返回值:成功返回映射地址,失败(void*)-1。
int shmdt(const void *shmaddr); /* 撤销映射:成功0,失败-1 */
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
/* cmd为IPC_RMID时删除共享内存对象,buf传NULL;成功0,失败-1 */
读写方式:二进制对象用memcpy(),字符串对象用strcpy()。
总结:shmget/shmat/shmdt都不删除共享内存,只有shmctl(IPC_RMID)标记删除。
5. 与管道的区别
|
对比项 |
管道 |
共享内存 |
|
数据形式 |
字节流 |
普通内存区域 |
|
读写方向 |
通常单向 |
双方均可读写 |
|
阻塞 |
可能读/写阻塞 |
不阻塞 |
|
速度 |
需经过内核缓冲区 |
直接访问,速度快 |
|
数据保留 |
读走后消失 |
删除对象前一直存在 |
|
同步 |
管道自身有一定协调 |
必须额外同步 |
6. 相关命令
ipcs -a /* 查询共享内存、信号量集、消息队列 */
ipcrm -m shmid /* 删除共享内存 */
十、对比总结
|
机制 |
适用关系 |
方向 |
阻塞特性 |
同步方式 |
|
无名管道 |
亲缘进程 |
半双工(当单工用) |
读写均可阻塞 |
read返回0判结束 |
|
有名管道 |
任意单机进程 |
半双工(当单工用) |
open处可能阻塞 |
同无名管道 |
|
信号 |
任意进程 |
单向通知 |
不阻塞(异步) |
signal自定义处理 |
|
共享内存 |
任意进程 |
双向读写 |
完全不阻塞 |
须搭配信号/信号量集 |
网硕互联帮助中心






评论前必须登录!
注册