首页 > 代码库 > Linux内核工程导论——进程

Linux内核工程导论——进程

进程

概要

进程这个概念最早是不存在的。用现在的话来说就是单进程的操作系统。可当时的人们却不这么觉得。一个在板子上跑起来的软件有一条执行流水线不是很正常的事情吗?但是随着业务逻辑越来越复杂,人们对一个板子同时做多个事情的要求越来越大,于是人们想办法要可以同时模拟出多个正在执行的代码段。之所以说模拟,是因为那个时候CPU着实只有单核,说CPU同时执行多个代码超出了物理的限制。因此只能是切分CPU的时间片用来造成假并行。即使是到现在有了物理的多核,CPU的时间片也会被切分用以创造超过CPU个数的并行现象。
这种需求的解决方案同时带来的有利的和不利的结果。有利的是满足这个编程需求,这个需求大到很多实际应用没有这个需求根本不能实现。既然这样,同时带来的副作用,无论多么大,只能想办法克服,毕竟需求是技术生存的原动力。有两个最大的副作用是资源竞争与执行实体的调度。
既然要在一个只能运行一个代码流水线的CPU模拟运行多个代码流水线,只能通过设计上层概念,然后分时的分配CPU资源,也就是从此CPU也被人们称为了资源。设计的上层概念有很多:进程、线程、workqueue、tasklet、softirq。这还仅是在linux中目前仍在使用的代码流水线概念。在当前的内核中,进程与线程是一样的,workqueue 、tasklet、softirq等和进程一起参与调度算法的调度,因为不调度就意味着不能被CPU执行。这些代码流的概念服务于不同的用途,例如softirq和tasklet一般用于中断,work queue一般用于驱动。进程和线程的概念则一般用于用户空间。而在实际的实现上,softirq、tasklet或work queue都可以封装到某个线程,如此调度算法在调度的时候只需要认识线程一种结构就好了,精简算法逻辑是有好处的。但是这也仅是实现的方式。我完全可以让调度算法认识各种不同的代码流概念,从而在调度时区别对待。
我们现在写用户空间代码时只认识进程和线程。那是因为用户空间是内核的接口产品,用户空间的程序员对整个世界的认识是内核希望他认识的样子,就如果国家与人民。例如用户空间看到进程,也看到线程,并且优秀的程序员都能显著的说出进程与线程的区别。但是在内核空间,着实线程与进程没有太大的区别,用户空间看到的区别,其实是伪造出来的。如同你看电影是在汉朝,但不在屏幕范围内的,但却就真实的位于皇帝旁边的是现代的摄像头和导演。
 
既然进程的概念最后胜出了,我们也只认识强者,也就是说为了说明现代的进程,必须要说明实现进程所必须付出的代价:进程调度、资源竞争以及进程概念是如何被制造出来的。另外还有一点不能说是代价,而是进程概念的组成部分,就是进程通信问题需要单独讨论。

进程调度

概要

         linux是个多进程的环境,不但用户空间可以有多个进程,而且内核内部也可以有内核进程。linux内核中线程与进程没有区别,因此叫线程和进程都是一样的。调度器调度的是CPU资源,按照特定的规则分配给特定的进程。然后占有CPU资源的资源去申请或使用硬件或资源。因此这里面涉及到的几个问题:

对于调度器来说:

 

  • 调度程序在运行时,如何确定哪一个程序将被调度来使用CPU资源?
  • 如何不让任何一个进程饥饿?
  • 如何更快的定位和响应交互式进程?
  • 单个CPU只有一个流水线,但能否一次调度多个进程同时使用多个CPU的物理资源?
  • 调度来的CPU如何让其释放资源?是任其自己释放还是有相关回收机制?

 

对于希望被调度的进程来说:

 

  • 如何定义自己被调度的概率?
  • 如何在等待被调度的同时接收信号?
  • 如何避免自己希望占有的资源在自己没有被调度时不被别的进程占用?或者SMP环境下没有与其同时使用同一资源的进程?

 

