linux内核设计与实现 - 进程管理

第三章 进程管理

本章内容

  1. 进程、线程
  2. linux内核如何管理每个进程:列举、创建、消亡

3.1 进程

  1. 进程:处理执行期的程序以及相关的资源的总称。(可执行代码、打开的文件、挂起的信号、内核内部数据、处理器状态、内存地址空间、执行线程、数据段
  2. 线程: 进程中活动的对象,内核最小的调度对象。(独立的程序计数器、进程栈、进程寄存器
    linux线程实现非常特别:不区分线程和进程
  3. 进程的两种虚拟机制:虚拟处理器和虚拟内存。
  4. fork(): 系统调用,通过复制一个现有进程来创建一个新的进程。
  5. exec():fork()新创建的进程都是为了立即执行新的程序,可以利用exec()函数创建新的地址空间,将新的程序载入
  6. exit():系统调用退出执行。
  7. wait()/ waitpid():父进程可以通过wait4()系统调用查询子进程是否终结,这使得进程拥有等待特定进程执行完毕的能力。
    (ps: https://blog.csdn.net/oqqhutu12345678/article/details/75043726
    wait工作原理
    (1). 子进程结束时,系统向其父进程发送SIGCHILD信号;(2). 父进程调用wait函数后阻塞;(3). 父进程被SIGCHILD信号唤醒,然后去回收僵尸子进程;(4). 父子进程之间是异步的,SIGCHILD信号机制就是为了解决父子进程之间的异步通信问题;(5). 若父进程没有任何子进程则wait返回错误。
    waitpid和wait的区别:
    (1). waitpid可以回收特定PID的子进程;(2). waitpid可以以阻塞和非阻塞式两种工作模式
    )

3.2 进程描述符及任务结构

  1. 内核把进程的列表(task_struct)存放在任务队列(task list) 的双向循环链表中。
  2. 进程描述符(task_struct):结构定义<linux/sched.h>,完整地描述一个正在执行的程序(打开的文件、进程地址空间、挂起的信号、进程的状态等)
  3. 分配进程描述符:
    (1). linux通过slab分配器分配task_struct结构. 达到对象复用和缓存着色的目的(预分配和重复使用task_struct,避免动态分配和释放开销)
    (2). struct thread_info:由于slab分配器动态生成task_struct,所以只需要在栈底(对于向下增长的栈)或栈顶(对于向上增长的栈)创建一个新的结构struct thread_info,总之,在内核栈的最最最底部(看图). 在<asm/thread_info.h>中定义
```c
struct thread_info {
unsigned long		flags;		/* low level flags */
int			preempt_count;	/* 0 => preemptable, <0 => bug */
mm_segment_t		addr_limit;	/* address limit */
struct task_struct	*task;		/* main task structure */
__u32			cpu;		/* cpu */
__u32			cpu_domain;	/* cpu domain */
struct cpu_context_save	cpu_context;	/* cpu context */
__u32			syscall;	/* syscall number */
__u8			used_cp[16];	/* thread used copro */
unsigned long		tp_value[2];	/* TLS registers */
};
```
![在这里插入图片描述](https://img-blog.csdnimg.cn/20190406170413198.png?x-oss-process,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTA1MjEzNjY=,size_16,color_FFFFFF,t_70)
  1. 进程描述符的获取
    (1). 内核通过唯一的进程标识值PID来识别每个进程。最大值32769(<linux/threads.h>可修改,或通过/proc/sys/kernel/pid_max提高上限)
    (2). task_struct的获取。由于内核访问任务需要获取指向其task_struct的指针。因此,current宏查找到当前正在运行进程的task_struct的速度很重要。
    • 专门寄存器指向当前task_struct
    • x86,在内核栈尾部通过thread_info,计算偏移间接查找task_struct
	1. 计算thread_info偏移,通过current_thread_info()函数,假定栈大小8KB
	movl $-8192, %eax
	andl %esp, %eax
	2. 通过thread_info的task域提取task_struct地址
	current_thread_info()->task
  1. 进程状态
    (1). TASK_RUNNING(运行):进程可执行的;或正在执行,或在运行队列中等待执行。这是进程在用户空间中执行的唯一可能的状态。(可执行和正在执行都是这个状态)
    (2). TASK_INTERRUPTIBLE(可中断):进程正在睡眠,等待某些条件的达成,此状态进程可接收信号而被提前唤醒并准备投入运行
    (3). task_unniterruptible(不可中断的):同上,但接收到信号也不会被唤醒
    (4). __task_traced:被其他进程跟踪的进程,如ptrace对调试进程进行跟踪。
    (5). __task_stopped(停止)
    -1 unrunnable, 0 runnable, >0 stopped
  2. 设置当前进程状态(4.18内核没看到这个函数)
    set_task_state(task, state)
    这个函数在必要时,会设置内存屏障来强制其他处理器作重新排序,否则就相当于task->state = state;
  3. 进程上下文
    一般程序在用户空间执行,当程序执行系统调用触发异常,它就陷入内核空间,此时,内核"代表进程执行”并处于进程上下文。(系统调用异常处理程序是对内核明确定义的接口)
  4. 进程家族树
    (1). 所有进程都是PID为1的init进程的后代。
    (2). 进程间的关系存放在task_struct中。parent指针指向父进程,children保存子进程链表。
    获得父进程:struct task_struct *my_parent = current->parent;
    获得子进程:
```
list_for_each(list, &current->children){
		task = list_entry(list, struct task_struct, sibling);}
```
(3). 通过这种继承体系,**可以从系统中任何一个进程出发查找到任意指定的其他进程**。因为任务队列本来就是一个双向循环链表。
获取链表中下一个进程:
`list_entry(task->tasks.next, struct task_struct, tasks)`
获取链表中前一个进程:
`list_entry(task->tasks.prev, struct task_struct, tasks)`
访问整个任务队列:
`for_each_process(task)`

3.3 进程创建

unix进程创建分两步:fork()和exec().
(1). fork():拷贝当前进程创建子进程,仅PID、 PPID(父进程的进程号,子进程设置为被拷贝进程的PID)、某些资源和统计量(如挂起的信号,没有必要继承)不同。
(2). exec():负责读取可执行文件并将其载入地址空间开始执行。

  1. 写时拷贝

fork()使用写时拷贝,让父子进程共享一个拷贝。只有在需要写入的时候,数据才会被复制。如在页根本不会被写入的情况下(fork()之后立即调用exec())它们就无需复制。

fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符,一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量不需要数据。

  1. fork()

linux通过clone()系统调用实现fork(),这个调用通过一系列参数指明父子进程需要共享的资源,fork()、vfork()、__clone()库函数根据各自需要的参数标志调用clone:fork()/vfork()/__clone() ⇒ clone() ⇒ do_fork()

do_fork()完成创建中的大部分工作,定义在kernel/fork.c中,该函数调用copy_process()函数:
(1). 调用dup_task_struct():为新进程创建一个内核栈、thread_info结构和task_struct,这些值与当前进程的值相同。
(2). 检查并确保当前用户拥有的进程数没超出资源限制
(3). 子进程开始与父进程分离。task_struct中许多成员清零或初始化
(4). 子进程状态设为TASK_UNINTERRUPTIBLE,保证不会投入运行
(5). 调用copy_flags()以更新task_struct的flags成员。表明进程还未调用exec()的PF_FORKNOEXEC标志被设置。
(6). alloc_pid()分配新的PID
(7). 根据clone()参数标志,拷贝或共享打开文件、文件系统信息、信号处理函数、进程地址空间、命名空间等
(8). 扫尾工作,返回一个指向子进程的指针。
do_fork()完成copy_process()后唤醒新创建的子进程,内核有意选择子进程首先执行, 因为子进程一般都会马上调用exec()函数,如果父进程首先执行的话,有可能会开始向地址空间写入,造成写时拷贝的额外开销。

  1. vfork()

不拷贝父进程的页表,除此之外与fork()相同。子进程作为父进程的一个单独线程在它的地址空间中运行,父进程被阻塞,直到子进程退出或执行exec(), 子进程不能向地址空间写入。

3.4 线程在linux中的实现

linux把所有线程当做进程来实现(轻量级进程),线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程拥有唯一的task_struct,对linux来说,是一种进程间共享资源的手段。

  1. 创建线程

线程的创建与普通进程的创建类似,不过在调用clone()的时候需要传递一些参数。
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
表明父子共享地址空间、文件系统资源、文件描述符和信号处理程序
2. 内核线程

内核线程和普通进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为NULL),他们只在内核空间运行。内核是通过从kthreadd内核进程中衍生出所有新的内核线程,在<linux/kthread.h>中申明有接口.
(1). 内核线程的创建(kthread_create):

struct task_struct *kthread_create(int (*threadfn)(void* data), 
									void *data,
									const char namefmt[], ... )

新任务通过kthread内核进程通过clone()系统调用创建的,新的进程将运行threadfn函数,给其传递的参数为data,进程会被命名为namefmt,后面是它的可变参数列表。新进程处于不可运行状态,如果不通过调用wake_up_process()明确地唤醒,它不会主动运行
(2). 内核进程的创建并运行(kthread_run):

struct task_struct *kthread_run(int (*threadfn)(void *data),
								void *data, const char naemfmt[], ...)

它只是简单地调用了kthread_create()和wake_up_process().
(3). 内核线程的退出(kthread_stop()):
内核线程启动后会一直运行,直到调用do_exit()退出,或者内核的其他部分调用kthread_stop()退出

int kthread_stop(struct task_struct *k);

3.5 进程终结

  1. 进程自身析构
    进程终结大部分要靠do_exit()完成。
    (1). 将task_struct标志成为设置为PF_EXITING。
    (2). 。。
    (3). 调用exit_mm()函数释放进程占用的mm_struct地址空间
    (4). 递减文件描述符、fs数据引用
    (5). exit_notify()向父进程发送信号,给子进程重新找养父或init,将进程状态设置为EXIT_ZOMBIE

    进程自身析构后,与进程相关联的所有资源都被释放,进程不可运行(也没有地址空间运行),并处于EXIT_ZOMBIE退出状态,但此时 仍占有内核栈、thread_info和task_struct,此时进程存在的唯一目的就是向父进程提供信息。由父进程进行剩余内存的释放。

  2. 删除进程描述符
    释放上面剩余的内存。
    wait()一族函数通过系统调用wait4()实现,标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。

  3. 孤儿进程造成的进退维谷
    如果父进程在子进程之前退出,则需要给子进程在当前线程组或者init当做父进程。在do_exit()中会调用exit_notify(),该函数会调用forget_original_partent(),通过find_new_reaper()来寻父。
    init会例行调用wait()来检查其子进程,清楚所有与其相关的僵死进程


第四章 进程调度

多任务:非抢占式和抢占式
进程:I/O消耗型和处理器消耗型
调度策略平衡:进程响应迅速(高响应) 和 最大系统利用率(高吞吐)。
linux更倾向于优先调度I/O消耗型

  1. 进程优先级
    linux采用两种不同的优先级范围:一种是nice值,第二种是实时优先级(nice值越高优先级越低,实时优先级值越高优先级越高)
  2. 时间片
    进程在被抢占前能持续运行的时间。
    linux的CFS调度器不是直接将时间片分给进程,而是将处理器的使用比划分给进程。是否将一个进程投入运行,完全由进程优先级和是否有时间片决定。CFS调度器的抢占时机取决于新的可执行程序消耗了多少处理器使用比,如果消耗的使用比比当前进程小,则新进程立刻投入运行,抢占当前进程

4.4 linux调度算法

4.4.2 unix系统中的进程调度

unix按nice值为优先级,导致问题:

  1. nice值到时间片映射与进程运行优先级混合。当将nice单位值对应到处理器的绝对时间,但是事实上,高nice值(低优先级)的进程往往是后台进程多是计算密集型,时间片短和初衷不符。
  2. 相对nice值。如果一个nice值0和1分配的时间片100ms和95ms,但18和19的nice值映射到10ms和5ms,即使都是只差了1个nice,但后者中18获得了2倍的nice时间。
  3. 绝对时间片。如果nice值映射到时间片,则时间片必须是定时器节拍的整数倍

优化:1. nic值以几何增长(问题2);2. 新的度量机制将nice值到时间片的映射与定时器节拍分开。

问题:没有解决:分配绝对的时间片引发的固定的切换频率,给公平性造成了很大的变数。

4.4.3 完全公平调度CFS

CFS的做法是允许每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,而不再采用分配给每个进程时间片的做法,CFS在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是靠nice值计算时间片。nice值在CFS中被作为进程获得处理器运行比的权重

任何进程所获得的处理器时间是由它自己和其他所有可运行进程nice值得相对差值决定的。

4.5 linux调度的实现

CFS的实现kernels/sched_fair.c,我们关注一下四个组成:
1.时间记账;2. 进程选择; 3. 调度器入口; 4. 睡眠和唤醒

4.5.1. 时间记账

  1. 调度器实体结构
    CFS使用调度器实体结构(<linux/sched.h>中的strcut sched_entity)来追踪进程运行记账。调度器实体结构作为一个名为se的成员变量,嵌入在task_struct中。
  2. 虚拟实时
    se中vruntime变量存放进程的虚拟运行时间,该运行时间的计算是经历了所有可运行进程总数的标准化。虚拟时间以ns为单位。
    功能:CFS使用vruntime变量来记录一个程序运行了多长时间以及还应该运行多久。
    记账:在kernel/sched_fair.c中的update_curr()函数更新当前任务的运行时统计数据。updata_curr()由系统定时器周期性调用,无论进程在可运行态还是不可运行态。

4.5.2 进程选择

CFS调度算法核心:选择具有最小vruntime的任务

CFS使用红黑树组织可运行进程队列,并利用其迅速找到最小vruntime值得进程。 红黑树的键值便是可运行进程的虚拟运行时间。

  1. 挑选下一个任务
    rbtree中vruntime最小的进程为:rbtree最左边叶子节点所代表的进程。
    实现这一过程的函数是__pick_next_entity()通过直接获得cfs_rq->rb_leftmost;因为该值已经缓存在rb_leftmost字段中。
  2. 向树中加入进程
    当进程变为可运行态或者通过fork()第一次创建进程时,通过enqueue_entity()将进程加入rbtree。
    (1). 更新任务的运行时统计数据
    (2). 根据键值插入红黑树合适的地方,小于走左边,大于走右边;
    (3). 顺便判断是否为最左数据,是则缓存。(如果一直走的最左边,则是leftmost)
    (4). 插入,红黑树开始自行调整平衡。
  3. 从树中删除进程
    删除动作发生在进程阻塞(变为不可运行态)或者终止时,由dequeue_entity()完成。
    (1).更新任务运行时统计数据
    (2). 删除节点
    (3). 更新rb_leftmost缓存,如果要删的是最左节点,那么需要顺序遍历找到下一个最左节点。

4.5.3 调度器入口

进程调度的主要入口点是schedule(),是内核用户调用进程调度器的入口:选择哪个进程运行、何时投入运行,通常需要和一个具体的调度类相关联,这个调度器类要有自己的可运行队列,schedule询问谁是下一个该运行的进程。

因此schedule()函数总唯一重要的事情是调用pick_next_task(),pick_next_task()会以优先级为序,从高到低,一次检查每一个调度类,并且从最高优先级的调度类中,选择最高优先级的进程。每个调度器类实现了pick_next_task()返回下一个可运行进程的指针。

4.5.4 睡眠和唤醒

睡眠:进程把自己标记为休眠状态,从可执行红黑树中移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。
唤醒:进程被设置为可执行状态,然后再从等待队列中移到可执行红黑树中。

  1. 等待队列
    内核通过wake_queue_head_t来代表等待队列。
DEFINE_WAIT(wait);	静态创建等待队列

add_wait_queue(q,&wait);		进程加入队列
while(!condition){		'condition'是我们在等待的事件
	prepare_to_wait(&q, &wait, TASK_INTERRUPTIBLE);	
	//prepare_to_wait()方法将进程状态变为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE。
	if(signal_pending(current))	如果被其他信号唤醒
		/* 处理信号*/
	schedule();
}
finish_wait(&q, &wait);		条件满足后,进程将自己设为TASK_RUNNING并将自己移出等待队列
  1. 唤醒
    通过wake_up() 进行,会唤醒指定的等待队列上的所有进程。
    (1). 将进程状态设置为TASK_RUNNING,调用enqueue_task()将进程放入可运行红黑树
    (2). 如果进程优先级比当前运行优先级高,还要设置need_resched标志。

4.6 抢占和上下文切换

上下文切换由context_switch()函数负责处理,包括两项基本工作:
(1). 调用switch_mm(),切换虚拟内存
(2). 调用switch_to(),切换处理器状态

抢占:
内核提供一个need_resched标志来表明是否需要重新执行一次调度。
设置和检查时机:
(1). 当某个进程应该被抢占时,scheduler_tick()会设置这个标志
(2). 当一个高优先级进程进入可执行状态时,try_to_wake_up()也会设置
内核检查这个标志,调用schedule()来切换到一个新的进程。
(3). 返回用户空间以及中断返回时,也会检查
need_resched标志是每个task_struct都拥有的变量,因为访问task_struct要比访问全局变量快。(current宏速度很快,且描述符一般都在高速缓存中)

  1. 用户抢占
    发生情况:(1)从内核返回用户空间时 (2)中断处理程序返回用户空间时
    因为内核此时会检查need_resched标志,会导致schedule()被调用,此时会发生用户抢占。

  2. 内核抢占
    只要重新调度是安全的,内核就可以在任何时间抢占正在执行的任务。即,只要没有持有锁,内核就可以进行抢占。

发生情况:
(1)中断处理程序正在执行,且返回内核空间之前
(2)内核代码再次具有可抢占性时
(3)内核中的任务显式调用schedule()
(4)内核中的任务阻塞(会导致调用schedule())
每个进程的thread_info出引入了preempt_count计数器,当使用锁时+1,释放锁时-1。当数值为0时表明可抢占。

4.7 实时调度策略

linux提供两种实时调度策略:SCHED_FIFO和SCHED_RR。普通的非实时的调度室SCHED_NORMAL。

SCHED_FIFO:无时间片,仅高优先级可抢占
SCHED_RR:仅在同一优先级中带时间片,仅高优先级可抢占

都是静态优先级,不为实施进程计算动态优先级。

4.8 与调度相关的系统调用

  1. 与调度策略和优先级相关的系统调用
    sched_setscheduler()和sched_getscheduler():设置和获取进程调度策略和实时优先级。其实最重要的工作在于读取或改写进程task_struct中policy和rt_priority。
    sched_setparam()或sched_getparam():设置和获取进程实时优先级。
    sched_get_priority_max()和sched_get_priority_min():返回给定调度策略的最大和最小优先级。
    nice():设置进程的nice值,给给定进程的静态优先级增加一个值,通过设置task_struct的static_prio和prio值
  2. 与处理器绑定有关的系统调用
    强制指定这个进程必须在这些处理器上执行,是通过task_struct的cpus_allowed掩码。
    sched_setaffinity()设置位掩码
  3. 放弃处理器时间
    sched_yield()系统调用:显示将处理器时间让给他人。将进程从活动队列移动到过期队列中。
    内核直接调用yield(),先确定进程确实处理可执行态,然后调sched_yield()。
    用户空间直接调sched_yield()即可。

猜你喜欢

转载自blog.csdn.net/u010521366/article/details/89058433