- TASK_KILLABLE,可以终止的新睡眠状态
从定义可以看出,TASK_WAKEKILL 用于在接收到致命信号时唤醒进程,而 TASK_KILLABLE 相当于这两位都设置了。
#define TASK_KILLABLE (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)
**task_struct**
//是否在运行队列上
int on_rq;
//优先级int prio;int static_prio;
int normal_prio;
unsigned int rt_priority;
//调度器类
const struct sched_class *sched_class;
//调度实体
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
//调度策略
unsigned int policy;
//可以使用哪些
CPUint nr_cpus_allowed;
cpumask_t cpus_allowed;
struct sched_info sched_info;
进程管理 task_struct 的结构图
- 内核中进程, 线程统一为任务, 由 taks_struct 表示
- 通过链表串起 task_struct
- task_struct 中包含: 任务ID; 任务状态; 信号处理相关字段; 调度相关字段; 亲缘关系; 权限相关; 运行统计; 内存管理; 文件与文件系统; 内核栈;
- 任务 ID; 包含 pid, tgid 和 *group_leader
- pid(process id, 线程的id); tgid(thread group id, 所属进程[主线程]的id); group_leader 指向 tgid 的结构体
- 通过对比 pid 和 tgid 可判断是进程还是线程
- 信号处理, 包含阻塞暂不处理; 等待处理; 正在处理的信号
- 信号处理函数默认使用用户态的函数栈, 也可以开辟新的栈专门用于信号处理, 由 sas_ss_xxx 指定
- 通过 pending/shared_pending 区分进程和线程的信号
- 任务状态; 包含 state; exit_state; flags
- 准备运行状态 TASK_RUNNING
- 睡眠状态:可中断; 不可中断; 可杀
- 可中断 TASK_INTERRUPTIBLE, 收到信号要被唤醒
- 不可中断 TASK_UNINTERRUPTIBLE, 收到信号不会被唤醒, 不能被kill, 只能重启
- 可杀 TASK_KILLABLE, 可以响应致命信号, 由不可中断与 TASK_WAKEKILL 组合
- 停止状态 TASK_STOPPED, 由信号 SIGSTOP, SIGTTIN, SIGTSTP 与 SIGTTOU 触发进入
- 调试跟踪 TASK_TRACED, 被 debugger 等进程监视时进入
- 结束状态(包含 exit_state)
- EXIT_ZOMBIE, 父进程还没有 wait()
- EXIT_DEAD, 最终状态
- flags, 例如 PF_VCPU 表示运行在虚拟 CPU 上; PF_FORKNOEXEC _do_fork 函数里设置, exec 函数中清除
- 进程调度; 包含 是否在运行队列; 优先级; 调度策略; 可以使用那些 CPU 等信息.
进程亲缘关系
struct task_struct __rcu *real_parent; /* real parent process */
struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */
struct list_head children; /* list of my children */
struct list_head sibling; /* linkage in my parent's children list */
parent 指向其父进程。当它终止时,必须向它的父进程发送信号。
children 表示链表的头部。链表中的所有元素都是它的子进程。
sibling 用于把当前进程插入到兄弟链表中。
运行统计信息, 包含用户/内核态运行时间; 上/下文切换次数; 启动时间等;
- 进程亲缘关系
- 拥有同一父进程的所有进程具有兄弟关系
- 包含: 指向 parent; 指向 real_parent; 子进程双向链表头结点; 兄弟进程双向链表头结点
- parent 指向的父进程接收进程结束信号
- real_parent 和 parent 通常一样; 但在 bash 中用 GDB 调试程序时, GDB 是 real_parent, bash 是 parent
- 进程权限, 包含 real_cred 指针(谁能操作我); cred 指针(我能操作谁)
- cred 结构体中标明多组用户和用户组 id
- uid/gid(哪个用户的进程启动我)
- euid/egid(按照哪个用户审核权限, 操作消息队列, 共享内存等)
- fsuid/fsgid(文件操作时审核)
- 这三组 id 一般一样
- 通过 chmod u+s program, 给程序设置 set-user-id 标识位, 运行时程序将进程 euid/fsuid 改为程序文件所有者 id
- suid/sgid 可以用来保存 id, 进程可以通过 setuid 更改 uid
- capability 机制, 以细粒度赋予普通用户部分高权限 (capability.h 列出了权限)
- cap_permitted 表示进程的权限
- cap_effective 实际起作用的权限, cap_permitted 范围可大于 cap_effective
- cap_inheritable 若权限可被继承, 在 exec 执行时继承的权限集合, 并加入 cap_permitted 中(但非 root 用户不会保留 cap_inheritable 集合)
- cap_bset 所有进程保留的权限(限制只用一次的功能)
- cap_ambient exec 时, 并入 cap_permitted 和 cap_effective 中
- 内存管理: mm_struct
- 文件与文件系统: 打开的文件, 文件系统相关数据结构
函数调用
原本不理解为什么调用函数时先压被调用函数参数,最后压返回地址,那么被调用函数怎么越过返回地址得到所需的参数。现在结合这个图就清晰了。
EBP的存在是为了和ESP搭配,限定当前函数堆栈范围,当调用新函数的时候自然要开新的栈。怎么开新栈呢?就是让EBP等于ESP。为了保证被调用函数返回之后能回忆起来调用函数的栈桢范围,需要在设置被调用函数的EBP=ESP之前,将EBP压入栈。也就是上图中最底下的绿色块。 在堆栈中两个函数交接的结构是固定的,即图中从参数N到最底下绿色块EBP这个结构是固定的。所以如果被调用函数想要访问第一个参数的话,是通过EBP+8来访问,访问第二个参数通过EBP+12,以此类推。如果允许的话,还可以通过EBP+4来访问函数返回地址。 缓冲区溢出攻击就是通过往堆栈中写入超过为其所分配空间的元素,导致覆盖了函数返回地址,然后当函数执行完了自动将esp减到ebp位置的时候,弹出一个被修改的ebp,再弹出一个被修改的返回地址,导致执行了意料之外的程序。
内核栈是一个非常特殊的结构
在用户态,应用程序进行了至少一次函数调用。
32 位和 64 的传递参数的方式稍有不同,32 位的就是用函数栈,64 位的前 6 个参数用寄存器,其他的用函数栈。
在内核态,32 位和 64 位都使用内核栈,格式也稍有不同,主要集中在 pt_regs 结构上。
在内核态,32 位和 64 位的内核栈和 task_struct 的关联关系不同。32 位主要靠 thread_info,64 位主要靠 Per-CPU 变量。
用户态/内核态切换执行如何串起来
- 用户态函数栈; 通过 JMP + 参数 + 返回地址 调用函数
- 栈内存空间从高到低增长
- 32位栈结构: 栈帧包含 前一个帧的 EBP + 局部变量 + N个参数 + 返回地址
- ESP: 栈顶指针; EBP: 栈基址(栈帧最底部, 局部变量起始)
- 返回值保存在 EAX 中
- 64位栈结构: 结构类似
- rax 保存返回结果; rsp 栈顶指针; rbp 栈基指针
- 参数传递时, 前 6个放寄存器中(再由被调用函数 push 进自己的栈, 用以寻址), 参数超过 6个压入栈中
- 内核栈结构:
- Linux 为每个 task 分配了内核栈, 32位(8K), 64位(16K)
- 栈结构: [预留8字节 +] pt_regs + 内核栈 + 头部 thread_info
- thread_info 是 task_struct 的补充, 存储于体系结构有关的内容
- pt_regs 用以保存用户运行上下文, 通过 push 寄存器到栈中保存
- 通过 task_struct 找到内核栈
- 直接由 task_struct 内的 stack 直接得到指向 thread_info 的指针
- 通过内核栈找到 task_struct
- 32位 直接由 thread_info 中的指针得到
- 64位 每个 CPU 当前运行进程的 task_struct 的指针存放到 Per CPU 变量 current_task 中; 可调用 this_cpu_read_stable 进行读取
调度策略与调度类
-
实时进程
-
普通进程
task_struct 中,有一个成员变量,我们叫调度策略。
unsigned int policy;
有以下几个定义:
#define SCHED_NORMAL 0
#define SCHED_FIFO 1
#define SCHED_RR 2
#define SCHED_BATCH 3
#define SCHED_IDLE 5
#define SCHED_DEADLINE 6
实时进程的调度策略
SCHED_FIFO、SCHED_RR、SCHED_DEADLINE 是实时进程的调度策略。
1.SCHED_FIFO 先来先服务 高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,我们遵循先来先得。
2.SCHED_RR 轮流调度算法 采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。
3. SCHED_DEADLINE 按照任务的 deadline 进行调度的。当产生一个调度点的时候,DL 调度器总是选择其 deadline 距离当前时间点最近的那个任务,并调度它执行。
普通调度策略
- SCHED_NORMAL 是普通的进程
- SCHED_BATCH 是后台进程,几乎不需要和前端进行交互,不要影响需要交互的进程,可以降低它的优先级。
- SCHED_IDLE 是特别空闲的时候才跑的进程,
配合调度策略的,还有优先级,也在 task_struct 中
int prio, static_prio, normal_prio;
unsigned int rt_priority;
实时进程,优先级的范围是 0~99;
对于普通进程,优先级的范围是 100~139。
数值越小,优先级越高。
从这里可以看出,所有的实时进程都比普通进程优先级要高
调度策略的执行逻
const struct sched_class *sched_class; // task_struct 里面,还有这样的成员变量:
sched_class 有几种实现:
- stop_sched_class 优先级最高的任务会使用这种策略,会中断所有其他线程,且不会被其他任务打断;
- dl_sched_class 就对应上面的 deadline 调度策略;
- rt_sched_class 就对应 RR 算法或者 FIFO 算法的调度策略,具体调度策略由进程的 task_struct->policy 指定;
- fair_sched_class 就是普通进程的调度策略;
- idle_sched_class 就是空闲进程的调度策略。
完全公平调度算法CFS
首先,需要记录下进程的运行时间。CPU 会提供一个时钟,过一段时间就触发一个时钟中断,我们叫 Tick。CFS 会为每一个进程安排一个虚拟运行时间 vruntime。如果一个进程在运行,随着时间的增长,也就是一个个 tick 的到来,进程的 vruntime 将不断增大。没有得到执行的进程 vruntime 不变。
虚拟运行时间 vruntime += 实际运行时间 delta_exec * NICE_0_LOAD/ 权重
当选取下一个运行进程的时候,按照最小的 vruntime 来。
解释:同样的实际运行时间,给高权重的算少了,低权重的算多了,但是当选取下一个运行进程的时候,还是按照最小的 vruntime 来的,这样高权重的获得的实际运行时间自然就多了。这就相当于给一个体重 (权重)200 斤的胖子吃两个馒头,和给一个体重 100 斤的瘦子吃一个馒头,然后说,你们两个吃的是一样多。这样虽然总体胖子比瘦子多吃了一倍,但是还是公平的。
调度队列与调度实体
- CFS 需要一个数据结构来对 vruntime 进行排序,找出最小的那个。----红黑树
- 红黑树的的节点是包括 vruntime 的--------称为调度实体
- 普通进程的调度实体定义如下,这里面包含了 vruntime 和权重 load_weight,以及对于运行时间的统计。
struct sched_entity {
struct load_weight load;
struct rb_node run_node;
struct list_head group_node;
unsigned int on_rq;
u64 exec_start;
u64 sum_exec_runtime;
u64 vruntime;
u64 prev_sum_exec_runtime;
u64 nr_migrations;
struct sched_statistics statistics;
......
};
vruntime 最小的在树的左侧,vruntime 最多的在树的右侧。 CFS 调度策略会选择红黑树最左边的叶子节点作为下一个将获得 CPU 的任务。
- 例子:这有点像让你把一筐球平均分到 N 个口袋里面,你看着哪个少,就多放一些;哪个多了,就先不放。这样经过多轮,虽然不能保证球完全一样多,但是也差不多公平。你可能会说,不还有优先级呢?如何给优先级高的进程多分时间呢?这个简单,就相当于 N 个口袋,优先级高的袋子大,优先级低的袋子小。这样球就不能按照个数分配了,要按照比例来,大口袋的放了一半和小口袋放了一半,里面的球数目虽然差很多,也认为是公平的。
CPU 任务队列
每个 CPU 都有自己的 struct rq 结构,其用于描述在此 CPU 上所运行的所有进程
包括:实时进程队列 rt_rq 和一个 CFS 运行队列 cfs_rq
- CPU struct rq 结构
//CPU struct rq 结构
struct rq {
/* runqueue lock: */
raw_spinlock_t lock;
unsigned int nr_running;
unsigned long cpu_load[CPU_LOAD_IDX_MAX];
......
struct load_weight load;
unsigned long nr_load_updates;
u64 nr_switches;
struct cfs_rq cfs;
struct rt_rq rt;
struct dl_rq dl;
......
struct task_struct *curr, *idle, *stop;
......
};
- 普通进程公平队列 cfs_rq
/* CFS-related fields in a runqueue */
struct cfs_rq {
struct load_weight load;
unsigned int nr_running, h_nr_running;
u64 exec_clock;
u64 min_vruntime;
#ifndef CONFIG_64BIT
u64 min_vruntime_copy;
#endif
struct rb_root tasks_timeline; //指向的就是红黑树的根节点
struct rb_node *rb_leftmost; //rb_leftmost 指向的是最左面的节点
struct sched_entity *curr, *next, *last, *skip;
......
};
- 在每个 CPU 上都有一个队列 rq,这个队列里面包含多个子队列,例如 rt_rq 和 cfs_rq,不同的队列有不同的实现方式,cfs_rq 就是用红黑树实现的。
- 当有一天,某个 CPU 需要找下一个任务执行的时候,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然 rt_sched_class 先被调用,它会在 rt_rq 上找下一个任务,只有找不到的时候,才轮到 fair_sched_class 被调用,它会在 cfs_rq 上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
sched_class 定义的与调度有关的函数
enqueue_task 向就绪队列中添加一个进程,当某个进程进入可运行状态时,调用这个函数;
dequeue_task 将一个进程从就绪队列中删除;
pick_next_task 选择接下来要运行的进程;
put_prev_task 用另一个进程代替当前运行的进程;
set_curr_task 用于修改调度策略;
task_tick 每次周期性时钟到的时候,这个函数被调用,可能触发调度
如果优先队列一直有任务,普通队列的task一直得不到处理,操作系统会怎么做呢?
为了防止这种现象的发生,操作系统在一定的时间周期会重置所有task的优先级,这样就保证了低优先级的task得以执行,而不被饿死。但是这个时间设置为多少合适?设置的短了会导致系统的频繁重置。设置的长了,又会使普通优先级的task切换太慢。这个时间一般是系统研究人员研究得到的,我觉得可能可以通过一些统计学上的方式来做。
为了解决task响应时间和完成时间的平衡,现代操作系统如Windows和Linux都依赖于Multi-Level Feedback Queue, 和文章讲的正好对应起来了。首先面对的情况是:
- 操作系统无法知道每个task何时到来 ?
- 操作系统无法知道每个task运行完成实际需要多少时间 ?
那么FIFO ShortJobFirst或者Short Time Completed First 算法,面对这两种场景将无从下手。
面对这样的问题,为了使交互性的TASK能够得到快速的响应,提升用户的的体验,同时缩短task 的完成时间。计算机科学家提出了Multi-Level Feedback Queue的解决方案。基本思想是通过优先级保证交互性的task,能够快速响应,同时通过统计task 对CPU的使用时间以期对TASK判断,有点类似于机器学习。
如果某个task 在其时间片里用完前释放CPU, 可以认为这是种交互式的task, 优先级保留。反之认为某个task是需要运行时间长的。同时基于对task 对cpu 时间使用的统计作为判断依据。这样经过一段时间运行后,长时间运行的队列会被逐渐降低优先级。
而快速响应的task 能够优先使用CPU。但是这里面还有两个问题: 首先,如果优先级低的一直得不到cpu, 可能会出现饿死。其次,有人可能会利用这个漏洞编程的方式在使用完CPU时间片后释放CPU,从而控制CPU。 基于此,Multi-feedback-queue有以下5条规则:
4. 如果A的优先级大于B, 则A先运行。
5. 如果A的优先级等于B, 则以RR算法交互运行。
6. 新来的 Task 会被置于最高的优先级。
7. 如果一个task 在其当前优先级运行完被分配的时间片后,会降低其优先级,重置其放弃使用CPU的次数。(这条规则修改过,是为了防止有人利于原有规则的漏洞控制CPU, 原来的规则是如果一个task 在其时间片用完前释放cpu, 则其优先级保持不变, 这个修正增加了对task 实际使用cpu 时间统计作为判断依据)。
8. 系统每过时钟周期的倍数,会重置所有task 的优先级。(这条规则是为了防止task被饿死的,也是我之前所疑惑的)。
进程主动调度
运行中的进程主动调用 __schedule 让出 CPU。在 __schedule 里面会做两件事情,第一是选取下一个进程,第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。
调度, 切换运行进程, 有两种方式
- 进程调用 sleep 或等待 I/O, 主动让出 CPU
- 进程运行一段时间, 被动让出 CPU
- 主动让出 CPU 的方式, 调用 schedule(), schedule() 调用 __schedule()
- __schedule() 取出 rq; 取出当前运行进程的 task_struct
- 调用 pick_next_task 取下一个进程
- 依次调用调度类(优化: 大部分都是普通进程), 因此大多数情况调用 fair_sched_class.pick_next_task[_fair]
- pick_next_task_fair 先取出 cfs_rq 队列, 取出当前运行进程调度实体, 更新 vruntime
- pick_next_entity 取最左节点, 并得到 task_struct, 若与当前进程不一样, 则更新红黑树 cfs_rq
- 进程上下文切换: 切换进程内存空间, 切换寄存器和 CPU 上下文(运行 context_switch)
- context_switch() -> switch_to() -> __switch_to_asm(切换[内核]栈顶指针) -> __switch_to()
- __switch_to() 取出 Per CPU 的 tss(任务状态段) 结构体
-
x86 提供以硬件方式切换进程的模式, 为每个进程在内存中维护一个 tss, tss 有所有寄存器, 同时 TR(任务寄存器)指向某个 tss, 更改 TR 会触发换出 tss(旧进程)和换入 tss(新进程), 但切换进程没必要换所有寄存器
- 因此 Linux 中每个 CPU 关联一个 tss, 同时 TR 不变, Linux 中参与进程切换主要是栈顶寄存器
- task_struct 的 thread 结构体保留切换时需要修改的寄存器, 切换时将新进程 thread 写入 CPU tss 中
- 具体各类指针保存位置和时刻
- 用户栈, 切换进程内存空间时切换
- 用户栈顶指针, 内核栈 pt_regs 中弹出
- 用户指令指针, 从内核栈 pt_regs 中弹出
- 内核栈, 由切换的 task_struct 中的 stack 指针指向
- 内核栈顶指针, __switch_to_asm 函数切换(保存在 thread 中)
- 内核指令指针寄存器: 进程调度最终都会调用 __schedule, 因此在让出(从当前进程)和取得(从其他进程) CPU 时, 该指针都指向同一个代码位置.
抢占式调度
抢占式调度
- 两种情况: 执行太久, 需切换到另一进程; 另一个高优先级进程被唤醒
-
执行太久: 由时钟中断触发检测, 中断处理调用 scheduler_tick
- 取当前进程 task_struct->task_tick_fair()->取 sched_entity cfs_rq 调用 entity_tick()
- entity_tick() 调用 update_curr 更新当前进程 vruntime, 调用 check_preempt_tick 检测是否需要被抢占
- check_preempt_tick 中计算 ideal_runtime(一个调度周期中应该运行的实际时间), 若进程本次调度运行时间 > ideal_runtime, 则应该被抢占
- 要被抢占, 则调用 resched_curr, 设置 TIF_NEED_RESCHED, 将其标记为应被抢占进程(因为要等待当前进程运行
__schedule
)
-
另一个高优先级进程被唤醒: 当 I/O 完成, 进程被唤醒, 若优先级高于当前进程则触发抢占
- try_to_wake_up()->ttwu_queue() 将唤醒任务加入队列 调用 ttwu_do_activate 激活任务
- 调用 tt_do_wakeup()->check_preempt_curr() 检查是否应该抢占, 若需抢占则标记
-
执行太久: 由时钟中断触发检测, 中断处理调用 scheduler_tick
- 抢占时机: 让进程调用
__schedule
, 分为用户态和内核态-
用户态进程
- 时机-1: 从系统调用中返回, 返回过程中会调用 exit_to_usermode_loop, 检查
_TIF_NEED_RESCHED
, 若打了标记, 则调用 schedule() - 时机-2: 从中断中返回, 中断返回分为返回用户态和内核态(汇编代码: arch/x86/entry/entry_64.S), 返回用户态过程中会调用 exit_to_usermode_loop()->shcedule()
- 时机-1: 从系统调用中返回, 返回过程中会调用 exit_to_usermode_loop, 检查
-
内核态进程
- 时机-1: 发生在 preempt_enable() 中, 内核态进程有的操作不能被中断, 会调用 preempt_disable(), 在开启时(调用 preempt_enable) 时是一个抢占时机, 会调用 preempt_count_dec_and_test(), 检测 preempt_count 和标记, 若可抢占则最终调用
__schedule
- 时机-2: 发生在中断返回, 也会调用
__schedule
- 时机-1: 发生在 preempt_enable() 中, 内核态进程有的操作不能被中断, 会调用 preempt_disable(), 在开启时(调用 preempt_enable) 时是一个抢占时机, 会调用 preempt_count_dec_and_test(), 检测 preempt_count 和标记, 若可抢占则最终调用
-
用户态进程
假如没有系统调用等,那岂不是会死循环?
简单来说就是如果发生了中断,那么当前进程肯定会陷入内核态。所以可能会有标记步骤和真正的抢占步骤。详细点来说,当一个进程正在 CPU 上运行,如果发生时钟中断,那么需要去处理这个时钟中断,也就是会调用相应的中断处理函数,而相应的中断处理函数需要在内核态下执行,所以当前进程会陷入内核态,然后保存用户态的情况,然后判断是否需要进行标记。然后中断函数处理完之后,会返回用户态,这个时候又会发生抢占。
进程的创建
- dup_task_struct 主要做了下面几件事情:
调用 alloc_task_struct_node 分配一个 task_struct 结构;
调用 alloc_thread_stack_node 来创建内核栈,这里面调用 __vmalloc_node_range
分配一个连续的 THREAD_SIZE 的内存空间,赋值给 task_struct 的 void *stack 成员变量;
调用 arch_dup_task_struct(struct task_struct *dst, struct task_struct *src),将
task_struct 进行复制,其实就是调用 memcpy;
调用 setup_thread_stack 设置 thread_info。
整个 task_struct 复制了一份,而且内核栈也创建好了
- copy_process 权限相关
调用 prepare_creds,准备一个新的 struct cred *new。如何准备呢?
其实还是从内存中分配一个新的 struct cred 结构,然后调用 memcpy 复制一份父进程的 cred;
接着 p->cred = p->real_cred = get_cred(new),将新进程的“我能操作谁”和“谁能操作我”两个权限都指向新的 cred。
sched_fork 主要做了下面几件事情:
- 调用 __sched_fork,在这里面将 on_rq 设为 0,初始化 sched_entity,将里面的 exec_start、sum_exec_runtime、prev_sum_exec_runtime、vruntime 都设为 0。你还记得吗,这几个变量涉及进程的实际运行时间和虚拟运行时间。是否到时间应该被调度了,就靠它们几个;
- 设置进程的状态 p->state = TASK_NEW;初始化优先级 prio、normal_prio、static_prio;
- 设置调度类,如果是普通进程,就设置为 p->sched_class = &fair_sched_class;
- 调用调度类的 task_fork 函数,对于 CFS 来讲,就是调用 task_fork_fair。在这个函数里,先调用 update_curr,对于当前的进程进行统计量更新,然后把子进程和父进程的 vruntime 设成一样,最后调用 place_entity,初始化 sched_entity。这里有一个变量 sysctl_sched_child_runs_first,可以设置父进程和子进程谁先运行。如果设置了子进程先运行,即便两个进程的 vruntime 一样,也要把子进程的 sched_entity 放在前面,然后调用 resched_curr,标记当前运行的进程 TIF_NEED_RESCHED,也就是说,把父进程设置为应该被调度,这样下次调度的时候,父进程会被子进程抢占。
fork 的第二件大事:唤醒新进程
void wake_up_new_task(struct task_struct *p)
{
struct rq_flags rf;
struct rq *rq;
......
p->state = TASK_RUNNING;
......
activate_task(rq, p, ENQUEUE_NOCLOCK);
p->on_rq = TASK_ON_RQ_QUEUED;
trace_sched_wakeup_new(p);
check_preempt_curr(rq, p, WF_FORK);
......
}
将进程的状态设置为 TASK_RUNNING。activate_task 函数中会调用 enqueue_task。
static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
{
.....
p->sched_class->enqueue_task(rq, p, flags);
}
如果是 CFS 的调度类,则执行相应的 enqueue_task_fair。
static void
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &p->se;
......
cfs_rq = cfs_rq_of(se);
enqueue_entity(cfs_rq, se, flags);
......
cfs_rq->h_nr_running++;
......
}
- fork -> sys_call_table 转换为 sys_fork()->_do_fork
- 创建进程做两件事: 复制初始化 task_struct; 唤醒新进程
- 复制并初始化 task_struct, copy_process()
- dup_task_struct: 分配 task_struct 结构体; 创建内核栈, 赋给
* stack
; 复制 task_struct, 设置 thread_info; - copy_creds: 分配 cred 结构体并复制, p->cred = p->real_cred = get_cred(new)
- 初始化运行时统计量
- sched_fork 调度相关结构体: 分配并初始化 sched_entity; state = TASK_NEW; 设置优先级和调度类; task_fork_fair()->update_curr 更新当前进程运行统计量, 将当前进程 vruntime 赋给子进程, 通过 sysctl_sched_child_runs_first 设置是否让子进程抢占, 若是则将其 sched_entity 放前头, 并调用 resched_curr 做被抢占标记.
- 初始化文件和文件系统变量
- copy_files: 复制进程打开的文件信息, 用 files_struct 维护;
- copy_fs: 复制进程目录信息, 包括根目录/根文件系统; pwd 等, 用 fs_struct 维护
- 初始化信号相关内容: 复制信号和处理函数
- 复制内存空间: 分配并复制 mm_struct; 复制内存映射信息
- 分配 pid
- dup_task_struct: 分配 task_struct 结构体; 创建内核栈, 赋给
- 唤醒新进程 wake_up_new_task()
- state = TASK_RUNNING; activate 用调度类将当前子进程入队列
- 其中 enqueue_entiry 中会调用 update_curr 更新运行统计量, 再加入队列
- 调用 check_preempt_curr 看是否能抢占, 若 task_fork_fair 中已设置 sysctl_sched_child_runs_first, 直接返回, 否则进一步比较并调用 resched_curr 做抢占标记
- 若父进程被标记会被抢占, 则系统调用 fork 返回过程会调度子进程
创建线程
创建进程的话,调用的系统调用是 fork,在 copy_process 函数里面,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用 clone,在 copy_process 函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
- 线程的创建
- 线程是由内核态和用户态合作完成的, pthread_create 是 Glibc 库的一个函数
- pthread_create 中
- 设置线程属性参数, 如线程栈大小
- 创建用户态维护线程的结构, pthread
- 创建线程栈 allocate_stack
- 取栈的大小, 在栈末尾加 guardsize
- 在进程堆中创建线程栈(先尝试调用 get_cached_stack 从缓存回收的线程栈中取用)
- 若无缓存线程栈, 调用
__mmap
创建 - 将 pthread 指向栈空间中
- 计算 guard 内存位置, 并设置保护
- 填充 pthread 内容, 其中 specific 存放属于线程的全局变量
- 线程栈放入 stack_used 链表中(另外 stack_cache 链表记录回收缓存的线程栈)
- 设置运行函数, 参数到 pthread 中
- 调用 create_thread 创建线程
- 设置 clone_flags 标志位, 调用
__clone
- clone 系统调用返回时, 应该要返回到新线程上下文中, 因此
__clone
将参数和指令位置压入栈中, 返回时从该函数开始执行
- 设置 clone_flags 标志位, 调用
- 内核调用
__do_fork
- 在 copy_process 复制 task_struct 过程中, 五大数据结构不复制, 直接引用进程的
- 亲缘关系设置: group_leader 和 tgid 是当前进程; real_parent 与当前进程一样
- 信号处理: 数据结构共享, 处理一样
- 返回用户态, 先运行 start_thread 同样函数
- 在 start_thread 中调用用户的函数, 运行完释放相关数据
- 如果是最后一个线程直接退出
- 或调用
__free_tcb
释放 pthread 以及线程栈, 从 stack_used 移到 stack_cache 中
进程和线程的异同点:
- 进程有独立的内存空间,比如代码段,数据段。线程则是共享进程的内存空间。
- 在创建新进程的时候,会将父进程的所有五大数据结构复制新的,形成自己新的内存空间数据,而在创建新线程的时候,则是引用进程的五大数据结构数据,但是线程会有自己的私有(局部)数据,执行栈空间。
- 进程和线程其实在cpu看来都是task_struct结构的一个封装,执行不同task即可,而且在cpu看来就是在执行这些task时候遵循对应的调度策略以及上下文资源切换定义,包括寄存器地址切换,内核栈切换,指令指针寄存器的地址切换。所以对于cpu而言,进程和线程是没有区别的。
- 进程创建的时候直接使用系统调用fork,进行系统调用的链路走,从而进入到_do_fork去创建task,而线程创建在调用_do_fork之前,还需要维护pthread这个数据结构的信息,初始化用户态栈信息。
linux 线程有自己独立的内核栈(内核栈中存储着pcb)吗?
疑问:首先,我们知道所有线程共享主线程的虚拟地址空间(current->mm指向同一个地址),且都有自己的用户态堆栈(共享父进程的地址空间,再在里面分配自己的独立栈,默认2M)。这是毫无疑问的,但还有一点我没搞明白,内核栈是共享还是独立的?
回答:独立的。理由:要不然内核栈对应的thread_info中的tast_struct(pcb进程控制块)没有办法与每个线程对应起来,因为现在已经有多个task_struct了,但保存内核栈的thread_info(其实是thread_union联合体)中只能保存一个task_struct。所以理论上分析,虽然可以共享地址空间,但每个线程还是需要一个单独的内核栈的。
- 读Linux内核以及相关的资料的时候,时刻要清醒地认识到它说的是内核态还是用户态的东西。
- 一个用户态进程/线程在内核中都是用一个task_struct的实例描述的,这个有点类似设计模式里面的桥接模式(handle-body), 用户态看到的进程PID,线程TID都是handle, task_struct是body。
- C语言书里面讲的堆、栈大部分都是用户态的概念,用户态的堆、栈对应用户进程虚拟地址空间里的一个区域,栈向下增长,堆用malloc分配,向上增长。
- 用户空间的堆栈,在task_struct->mm->vm_area里面描述,都是属于进程虚拟地址空间的一个区域。
5.而内核态的栈在tsak_struct->stack里面描述,其底部是thread_info对象,thread_info可以用来快速获取task_struct对象。整个stack区域一般只有一个内存页(可配置),32位机器也就是4KB。 - 所以说,一个进程的内核栈,也是进程私有的,只是在task_struct->stack里面获取。7. 内核态没有进程堆的概念,用kmalloc()分配内存,实际上是Linux内核统一管理的,一般用slab分配器,也就是一个内存缓存池,管理所有可以kmalloc()分配的内存。所以从原理上看,在Linux内核态,kmalloc分配的所有的内存,都是可以被所有运行在Linux内核态的task访问到的。