调度策略

         分为分时系统和实时系统两种。linux本身不是实时系统,但是本着兼容并包的原则,linux也实现了实时系统的接口。

         对于整个内核来说,调度策略包括:SCHED_NORMAL、SCHED_FIFO、SCHED_RR、SCHED_BATCH四种。而标准的调度策略还有两种linux没有实现:SCHED_IDLE、SCHED_DEADLINE。SCHED_NORMAL就是默认的我们最常说的分时的调度策略。

SCHED_IDLE的进程将会在没有任何非SCHED_IDLE进程存在的情况下执行。该等级通常用于类似磁盘整理等不能影响用户的后台时间不敏感操作。但是linux内核并没有实现。

         SCHED_NORMAL有完全公平和针对用户交互优化调整优先级两种情况。一般我们常用的是要针对用户做动态优先级调整的。

         无论是实时的还是普通的,优先级都是由数值表示的。普通的静态优先级全部为0,区别普通调度程序可以用动态优先级。实时调度的程序优先级为1-99,也就是说任何一个实时程序的优先级都高于普通程序。

         当使用SCHED_RR时,时间片流转的,虽然也有优先级的数字,但是即使是最高优先级的进程在时间片用完的时候也会释放CPU。而SCHED_FIFO,除非主动释放,否则具有最高优先级的进程永远不会释放CPU(等待IO完成除外)。两者当存在更高优先级进程时都会被抢占。

         前面说了SCHED_IDLE调度方式没有实现,那么linux如何实现后台磁盘整理等操作?答案是功能类似的SCHED_BATCH调度方式。这种调度方式并不会在有正常程序的时候完全不执行,但是其会保证正常程序的执行和交互程序的响应。也即适合GCC等编译操作。

进程调度策略的配置

         你可以通过内核提供的API设置调度调度办法,也可以通过命令行。命令是chrt。你还可以配置实时进程的最大时间占用情况,因为如果实时进程出现bug,最高优先级的进程几乎不可能释放CPU,导致系统卡死。通过sysctl调用可以设置kernel.sched_rt_period_us等参数可以配置最大的实时调度进程占用的CPU情况。

         通过搭配cgroup和进程调度,还可以实现按照cgroup进行CPU资源的配置方式。这也是通过cgroup文件系统完成的。

 

调度过程

内核进程提供的内核基础设施

         内核中很多操作都是使用一些内核基础设施完成的。例如workqueue、tasklet、softirq。这些基础设施一般可以完成特定的任务。既然是用来完成任务的,就必须参与调度。而调度的单位只能是内核线程。所以这些机制虽然对用户来说是一些拿来即用的调用接口,但其执行却是通过特定的内核守护线程执行的。

软中断、tasklet与workqueue

         linux中中断分为上下两部分,下半部分可以关中断,产生上半部分的中断任务。上半部分不需要关中断,可以调度执行。这样的原因是系统中关中断的时间必须短,否则就会失去响应。产生的软中断被加入内核守护线程ksoftirqd的执行队列。这个线程后续会调度执行相关软中断。tasklet与软中断类似,只是在SMP系统中,软中断是可以被多个CPU一起执行的,是可重入的,而tasklet一次只允许一个CPU执行,是不可重入的。用户可以根据软中断是否允许重入决定是否使用tasklet或softirq。

         特殊的,softirq和tasklet不能睡眠,所以不能使用信号量或其他阻塞函数。因为他们对应的都是由一个内核线程执行的(ksoftirqd),如果阻塞了,系统将无法响应其他软中断。而工作队列workqueue本身就是作为一个可用的单元提供给用户,一个workqueue就是一个内核线程。内核模块可以生成一个workqueue,然后添加自己的任务进去。也可以使用内核已经有的workqueue,向其中添加任务。workqueue是一个容器,内核模块可以向已有的workqueue中添加任务。该workqueue就会调度执行自己的子任务。可以说是进程中的进程。

资源锁

         内核中的资源锁有:自旋锁、信号量、互斥锁、读写锁rwlock、顺序锁、RCU锁、Futex锁。

这些锁分别用来解决不同类型的问题:

