进程管理
进程管理
进程数据结构
进程的数据结构是task struct
- cpu资源:
- 调度优先级
- 内存地址空间资源:
- mm_struct
- 打开的文件资源:
- file_struct files (一个数组, 存的就是打开的文件的地址, 索引即是文件描符 fd)
进程自己的信息与状态
- 进程状态: 存储在task->state, task->exit_state两个字段中
- 如TASK_RUNNING, TASK_INTERRUPTIBLE, TASK_UNINTERRUPTIBLE, __TASK_STOPED…
- 唯一ID
- pid: 线程级别的id
- gtid: 进程级别的id
- 文件系统信息
struct fs_struct *fs - namespace
- 进程树关系: 该进程在整个进程树里面的位置
进程的状态
1 | /* Used in tsk->state: */ |
常见状态语义说明:
存储在task->state的
- TASK_RUNNING: 进程正在运行队列中, 准备运行/正在运行
- TASK_NORAML/TASK_INTERRUPTIBLE/TASK_UNINTERRUPTIBLE:
- TASK_NORMAL =
(TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE)是这两个状态的综合, 表明进程正在睡眠 - TASK_INTERRUPTIBLE: 进程处于可打断的睡眠状态, 正在等待某个条件满足, 被wake_up唤醒, 或者被信号唤醒. 不会被计入load average
- TASK_UNINTERRUPTIBLE: 进程处于不可中断的睡眠状态, 只能在条件满足时, 被wake_up唤醒, 不能被信号唤醒, 常用于(如磁盘 I/O)等不能被中途打断的SLEEP, 这也代表, 在这个状态的进程的数量能一定程度反应当前计算机的物理负载, 这个状态会被计入load average
- TASK_KILLABLE =
(TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)可以被致命信号杀死(SIGKILL)的不可中断睡眠 - __TASK_STOPPED = 进程被暂停执行了, 不会被调度. 1. 收到SIGSTOP / SIGSTP / SIGTTIN / SIGTTOU 信号 2. ptrace attach 后发送 SIGSTOP. 常见的进入情景: 1. Ctrl + z 2. debug. 退出的时机: 收到 SIGCONT 信号
- __TASK_TRACED = 进程正在被调试器跟踪
- TASK_IDLE (TASK_UNINTERRUPTIBLE | TASK_NOLOAD):
- 语义: TASK_UNINTERRUPTIBLE类似的不可中断睡眠, 但不计入 load average
- 内核中空闲时会运行的 idle线程, 内核中某些无关紧要的等待
- 如果 idle线程用TASK_UNINTERRUPTIBLE而包含TASK_NOLOAD会导致load average空长, 不能反应真实物理资源负载
- TASK_NORMAL =
存储在task->exit_code的
- EXIT_ZOMBIE: 僵尸状态
- 语义: 代码已经执行完毕
- 进入的时机: 进程调用exit() -> do_exit() -> exit_notify() 中设置 tsk->exit_state = EXIT_ZOMBIE
- 退出的时机: 父进程调用
wait/waitpid()读取退出状态 ->wait_task_zombie()->exit_state中改为EXIT_DEAD->task_struct被释放
- EXIT_DEAD (0x10) : 彻底死亡
- 语义: 进程的task_struct正在被回收或者已经被回收
- 进入的时机: 父进程
wait()成功后, 状态从EXIT_ZOMBIE变成EXIT_DEAD - 退出的时机: 这是一个瞬态, 存在的时间极短, 之后
task_struct被回收
常见的进程类型
僵尸进程: 进程(task_struct)已经被释放, 但是还保留PID和exit_state为EXIT_ZOMBIE
- 为什么需要这个状态: 父进程可能需要在进程被关闭以后, 获取进程的PID并检查它的退出码来衡量进程是不是正常退出的
- 资源泄漏: 状态为EXIT_ZOMBIE的进程占用一个PID和少量的内核内存. 如果大量堆积, 会导致内核内存泄漏, 或PID耗尽
- 怎么产生的: 父进程没有调用wait()或者waitpid()
- 解决方式:
- 父进程调用 wait() / waitpid()
- 设置 signal(SIGCHLD, SIG_IGN) 让内核自动回收 (do_notigy_parent返回true的时候直接设置状态为EXIT_DEAD)
- 杀死父进程 -> 僵尸被init(PID=1)收养 -> init会自动
wait()清理
孤儿进程: 进程的父进程先于子进程被杀死了, 子进程就成为了孤儿进程
- 内核会将孤儿进程的父进程指向init, 或者当前 PID namespace 中的 subreaper 进程, init进程会定期调用
wait()来清理 - 本身是正常的, 不会造成问题
- 内核会将孤儿进程的父进程指向init, 或者当前 PID namespace 中的 subreaper 进程, init进程会定期调用
守护进程: 在后台运行的, 不再与任何终端关联的长期运行的进程. 如
sshd, nginx等- 不是一个内核概念, 而是一个用户空间的编程的概念
- 经典的创建过程: double-fork
fork()- 创建子进程- 父进程退出 - 子进程被托孤给init
- setsid - 创建新的session (和原终端解绑)
- 再次
fork()- 确保不会重新获得中断控制能力 - chdir(“/) - 避免占用可卸载的文件系统
- 关闭/重定向 stdin/stdout/stderr
进程的唯一ID
tgid和pid, 为什么要有两个ID
经典的用途是区分进程和线程, 本身是进程组概念, 父进程和他fork出来的子进程组成一个进程组, 共享一个tgid.
他们的id (pid, tgid)会是 (1, 1), (2, 1), (3, 1). 对于操作系统来说, 只有进程这个概念, 线程实际上是一个轻量级进程, 对于操作系统的调度来说, 进程和线程是同等地位的实体, 唯一通过PID来作为唯一标识, 而tgid是标识”进程”而非”线程”的唯一标识.
进程树
通过 task_struct 中的 parent/real_parent、children/sibling 字段, 所有进程构成一棵多叉双向树. Linux 使用侵入式链表(list_head)而非传统多叉树来实现, 使得增删 O(1)、零内存分配、API 全内核复用.
进程树支撑了 wait() 回收、托孤、SIGCHLD 通知等核心操作. 进程树与进程组(PGID)、会话(Session) 共同构成了完整的作业控制体系.
详细内容: 进程树
进程调度
运行队列 struct rq
内核声明了一个per-CPU变量: runqueues, 每个CPU都有自己的rq变量, 通过cpu_rq(cpu)等宏能执行拿到第N号CPU的运行队列指针等操作.
1 | DECLARE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues); |
这个数据结构嵌入了
cfs_rq,rt_rq,dl_rq三个内部运行队列(注意这里是嵌入, 这三个字段不是指针). 这意味着 rq本身就包含了所有调度类各自的运行队列数据. 每个调度类管自己的调度队列.
1 | struct rq { |
调度类 - struct sched_class
调度类本身是一个虚函数表, 里面定义了一系列的虚函数(接口), 里面的各个函数实际上是函数指针. 可以把调度类就看作是一个interface/vitrual class.
1 | struct sched_class { |
调度类的实现类有五个
- stop_sched_class
- dl_sched_class
- rt_sched_class
- fair_sched_class
- idle_sched_class
这五个类实现了上面的各个函数, 使用不同的策略和子队列.
每个task_struct里面都一个sched_class字段, 指向这个进程对应的调度类全局实例(看成是静态类即可)
在rq挑选下一个任务的时候, 会按照上面的顺序遍历所有的调度类, 尝试调用他们各自的pick_next_task.
如果调度到的不是NULL, 返回找到的任务.
调度的整体运行流程
rq是容器,sched_class是行为, 子队列 (cfs_rq, rt_rq, dl_rq) 是各调度类在容器内的私有存储队列. 三者通过task_struct.sched_class桥接
1. 进程入队: 从唤醒到进入到子队列里面
1 | try_wake_up(task_struct* p) |
- 如果这里的进程是普通的进程, 这里的p->sched_class就是fair_sched_class, p->sched_class->enqueue_task最后调用的就是enqueue_task_fair(), 这个函数会将p插入到cfs rq中的红黑树子队列中
- 如果p是RT进程, 实际调用的就是enqueue_task_rt()
2. 进程出队: 睡眠或退出
__schedule()
-> deactivate_task(rq, prev, DEQUEUE_SLEEP)
-> dequeue_task(rq, prev, flags)
-> p->sched_class->dequeue_task(rq, p, flags)
3. 选择下一个进程: pick_next_task的两条路径
这里的pick_next_task并不是调度类里面的这个函数, 而是sched.h里面的一个文件
__scheduler()
-> pick_next_task(rq, prev, &rf)
-> __pick_next_task(rq, prev, rf)
快速路径
rq->nr_running == rq->cfs.h_nr_running ? (全部进程都是normal/CFS进程)
-> pick_next_task_fair(rq, prev, rf)
-> 从rq->cfs.tasks_timeline 取出来最左边的节点
慢速路径
for_each_class(class)
class->pick_next_task(rq)
-> stop_sched_class.pick_next_task(rq) -> 检查 rq->stop
-> dl_sched_class.pick_next_task(rq) -> 检查 rq->dl 红黑树
-> rt_sched_class.pick_next_task(rq) -> 检查 rq->rt 优先级数组
-> fair_sched_class.pick_next_task(rq) -> 检查 rq->cfs 红黑树
-> idle_sched_class.pick_next_task(rq) -> 返回 rq->idle(兜底)
for_each_class是一个宏, 会遍历所有的sched_class, 尝试调用每个调度类自己实现的pick_next_task, 直到返回的不是NULL, 返回. 这样就构造出来了不同rq/调度器之间的优先级问题.
将task_struct挂载在运行队列上
这里主要聚焦的是, rq中的子队列, 比如cfs, 是把task_struct直接存在里面的吗, 是怎么挂载上去的?
1 | struct task_struct{ |
同样是侵入式数据结构设计, 通过将这几个嵌入式的字段挂载在task_struct中, 通过container_of来取出task_struct.
同时rb_node嵌入到sched_entity中, 来将这个数据结构挂在红黑树上
为什么不直接操作task_struct呢?
在cgroup分组调度下, CFS需要把“一组进程”当成一个整体来调度, sched_entity可以代表一个进程, 也可以代表一个进程组.
1 | struct sched_entity { |
sched_entity 是 CFS 调度器的操作单元,嵌入在 task_struct.se 中。它通过 run_node 挂在 cfs_rq 的红黑树上,以 vruntime 为排序键。CFS 选人就是取 vruntime 最小的 sched_entity,再通过 container_of 反推到 task_struct。分组调度下,一个 sched_entity 可以代表整个 task_group,通过 parent/cfs_rq/my_q 构成层级树。
