第3章 进程
1. 进程、线程,轻量级进程
1.1. 进程
- 进程是程序执行的一个实例,是担当分配系统资源(CPU,内存等)的实体
1.2. 轻量级进程
- Linux没有线程,使用轻量级进程实现线程的POSIX标准库函数
- Linux轻量级进程可以共享地址空间、打开的文件等,这样Linux中每个“线程”都可以被内核独立调度
- 轻量级进程也有自己的task_struct结构
- 轻量级进程和进程的区别,就是是否和别的进程(同个进程组的进程,tgid相同)共享一些地址空间
对于内核任务来说,其使用地址空间都是同一个,所以内核任务一般都叫内核线程,而不是内核进程!
2. 进程描述符
2.1. struct task_struct
或task_t,描述了与进程相关的所有信息,包括:
- 进程优先级
- 当前状态
- 分配的地址空间
- 允许访问的文件
2.2. 进程状态 state字段
TASK_RUNNING
- 可运行状态;要么正在执行,要么准备执行
TASK_INTERRUPTIBLE
- 可以被打断(原文是中断,容易引起误解)的等待状态
- 进程被挂起,直到释放进程等待的资源或给进程传递一个信号(比如更改进程状态位TASK_RUNNING)
TASK_UNINTERRUPTIBLE
- 不可打断的等待状态
- 不能被外部的信号打断,只能等待到需要的资源
TASK_STOPPED
- 暂停状态
- 由这几个信号触发:SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU
TASK_TRACED
- 进程的执行已被debugger程序暂停
- 某个进程的debugger程序执行ptrace()监控另一个进程的时候,会把该进程置为TRACED,表示暂停并读取进程信息
EXIT_ZOMBIE
- 僵死状态,表示进程执行被终止,但是并没有被父进程发布wait4()或waitpid()系统调用,来回收进程资源
- 顺便说下孤儿进程:父进程在子进程之前结束
- 听起来孤儿进程更危险(泄露内存更多),其实孤儿进程会被设置为init()进程的子进程,并不会产生什么问题;
而僵死进程虽然占用进程资源很少(只保留一些必要的信息准备返回父进程),但是如果多的话,也会造成不小的内存泄漏
3. 进程标识符PID
- 和进程一一对应,新创建进程的PID是前一个进程的PID + 1
- PID有上限,超过上限会循环使用已闲置的小号
- Linux的每个线程组具有相同的PID,也就是该组中第一个轻量级进程的PID,存放在进程描述符的tgid字段
- Linux中大多数进程可以认为都属于包含单一成员的线程组
4. thread_info和获取进程描述符
4.1. thread_info和堆栈
- 内核单独为进程分配两个页框(8K)的储存区域,用来存放小数据结构thread_info(线程描述符)和进程堆栈
- 这块储存区域的起始地址是2^13的倍数(用来快速寻址)
- esp寄存器是CPU栈指针,存放栈顶单元的地址;切换进程要做的事情就是修改esp寄存器的值为目标进程的栈指针!
注意:
thread_info存放进程的私有信息,所以称为线程描述符;
task_struct是进程组共享的数据结构,所以称为进程描述符;
当我们提到Linux中的线程的时候,要注意到它是轻量级进程,和进程的唯一区别就是创建方式和地址空间是否共享,其他数据结构都是一样的。
4.2. 获取进程描述符
进程切换:从esp寄存器获取栈指针 -> 屏蔽掉低13位获取thread_info地址(这块储存区域地址位2^13倍数) -> thread_info-> task_struck
thread_info->task指向进程描述符,进程描述符中也包含thread_info的地址。
5. 进程链表
5.1. 进程链表介绍
- 进程链表是一个双向循环链表,每个task_struct结构都包含一个list_head类型的tasks字段
- 进程链表的头是init_task(0进程或swapper进程)描述符
- init_task的tasks.prev指向链表中最后插入的进程描述符的tasks字段(循环)
- 进程调度的时候遍历链表开销很大,所以Linux根据优先权(0~139)创建了140个进程链表可运行(TASK_RUNNING)队列!
5.2. 进程间的关系
① idle进程
系统自动创建,pid = 0,系统创建的第一个进程,运行在内核态;
swapper的意思是交换,但是idle进程并不会管理内核进程或内存/磁盘的换入换出,不要被名字误解了;
使用静态分配的数据结构(其他所有进程的数据结构都是动态分配的);
多处理器中,每个CPU都有一个进程0:开机上电->BIOS启动一个CPU,并禁止其他CPU;初始化内核数据结构,激活其他CPU;拷贝swapper进程到其他CPU。
② init进程
由idle进程创建,pid = 1,完成系统的初始化,是所有用户空间的祖先进程(0生1,1生万物);
如果一个进程父进程被杀死回收,该进程的父进程指向init进程;
系统关闭前,init进程一直存活,因为它创建和监控所有用户空间进程的执行。
③ kthreadd进程
由idle进程创建,pid = 2,管理和调度所有内核线程kernel_thread。
④ 其他内核进程
- keventd:执行keventd_wq工作队列中的函数
- kapmd:处理与高级电源管理(APM)相关的事件
- kswapd:这才是内存回收进程
- pdflush:刷新“脏”缓存区的内容到磁盘以回收内存(不是回收cache)
- kblock:执行kblocked_workqueue工作队列中的函数,周期性地激活块设备驱动程序
- ksoftirqd:运行tasklet;每个CPU都有这样一个内核线程
5.3. 进程描述符中关于进程间关系的字段
① 亲属关系
parent:父进程
children:子进程链表的头
sibling:兄弟进程(当前进程父进程的子进程链表中,当前进程的prev和next进程)
② 非亲属关系
group_leader:当前进程所在进程组的领头进程的描述符指针
tgid:当前进程所在线程组的领头进程的描述符指针
6. 进程散列表
6.1. 目的
根据PID快速获取进程描述符。
6.1. pidhash
引入了四个类型的PID hash:
- 进程的PID
- 线程组领头进程的PID
- 进程组领头进程的PID
- 会话领头进程的PID
和所有散列表形同,每个PID只对应一个表索引,但是一个表索引可能会被多个PID索引到,成为冲突。
解决冲突的方法,就是把表索引改为链表,PID_A和PID_B索引到一个进程描述符链表,然后再遍历链表找真正需要的进程描述符。
7. 等待队列
7.1. sturct wait_queue_t
- unsigned int flag:互斥进程标志,防止互斥的多个进程被一个资源释放同时唤醒,然后又被sleep(惊群现象)
- struct task_struct *task:进程描述符
- wait_queue_func_t func
- struc list_head task_list:等待进程链表
8. 进程切换
8.1. 硬件上下文
进程拥有自己的地址空间,但是必须共享寄存器;
进程恢复前必须装入寄存器的一组数据,成为硬件上下文(hardware context);
硬件上下文的一部分存放在任务状态段(Task State Segment,TSS),另一部分存放在内核态堆栈中;
TSS反映了CPU上当前进程的特权级;
硬件上下文保存在thread_struct的thread字段。
8.2. 执行进程切换
① 进程切换的点:schedule()
② 进程切换分为两步
- 切换页全局目录以安装一个新的地址空间
每个进程有自己的地址空间,地址空间就是系统为进程分配的虚拟内存区域,包括代码区、数据区、堆栈,以及这些区域的用途和访问权限。 - 切换内核态堆栈(ebp)和硬件上下文
用户态进程也是这些步骤吗?比如内核态堆栈和硬件上下文的切换?
——是的,用户态切内核态或者用户态之间的进程切换都是这样,所以减少进程切换也包括减少用户态->内核态的陷入。
8.3. RT_THREAD进程切换
RT_THREAD比Linux的进程切换简单很多,RT_THREAD跑在32位单片机上,也比Linux上常用的Intel和AMD的芯片简单很多。
即便如此,rt_thread的进程切换看上去已经很复杂了。如果看懂了简单版的进程切换,明白CPU寄存器如何参与其中,就可以“浅尝辄止”。
强烈推荐的这篇文章就是:6. 线程的定义与线程切换的实现
9. 创建进程
9.1. 快速创建进程
子进程需要拷贝父进程的整个地址空间,所以创建非常慢而且效率低;现代Unix内核通过引入三种不同的机制解决该问题:
- 写时复制技术:父子进程两者有一个试图写一个物理页,就把物理页内容拷贝到新的物理页,分配给正在写的进程(而不是子进程)
- 轻量级进程(就是线程啊)允许父子进程共享进程在内核的多个数据结构,如:页表(与就是整个用户态地址空间?)、打开的文件表及信号处理
- vfork()系统调用创建的进程能共享父进程的内存地址空间,然后阻塞父进程执行,直到子进程退出或执行新程序;
这样可以防止父进程重写子进程需要的数据;但是子进程会修改父进程需要的数据吗?
9.2. 创建进程的系统调用
clone():创建轻量级进程;fork()和vfork()都是通过调用clone()实现;
fork():flag参数为SIGCHLD、所有标志都清零的clone()函数
vfork():flag参数为SIGCHLD、标志为CLONE_VM及CLONE_VFORK的clone()函数
fork/vfork() clone() do_fork() 通过查找pidmap_array,为子进程分配新的PID 检查父进程的ptrace字段,如果父进程被另外一个进程debugger跟踪,判断它是否也想跟踪子进程 copy_process():复制进程描述符 检查clone_flags标志的一致性:有些标志需要互斥或绑定 security_task_create():所有附加安全的检查 dup_task_struct():为子进程获取进程描述符 检查进程数量,并递增进程计数器 初始化关键字段和变量,比如PID、list_head、自旋锁、定时器等 copy_thread():用父进程CPU寄存器的值初始化子进程的内核栈 sche_fork():对新进程调度程序数据结构的初始化(设置新进程的状态为TASK_RUNNING,并禁止内核抢占) 设置父子进程的关系,也就是设置子进程的tsk->real_parent和tsk->parent 把新进程插入进程链表,并调用attach_pid()把新进程的PID插入pidhash散列表 根据子进程是否为线程组的领头进程(CLONE_THREAD标志被清0),进行进程组的相关调用 递增nr_threads和total_forks变量,记录进程数量 终止并返回子进程的进程描述符 wake_up_new_task() 调整父子进程的调度参数? 如果父子进程在同一个CPU上,把子进程插入到父进程的运行队列,并在父进程之前,省去了写时复制 如果设置了CLONE_STOPPED,则把子进程设置为TASK_STOPPED 如果设置了CLONE_VFORK,则把父进程插入等待队列,挂起父进程直到执行结束,释放自己的内存地址空间
下面这张图参考:一文聊聊Linux中线程和进程的联系与区别!
10. 撤销进程
10.1. 进程终止
exit():终止某一进程
exit_group():终止整个线程组(多个轻量级进程组成一个进程的线程组)
do_exit():终止进程的实际处理函数
- 把进程描述符的flag字段设置为PF_EXITING,表示进程正在被删除
- 如果需要,调用del_timer_sync()从动态定时器队列中删除进程描述符
- 分别调用exit_mm(),exit_sem(),exit_thread()等函数,从进程描述符中分离出分页、信号量等数据结构(资源);如果没有其他进程共享这些数据结构,就删除它们
- 把进程描述符的exit_code字段设置成进程的终止代号,以便退出时返回相关信息(如是否异常终止)给回收进程
- exit_notify():更新进程父子关系;通知父进程子进程死亡;
- schedule()选择新的进程运行
如果一个程序被exit(),但是其父进程并没有wait()该进程(wait()可以等进程结束后回收进程描述符),该进程会被设置EXIT_ZOMBIE;
EXIT_ZOMBIE虽然还有进程描述符,但是里面信息极少,包括终止代号等可能用得到的信息,该状态的进程也不会被调度程序调度。
10.2. 进程删除
两种处理僵死进程的方式
- 如果父进程不接收子进程的信号,调用do_exit();内存的回收将会由进程调度程序来完成
- 给父进程发信号,父进程调用wait4()或waitpid(),回收进程描述符占用内存
本文来自博客园,作者:moonのsun,转载请注明原文链接:https://www.cnblogs.com/moon-sun-blog/p/18654526
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术