l  软中断中多个CPU同时访问同一资源。由于软中断不能睡眠,因此在多个CPU抢用统一资源时不能用其他锁,只能忙等,这就是自旋锁。

l  普通进程竞争资源时,该资源无论读写,在同一时间只能有一个或几个进程获得。这就是互斥锁和信号量(信号量为1时就是互斥锁)

l  当互斥不是很频繁的时候,希望不必每次都进入内核。就有Futex锁

l  同一个资源希望读和写分开处理。就是读写锁和顺序锁和RCU

不同的锁服务于不同的目的和场景。实际上linux只是应用资源锁思想的一部分,操作系统原理是一门学科,其有多种方式用于处理资源锁问题。

资源锁本质上是同步和互斥问题。从上面可以看出,大部分是处理同时写的问题。所以只要能保证比较并写的操作是原子的,线程就可以是无锁的。Intel已经实现了类似的指令,如cmpxchg8,在一个周期内完成比较和写操作就可以保证不发生并发写冲突。

同样的思想,linux也提供了两组原子操作,一组针对整数,一组针对位。合理的利用原子操作就可以避免大部分的锁应用场景。自旋锁看起来代价很大,一个运行时需要两外的CPU空转等待,但是在要锁住的代码量很少的时候,由于自旋锁的轻量级,就比使用信号量代价小很多。所以,自旋锁不仅用于软中断,还可以用于加锁很小的一段代码的时候。

除了自旋锁,还有一种锁需要忙等,就是顺序锁。严格的说这不是忙等,而是使用了一个巧妙而又非常简单的思想,在读之前看锁值,在读之后看锁值,如果不变化,就表明读的过程中,读的值没有被写,就重读。写的时候就会改变锁值。原理相当于自旋锁,但是可以允许多个写,读操作在多个写操作全部完成后才能读得正确的值。

但是当要加锁的是大块的逻辑时,就的需要信号量这种重量级的锁。但是,一般的逻辑都应该尽量避免大块锁。现实中,也可以通过精细的设计来避免大块锁。

RCU锁直接不阻塞写,前面的顺序锁已经是改进的读写锁了,但同时也只能有一个写。但RCU锁允许不阻塞写操作,多个写的时候不是写到同一个地方,而是拷贝一份新的数据写。读还继续读旧的,如此以内存的使用增多为代价换来读写都不阻塞。

还有一种仅由用户空间进程使用的锁futex。使用这个锁可以完全取代用户空间的各种锁。因为其高效,行为又符合要求。futex的原理其实是考虑到用户态之前使用的信号量等锁都是内核中的一个变量,每次查询的时候都要进入内核态,还要再出来。fitex的思想就是直接将内核态的这个锁变量mmap映射到用户进程空间,如此,各个用户进程就可以在自己的空间直接查询这个值而不用进入内核就可以知道有没有人在用。读取虽然是大家都随便读,但是写入考虑到多个进程操作一个变量的可能冲突,linux是提供的API陷入内核来加锁写入的。虽然最后还是要陷入内核,但是其判断部分可以不进入内核完成,而大部分进入的情况判断资源是没有并发访问的。特殊应用场景除外。

信号量有个问题是,如果多个CPU获得读锁,则信号量本身会在各个cpu的cache中不断的刷新,造成效率的下降。解决的方式内核定义了一个新型的信号量:percpu-rw-semaphore。

互斥与同步

         互斥概念与同步概念必须要区别开。互斥只同一时间只有一个进程可以访问资源,没有时序概念,而同步包含了多个访问该资源的进程的访问的先后顺序,有你结束了轮到我的意思。互斥只是你还没结束我就没法开始。信号量是同步概念的,因为未得到资源的进程会睡眠等待。其他的内核锁是互斥概念的(自旋、顺序),因为得不到就阻塞,或者是让其永远可以得到(RCU)。

SMP锁与可抢占锁

         资源被抢占的情况有两种:SMP系统下多个CPU的并发访问和一个CPU下的可抢占访问。大部分应用做开发时都是用的一样的锁来锁数据。然而这两种情况有不一样的特点,很多情况下,一个CPU的可抢占锁可以做的更轻巧。

