读书笔记《Linux内核设计与实现》进程管理与调度
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了读书笔记《Linux内核设计与实现》进程管理与调度相关的知识,希望对你有一定的参考价值。
大学跟老师做嵌入式项目,写过I2C的设备驱动,但对Linux内核的了解也仅限于此。android系统许多导致root的漏洞都是内核中的,研究起来很有趣,但看相关的分析文章总感觉隔着一层窗户纸,不能完全理会。所以打算系统的学习一下Linux内核。买了两本书《Linux内核设计与实现(第3版)》和《深入理解Linux内核(第3版)》
0x00 一些废话
-
面向对象思想。
Linux内核虽然是C和汇编语言写的,没有使用面向对象的语言,但里面却包含了大量面向对象的设计。比如可以把内核中的进程看作一个对象,里面即有用于表示进程状态的各种变量,又有用于表示进程所有可执行操作的ops函数指针结构体。
-
学习原理、思想和框架。
Linux内核代码相当庞大,一个表示进程的tast_struct结构都有1.7K大小(内核2.6-32位),如果我们关注的是其中某一个成员变量,很容易迷失在细节当中,所以学习原理和思想更重要,举一反三才能剖析起内核源码来游刃有余。
0x01 Linux内核中,进程长啥样
内核把进程存放在叫做任务队列(task list)的双向循环链表中。链表中每一项都是类型为task_struct,称为进程描述符的结构。
内核中的进程也称任务。
注意到进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态,还有其他更多信息。
2.6以前的内核中,task_struct是放在进程内核栈的尾部,这样像x86这种寄存器较少的平台中,可以通过栈指针快速计算出其位置。但2.6内核中,task_struct的空间是通过slab分配器进行动态分配的,因此现在进程内核栈的尾部只存了一个叫thread_info的结构体,其成员*task指针指向task_struct结构。
在(f:\linux-2.6.32.67\arch\x86\include\asm\thread_info.h)文件可以查看x86架构下thread_info结构定义:
struct thread_info { struct task_struct *task; /* main task structure */ struct exec_domain *exec_domain; /* execution domain */ __u32 flags; /* low level flags */ __u32 status; /* thread synchronous flags */ __u32 cpu; /* current CPU */ int preempt_count; /* 0 => preemptable, <0 => BUG */ mm_segment_t addr_limit; struct restart_block restart_block; void __user *sysenter_return; #ifdef CONFIG_X86_32 unsigned long previous_esp; /* ESP of the previous stack in case of nested (IRQ) stacks */ __u8 supervisor_stack[0]; #endif int uaccess_err; };
内核中,大部分处理进程的代码都是通过直接操作的task_struct结构进行的,因此快速定位task_struct位置很重要,这直接影响操作系统运行速度。像PowerPC这类处理器(RISC)寄存器很多,task_struct的地址直接保存在寄存器中即可。而在x86当中,只能在内核栈的尾端创建thread_info结构,通过计算偏移间接查找task_struct结构,在x86系统上,是把栈指针的后13个有效位屏蔽掉,用来计算thread_info的偏移。该操作通过current_thread_info()函数完成,其汇编如下:
movl $-8192, %eax andl %esp, %eax
这里假定栈大小为8K。最后通过取task域获得task_struct地址:
current_thread_info()->task;
进程描述符中的state域描述了进程的当前状态。系统中每个进程必然处于下面5种进程状态中的一种:
-
TASK_RUNNING(运行)——进程是可执行的,它或者正在执行,或者在运行队列中等待执行。
-
TASK_INTERRUPTIBLE(可中断)——进程正在睡眠(也就是说它被阻塞),待某些条件的达成。
-
TASK_UNINTERRUPTIBLE(不可中断)——接收信号也不会被唤醒。其它跟TASK_INTERRUPTIBLE相同
-
__TASK_TRACED——被其它进程跟踪的进程,例如通过ptrace()对调试程序进行跟踪。
-
__TASK_STOPPED(停止)——进程停止执行。通过这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。
内核可以使用set_task_state(task, state)函数来设置进程状态:
set_task_state(task, state); /* 将任务task的状态设置为state */
关于进程上下文
可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当一个程序执行了系统调用或者触发了某个异常,它就陷入内核空间。此时,我们称内核“代表进程执行”并处于进程上下文。除非在此期间有更高优先级的进程需要执行并由调度器做出相应调整,否则内核退出时,程序恢复在用户空间继续执行。
进程描述符中,还存放了进程间的关系。每个task_struct都包含一个指向其父进程task_struct的parent指针,还包含一个称为children的子进程链表。所以对当前进程,可以通过下面代码获取其父进程的进程描述符:
struct task_struct *my_parent = current->parent;
同样可以用下面方式访问子进程:
struct task_struct *task; struct list_head *list; list_for_each(list, &current->children) { task = list_entry(list, struct task_struct, sibling); /* task现在指向当前进程的某个子进程 */ }
init进程的进程描述符是作为init_task静态分配的。下面代码很好的展示了所有进程间的关系:
struct task_strcut *task; for (task = current; task != &init_task; task = task->parent) ; /* task现在指向init */
这样我们就很空间从系统中任意一个进程出发,遍历所有进程。因为队列本身就是一个双向链表,所以还可以这样做。
获取链表的下一个进程
list_entry(task->tasks.next, struct task_struct, tasks);
获取前一个进程
list_entry(task->tasks.prev, struct task_struct, tasks);
这两段代码分别通过next_task(task)宏和prev_task(task)宏实现。而实际上for_each_process(task)宏提供了访问整个队列的能力。每次访问,任务指针都指向链表中的下一个元素:
struct task_struct *task; for_each_process(task) { /* 打印每个任务名称与PID */ printk("%s[%d]\n", task->comm, task->pid); }
0x02 fork()干了什么
接下来看一下,内核中进程创建和终止的过程。
进程的产生过程是,在新的地址空间创建进程,读入可执行文件,开始执行。Linux把这个过程放到了两个函数中实现:fork()和exec()。首先fork通过拷贝当前进程创建一个子进程,子进程只在PID,PPID和某些资源和统计量(例如:挂起的信号,它没有必要被继承)上与父进程有区别。exec负责读取可执行文件并载入地址空间执行。
Linux的fork()有一个特点,就是写时拷贝(copy-on-write)。也就是说通过fork创建的新进程,并不马上拷贝父进程的地址空间,只在需要写入的时候,数据才会被复制。而其它一些情况,比如fork完以后接着调用exec函数,并不需要拷贝父进程资源,这大大提高了fork的效率。Linux强调进程的快速执行能力,这个特性很重要。
fork()根据自己的参数标志调用clone(),然后由clone()调用do_fork()。
do_fork()完成进程创建的大部分工作,它定义在kernel/fork.c文件中。该函数会调用copy_process()函数,然后让进程开始执行。copy_process()函数主要实现下面功能:
-
调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct结构。此时子进程和父进程的描述符是完全相同的。
-
检查确认进程数未超过系统限制。
-
子进程着手与父进程区别开来。进程描述符中许多参数被清0或设为初始化值,这些不是继承而来的描述符成员,主要是统计信息。task_struct中的大多数数据都依然未被改变。
-
子进程状态被设置为TASK_UNINTERRUPTIBLE以保证它不会投入运行。
-
copy_process()调用copy_flags()来更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0。表明进程还没有调用exec()的标志PF_FORKNOEXEC标志被设置。
-
调用alloc_pid()为新进程分配一个有效PID
-
根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信息处理函数、进程地址空间和命名空间等。
-
最后,copy_process()做扫尾工作并返回一个指向子进程的指针。
再回到do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。内核会优先选择执行新进程,因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销。
在进程终止时,内核必须释放它所占有的资源并通知其父进程,这主要为了父进程能收集子进程退出的信息。一般情况进程的退出,都是显式或隐式的调用了exit()引起的。该功能由do_exit()实现,它定义在kernel/exit.c函数中,主要做下面的工作:
-
将task_struct中的标志成员设置为PF_EXITING
-
调用del_timer_sync()删除任一内核定时器。根据返回结果,它确保没有定时器在排队,也没有定时器处理程序在运行。
-
如果BSD的进程记帐功能是开启的,do_exit()调用acct_update_integrals()来输出记帐信息。
-
然后调用exit_mm()函数释放进程占用的mm_struct,如果没有进程使用它们(也就是说这个地址空间没有被共享),就彻底释放它们。
-
接下来调用sem_exit()函数。如果进程排队等候IPC信号,则离开队列。
-
调用exit_files()和exit_fs(),分别递减文件描述符和文件系统数据的引用计数。如果其中某个引用计数为0,则释放之。
-
使用exit()的退出码设置task_struct结构的exit_code成员。以供父进程检索,查询子进程退出状态。
-
调用exit_notify()向父进程发送信,给子进程重新找养父,养父为线程组中的其它成员或init进程,并把进程状态(task_struct的exit_state成员)设成EXIT_ZOMBIE
-
do_exit()调用schedule()切换到新的进程。因为处于EXIT_ZOMBIE状态的进程不会再被调度,所以这是进程执行的最后一段代码。do_exit()永不返回。
至此,与进程相关的所有资源都被释放掉,它仅占用的只有内核栈、thread_info结构和task_struct结构,此时它存在的唯一目的是向父进程提供信息。父进程检索到信息或通知内核那是无关信息后,由进程持有的剩余内存全部返还给系统。
父进程通过wait这一族函数来接收子进程的退出通知,它们都最终调用wait4()这一系统调用来实现。删除进程描述符通过release_task()由父进程实现,它负责删除进程描述符并给孤儿进程(退出进程的子进程)找养父。
0x03 从Linux内核看线程与进程
包括《UNIX编程艺术》和一些前辈都说,Linux下不要使用线程。因为Linux下的进程与Windows等系统的不同,它相当轻量,并且多进程系统在某个进程挂掉时并不影响整个系统,Master进程可以重启Worker进程。而多线程系统中,一个线程挂掉整个系统会崩溃。现在有机会从内核角度看一下Linux线程的实现。结果很意外,Linux下进程与线程并无不同,包括结构都是task_struct来描述。线程只被看作一个与其他进程共享某些资源的进程,在内核看来,并没有线程这一概念。;(
0x04 Linux内核中进程调度的演化史
一个处理器,同一时刻只能被一个进程占用,需要进程调度的原因很简单。如果我们来设计进程调度算法,最容易想到和实现的就是,遍历整个进程列表,每个活动进程给固定时间的执行周期比如10ms。周而复始。这是最简陋的时间片轮转的进程调度算法。Linux 2.5版本内核之前的进程调度算法都是如此简陋,它难以胜任大量进程存在的情况和多处理器情况。
Linux 2.5的内核对进程调度做了大手术,引入了O(1)调度程序,它通过静态时间片算法和针对每一个处理器的运行队列,帮助摆脱子先前调度程序的限制。但它对时间敏感的进程有先天不足,所谓时间敏感进程,是指存在大量用户交互的进程,比如桌面程序,它需要快速的响应客户操作。
在Linux 2.6内核中,引入了“完全公平调度算法”,简称CFS。它吸收了队列理论,并将公平调度的概念引入到Linux调度程序。
0x05 当前调度算法为了解决什么问题
CFS算法的引入是为了解决O(1)调度算法中,对时间敏感进程响应的先天不足。
0x06 CFS算法原理分析
分析CFS算法调度原理之前,首先要思考的是我们要遵循什么样的调度策略。
-
I/O密集型和计算密集型进程
一个文本编辑器属于I/O密集型进程,因为它时刻在等待并处理用户输入。而一个视频解码器则属于计算密集型乾,它的工作主要是大量的运算。调度策略通常要在两个矛盾的目标中间寻找平衡:进程响应速度(响应时间短)和最大系统利用率(高吞吐量)。为了满足这样的要求,调度程序通常采用一套非常复杂的算法来决定最值得运行的进程投入运行,但是它往往不能保证低优先级的进程被公平对待。Linux系统为了保证交互式应用和桌面系统的响应性能,对进程的响应做了优化,更倾向于优先调度I/O密集型进程。
-
进程优先级
调度算法中最基本的一类就是基于优先级的调度算法。通常做法是优先级高的进程先运行,低的后运行,相同优先级的轮转运行。在有的系统上,高优先级的进程还可能拥有更多时间片。
Linux采用了两种不同的优先级范围。第一种是nice值,范围是-20到19,默认为0,越大的nice值意味着越低的优先级。可以通过ps -el命令查看,标记NI的一列就是进程的nice值。
第二种是实时优先级。其值是可配置的,默认变化范围是0-99。与nice值意义相反,越高的实时优先级数据意味着进程优先级越高。
-
时间片
时间片是一个数值,它表明进程在被抢占前可以运行的时间。调度策略必须确定一个默认时间片,但这并不简单。因为过长的时候片会导致系统反应迟钝,而过短的时间片会明显增大进程切换带来的消耗。这同样是前面看到的矛盾,I/O密集型进程需要更短的时间片来保证响应速度,而计算密集型进程需要更长的时间片来保证效率(比如这样可以使它们在高速缓存中命中率更高)。
进程优先级和时间片是传统进程调度两个通用概念。
下面就可以看一下CFS算法的工作原理。
假设这样一个Linux系统,其上只运行了两个进程,一个文本编辑程序和一个视频解码程序。CFS不再给文件编辑程序分配给定的优先级和时间片,而是分配一个处理器使用比。如果两个程序具有相同的nice值,那其处理器比都是50%,它们平分了处理器时间。但很明显文件编辑器更多时间用来等待用户输入,因此它肯定不会用到处理器的50%,而视频解码器为了更快完成解码任务,就有机会使用超过50%的处理器时间。CFS发现这种情况,为了兑现进程公平使用处理器的承诺,在文本编辑器被唤醒时,将会立刻抢占视频解码进程,让文件编辑程序投入运行。
我们看到,CFS算法的核心就是:“完全公平”。
CFS的出发点是基于完善的多任务处理器模型,所谓完善多任务处理器模型是这样的:我们能在10ms内同时运行两个进程,它们各自使用处理器一半的能力。当然,这种模型并非现实,因为一个处理器不能同时运行多个进程。CFS允许每个进程运行一段时间,循环轮转,选择运行最少的进程作为下一个运行进程,而不再采用分配给每个进程时间片的做法了,CFS在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是依靠nice来计算时间片。nice值被CFS拿来作为进程获得处理器运行时间比的权重。
我们应该注意到,当可运行的任务趋于无限时,它们各自所获得的处理器运行比和时间片都趋于0,这样造成了不可接受的切换损耗。CFS为此引入了每个进程可获得时间片的最小值,称为最小粒度。默认这个值是1.这保证了即使趋向于无穷的可运行进程,每个也至少得到1ms的运行时间。也就是说在进程非常非常多的时候,CFS并不是完美的公平调度算法,因为大家要轮转执行;(
CFS主要在kernel/sched_fair.c中实现,需特别关注的是4个组成部分:
-
时间记账
CFS使用vruntime变量来记录一个程序到底运行了多长时间以及它还应该再运行多久。它的计算经过了所有可运行进程的标准化(或者说被加权的),并以ns为单位,因此它跟定时器节拍不再相关。
-
进程选择
CFS会挑选一个最小vruntime的进程来执行(经过nice加权过了,与优先级已经无关),这就是CFS调度算法的核心:选择具有最小vruntime的任务。那么剩下的工作就是如何选择vruntime最小的任务了,CFS使用红黑树来组织可运行进程队列,其中节点的键值便是可运行程序的vruntime.最左侧的叶子节点就是我们要选择运行的下一个进程。因此所有操作,都归纳到红黑树的增,删,平衡上面来了。
-
调度器入口
调度器入口函数是schedule(),定义在kernel/sched.c中。它是内核其它部分调用进程调度器的入口:选择哪个进程可以运行,何时将其投入运行。schedule()首先通过pick_next_task()找到合适调度类。然后问一下调度类,哪个程序该运行了,其实现就这么简单。
-
睡眠和唤醒
休眠(被阻塞)的进程处于一种特殊的不可执行状态。进程休眠有多种原因,但肯定都是为了等待某一个事件。事件可能是一段赶时间从文件I/O读更多数据,或者某个硬件事件。一个进程还有可能尝试获取一个已被占用的信号量被迫进入休眠。无论何种情况引起的休眠,内核处理操作都是相同的:进程把自己标记为休眠状态,从可执行红黑树中移除,放入等待队列,然后调用schedule()选择和执行一个其他进程。唤醒过程正好相反:进程被设置为可运行状态,然后再从等待队列移到可执行红黑树。
以上是关于读书笔记《Linux内核设计与实现》进程管理与调度的主要内容,如果未能解决你的问题,请参考以下文章