linux是个多进程的环境,不但用户空间可以有多个进程,而且内核内部也可以有内核进程。linux内核中线程与进程没有区别,因此叫线程和进程都是一样的。调度器调度的是CPU资源,按照特定的规则分配给特定的进程。然后占有CPU资源的资源去申请或使用硬件或资源。因此这里面涉及到的几个问题:
对于调度器来说:
对于希望被调度的进程来说:
分为分时系统和实时系统两种。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。这些基础设施一般可以完成特定的任务。既然是用来完成任务的,就必须参与调度。而调度的单位只能是内核线程。所以这些机制虽然对用户来说是一些拿来即用的调用接口,但其执行却是通过特定的内核守护线程执行的。
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系统下多个CPU的并发访问和一个CPU下的可抢占访问。大部分应用做开发时都是用的一样的锁来锁数据。然而这两种情况有不一样的特点,很多情况下,一个CPU的可抢占锁可以做的更轻巧。
preempt_enable()、preempt_disable()、preempt_enable_no_resched()、preempt_count()、preempt_check_resched()通过这几个函数可以在可抢占单CPU情况下完成锁的工作,就不需要其他种类的锁了。
futex是用户端使用锁的一个很好的选择,然而用户的进程具有不同的优先级,而锁无视所有优先级,信号量可以实现同步概念,但锁没有。然而有些时候希望获得锁在进程上有优先级的区别,这是pi-futex锁提供的功能,叫做优先级继承。是使用futex锁实现的,但是增加了判断进程优先级来确定解锁的优先级。打开这个机能之后效率会显著下降。
当一个自旋锁上很多进程在自旋等待,就可以判断在自旋锁上非常忙。判断的方式是自旋的过程中发现自旋锁的所有者发生了改变,但变成的却不是自己。此时,应该睡眠而不是继续自旋。
lg_local_lock、lg_global_lock
由Linux内核对线程和进程没有区别,如果要实现具有单独调度单位的线程,在内核中必须用进程来对应。众所周知的是,在内核看来,每个进程能访问的资源通常是其他进程不知道的,而用户态要求多线程编程需要可以共享内核,Linux内核中解决这个问题的方式是使用一个机制,使得一个进程在创建时可以指定哪些资源可以与其他进程共享。如此模拟实现多线程环境。较新的内核不但可以共享资源,还可以使用unshare系统调用取消共享,也就是说内核从底层让用户端线程脱离进程独立运行成为了可能。
有一大类需求是限制进程可用的资源。可以限制CPU、内存、文件、行为等。甚至系统调用。
限制进程的可见的系统调用使用seccomp_filter功能。
我们知道进程是被制造出来的概念。那么linux是如何制造的这个概念呢?前面说了调度和资源竞争的解决。那么调度的究竟是什么呢?之所以这么安排结果,是因为所有人都已经或多或少的认知了进程概念,所以这里探讨的本质,更多的是提高,而非开门的概览。
例如我们自己写一个机场飞机起飞的调度程序,我们根据一系列的原因(或者是跑到资源、或者是天气原因、或者是上面的意思)安排不同飞机在不同的跑到上起飞,我们安排的是飞机,那么在程序中我们如何表示飞机呢?那必然是一个结构体(C++中可以是个类)。这也就不难理解进程调度算法调度的是什么了,其实也是个结构体,这个结构体是task_struct,一个非常大的结构体。只要进入调度算法,当调度算法运行结束后,其必然输出的是一个task_struct结构体(current宏)。而CPU永远执行的是调度算法执行结束后的输出结构体所描述的代码位置。
我们知道任何的现代程序的执行都要有栈的概念,栈最大的功能是用来做函数跳转(其实又是一个为了达到函数目的而带来的代价产品)。而栈有大小,有组织结构,有当前的位置,有定义好的出栈入栈的操作方法。你没有问过栈的这些属性和方法是谁设计和实现的吗?答案自然是内核。没有进程概念时,只需要一个栈,就是内核代码运行的栈,而有了进程概念后,就要为每个进程准备单独的栈了。而这个工作只能由内核自己来完成。
为了实现进程概念,带来的又何止是栈设计与维护这点工作量?如何有效的定位各个task_struct,自然靠数字,于是有了pid概念。进程被调度算法切换出CPU时,进程执行时存储在寄存器上的变量怎么办?只能设计机制来保存与恢复,于是又有了进程上下文的概念。进程作为一个实体与其他进程的关系应该怎么定义?于是又有了进程家族树的概念。进程如何创建?如何终结?又带来了很多新的概念。这一切的代价,都得内核去弥补。
确切的说,介绍内核的进程是如何就是介绍内核是如何处理进程概念的引入带来的一系列代价的。而至于进程的概念本身,在所有操作系统都是一样的。因为它只是一个存在于理论上的概念模型。