preempt_enable()、preempt_disable()、preempt_enable_no_resched()、preempt_count()、preempt_check_resched()通过这几个函数可以在可抢占单CPU情况下完成锁的工作,就不需要其他种类的锁了。

优先级锁

         futex是用户端使用锁的一个很好的选择,然而用户的进程具有不同的优先级,而锁无视所有优先级,信号量可以实现同步概念,但锁没有。然而有些时候希望获得锁在进程上有优先级的区别,这是pi-futex锁提供的功能,叫做优先级继承。是使用futex锁实现的,但是增加了判断进程优先级来确定解锁的优先级。打开这个机能之后效率会显著下降。

自旋锁的SMP处理

         当一个自旋锁上很多进程在自旋等待,就可以判断在自旋锁上非常忙。判断的方式是自旋的过程中发现自旋锁的所有者发生了改变,但变成的却不是自己。此时,应该睡眠而不是继续自旋。

lg_local_lock、lg_global_lock

多进程(线程)

         由Linux内核对线程和进程没有区别,如果要实现具有单独调度单位的线程,在内核中必须用进程来对应。众所周知的是,在内核看来,每个进程能访问的资源通常是其他进程不知道的,而用户态要求多线程编程需要可以共享内核,Linux内核中解决这个问题的方式是使用一个机制,使得一个进程在创建时可以指定哪些资源可以与其他进程共享。如此模拟实现多线程环境。较新的内核不但可以共享资源,还可以使用unshare系统调用取消共享,也就是说内核从底层让用户端线程脱离进程独立运行成为了可能。

进程资源限制

         有一大类需求是限制进程可用的资源。可以限制CPU、内存、文件、行为等。甚至系统调用。

系统调用限制:seccomp_filter

         限制进程的可见的系统调用使用seccomp_filter功能。

进程现象的制造

我们知道进程是被制造出来的概念。那么linux是如何制造的这个概念呢?前面说了调度和资源竞争的解决。那么调度的究竟是什么呢?之所以这么安排结果,是因为所有人都已经或多或少的认知了进程概念,所以这里探讨的本质,更多的是提高,而非开门的概览。

例如我们自己写一个机场飞机起飞的调度程序,我们根据一系列的原因(或者是跑到资源、或者是天气原因、或者是上面的意思)安排不同飞机在不同的跑到上起飞,我们安排的是飞机,那么在程序中我们如何表示飞机呢?那必然是一个结构体(C++中可以是个类)。这也就不难理解进程调度算法调度的是什么了,其实也是个结构体,这个结构体是task_struct,一个非常大的结构体。只要进入调度算法,当调度算法运行结束后,其必然输出的是一个task_struct结构体(current宏)。而CPU永远执行的是调度算法执行结束后的输出结构体所描述的代码位置。

我们知道任何的现代程序的执行都要有栈的概念,栈最大的功能是用来做函数跳转(其实又是一个为了达到函数目的而带来的代价产品)。而栈有大小,有组织结构,有当前的位置,有定义好的出栈入栈的操作方法。你没有问过栈的这些属性和方法是谁设计和实现的吗?答案自然是内核。没有进程概念时,只需要一个栈,就是内核代码运行的栈,而有了进程概念后,就要为每个进程准备单独的栈了。而这个工作只能由内核自己来完成。

为了实现进程概念,带来的又何止是栈设计与维护这点工作量?如何有效的定位各个task_struct,自然靠数字,于是有了pid概念。进程被调度算法切换出CPU时,进程执行时存储在寄存器上的变量怎么办?只能设计机制来保存与恢复,于是又有了进程上下文的概念。进程作为一个实体与其他进程的关系应该怎么定义?于是又有了进程家族树的概念。进程如何创建?如何终结?又带来了很多新的概念。这一切的代价,都得内核去弥补。

确切的说,介绍内核的进程是如何就是介绍内核是如何处理进程概念的引入带来的一系列代价的。而至于进程的概念本身,在所有操作系统都是一样的。因为它只是一个存在于理论上的概念模型。

 感谢大家观看兄弟连分享

 

Linux内核工程导论——进程