linux内核设计与实现笔记

1 linux系统层次自底向上:

  • 硬件,鼠标、显示器、磁盘、光驱等
  • 设备驱动程序,鼠标驱动、硬盘驱动、光驱驱动等
  • linux内核,文件管理、主存以及二级存储管理、进程管理、cpu调度、进程间通信IPC
  • 系统调用接口(内核的切入口),任何用户、程序对操作系统资源和硬件设备的访问,都要通过系统调用。好处是他提供了一种硬件的抽象接口、保证系统稳定和安全,系统调用是访问内核的唯一手段。
  • 语言函数库,c、c++、java、fortran等
  • linux shell、TC、应用程序

2 IPC进程间通信

  • 管道,本地有关系的多个进程间的通信,例如父子进程,管道是驻留在内存中的临时渠道,而且通常是由内核代表父进程创建
  • 命名管道(FIFO),用于本地多个无关系的进程间通信,是驻留在磁盘中的永久通信渠道
  • BSD套接字:可用于本地、远程的进程间通信,是因特网软件如浏览器、ftp、telnet的实现基础

3 对于linux而言,文件都是字节序列,内核不对文件做解释,解释工作由应用程序来做,所有的系统资源都可以用文件来表示,例如设备、目录、打印机都可以看做是一个文件。文件包括:

简单普通文件;目录;符号(软)链接;特殊文件(设备),字符文件或是块文件;命名管道(FIFO)

4 一个进程就是一个执行期的程序,它不仅包括代码,还包括打开的文件、挂起的信号、内核内部数据、处理器状态、地址空间、一个或者多个执行线程、存放全局变量的数据段。对于linux,线程和进程是一样的,线程只是一种特殊的进程而已。线程之间可以共享虚拟内存,但是各有各的虚拟处理器。

线程状态的切换


linux的进程本身就是轻量级的,对于内核而言线程和进程没有特殊的区别。只不过是在创建线程的时候,指定了一些共享资源,如地址空间、文件系统、文件描述符和信号处理程序。

5 进程调度

进程调度的基本原则是,只要有可运行的进程,那么就会有正在执行的进程。当可运行的进程数,大于cpu个数,那么就总会有进程在等待。

调度可以分为两类,一种是非抢占式调度,一个进程一旦开始运行,那么cpu就会一直被占用,知道进程运行结束;一种是抢占式,比如一个进程的时间片用完,那么就会调度其他进程,或者进程自身阻塞挂起,系统也会调度其他进程。

进程可以分为两类,一种是io消耗型,在运行过程中会经常进入io等待;还有一种是cpu消耗型,除非被抢占,否则会一直运行。为了提高系统的响应速度,应该降低cpu消耗型的进程执行频率,降低其优先级,相当于说是锄强扶弱,让io消耗型的任务不吃亏。调度策略需要针对进程的类型,在响应时间和吞吐量取得平衡。

linux采用基于优先级的调度方法,会优先调度优先级高的进程,相同优先级的进程会采用轮转法调度。有两种优先级范围,一种的nice,nice越高的进程表示越懂谦让,优先级越低。一种是实时优先级,可以配置,实时优先级的进程都优先于普通进程。

时间片表示进程在被抢占之前,能够持续运行的时间。一般来说交互性较高或者优先级较高的都会有较长的时间片,反之亦然。时间片一般默认是20ms。时间片用完,进程就到期了,直至所有的进程时间片都用完,才会重新计算时间片,重新获得被执行的机会。

当一个进程进入可运行状态的时候,linux会检查这个进程的优先级是不是大于当前运行的进程优先级,如果是的话,那么当前运行的进程会被新进程抢占。当时间片用完的时候,同样也会发生一次调度抢占。

调度程序会包含两个优先级数组,一个活跃的,一个过期的。数组中的每一个优先级都会对应一个运行队列,当发生调度的时候,会优先调度优先级最大的可运行进程。当进程的时间片用完的时候,会被迁移到过期队列,并且在迁移的时候,重新计算时间片。当活跃数组中的进程时间片都用完、队列为空的时候,过期数组和活跃数组会做一次切换。

动态优先级特性,每个进程创建后都会有一个默认的优先级,在运行的过程中,系统会通过观察这个进程的休眠时间情况,来判断它到底是io型的还是cpu型的,如果偏向于cpu型的,那么会依据一定的算法减少它的时间片和优先级。

当进程在等待事件的时候,例如io数据的就绪,就会把自己标记为休眠,从运行队列移到等待队列,并触发调度算法;唤醒则是相反,把自己标记为可运行状态,并放入运行队列


linux还会有一个负载均衡程序来保证多cpu的负载均衡。

6 抢占与上下文切换

上下文切换,系统首先把虚拟内存从上一个进程映射切换到新进程,然后从上一个进程的处理器状态切换到新进程的处理器状态,包括保存、恢复栈信息和寄存器信息。

7 内核需要管理计算机上面所有的设备,有一种思路是内核定时去轮询设备,看设备是否有事件需要处理。但考虑到设备运行速度和cpu速度不是一个级别的,轮询会浪费很多工作量。于是就有了中断机制,让设备在有事件要处理的时候向cpu发送电信号,再由cpu通知内核执行中断处理。例如敲打键盘,键盘控制器(控制键盘的硬件设备)向处理器发送一个中断,处理器收到中断后,通知操作系统,由操作系统来处理新到的数据(timer也是通过中断来完成的)。一旦发生中断,处理器就会马上停下手头工作,转而处理中断。所以必须要求中断处理程序在短时间内完成,以免对其他进程造成影响。

每个设备都可能会有一个或者多个的中断处理程序,而这个中断处理程序便是由设备的驱动程序提供的,并向内核注册这个处理程序。
 由于中断处理需要快,有些中断又需要处理很多事情,于是操作系统把中断分为上下部分。以网卡为例,当有数据到来的时候,网卡会立马发出中断,通知内核有数据到来。内核收到通知,执行网卡注册的中断处理程序,首先他应答设备,然后把最新的数据包拷贝到内存中,读取网卡更多的数据包。这些都是重要紧迫、并与硬件相关的工作,成为上半部分。处理和操作数据包在下半部分进行。

中断是不可重入的,一个中断处理程序不会同时处理两个统一中断线上的中断。


8 中断下半部分

之所以会有下半部分,是因为上半部分即中断处理程序,有几个局限性:

  • 他可能随时会打断其他代码的执行,为了避免其他代码停止时间过长,影响系统响应性,中断处理程序必须快。
  • 当一个中断执行时,他可能会屏蔽掉同级的中断,甚至是全局的中断,所以需要越快越好
  • 中断处理程序一般要对硬件进行操作,有严格的时限。
  • 中断处理程序不在进程上下文中进行,不能阻塞,因此有很多事情会受到限制

因此中断处理程序必须快速、异步、简单的响应硬件。把对时间要求宽松的任务推迟到下半部分进行。

只有当任务对时间要求特别敏感、和硬件相关、保证不被其他中断打断之外,其他任务最好是放到下半部执行。

下半部的实现包括软中断、tasklet(基于软中断实现的,比软中断易用)、工作队列。其中软中断的执行是通过内核线程来执行,效率较高。当中断处理程序返回的时候,一般就会执行软中断。为了避免大量的软中断对用户进程造成影响,执行软中断的线程一般nice值都设置为19,只有当处理器较为空闲的时候,才会去处理软中断。工作队列有别于软中断的实现机制,他是可以在进程上下文中进行的,因为它可以休眠、阻塞调用、使用大量的内存、获取信号量。当你需要这些特质的时候,就使用工作队列吧,否则推荐使用tasklet.


 9 系统调用

一般来说应用程序通过api而不是直接通过系统调用来编程。一个api可以实现一个系统调用,也可以是多个系统调用的组合,例如posix接口,基于unix可移植的操作系统标准。

linux的系统调用是在c库里面提供的,c库实现了unix系统的主要api,包括标准c函数和系统调用。

当进行系统调用的时候,应用程序必须以某种方式来通知内核,切换到内核态,让内核代表自己执行系统调用。通知内核的机制是通过软中断来实现的:通过引发一个异常来促使系统切换到内核态执行异常处理程序,此时的异常处理程序就是系统调用程序。由于有这种用户态和内核态切换的开销,在编程的时候尽量避免系统调用。例如不用阻塞锁,而用自旋锁就是为了减少这种开销。

10 定时器

除了事件驱动外,内核中还有很多任务是基于时间驱动的。例如每秒执行多少次,多久之后执行之类的任务。这些功能的实现都是基于系统定时器来完成的。即定时器中断,他对应的中断处理程序负责更新系统时间和执行需要周期性运行的任务。系统定时器和时钟中断处理程序是linux内核管理的中枢。

系统的时钟频率一般为1000hz,也就是每秒执行1000次,这样依赖于时间的任务最大的误差不会超过1ms。提高解析度和准确度。然而频率高也会有坏处,比如每秒会产生1000次定时器中断,会增大cpu开销,不过就目前而言,并没有因此给系统性能带来影响。

java里面的各种定时器的实现底层就是系统定时器,由系统定时器硬件和对应的中断处理程序来提供的。

11 内核同步

临界区指的是访问共享数据的代码段,当有多个线程并发修改共享数据的时候,就有可能产生数据不一致的情况,我们称为竞争条件。避免并发和防止竞争条件的机制称为同步。

同步的具体实现方式是加锁,进入临界区之前必须获得锁,出临界区必须释放锁。表征锁状态的数据本身也是共享的,所以锁的操作必须是原子性的,刚好几乎所有的cpu都支持cas指令,使得加锁措施能够奏效。

加锁会使程序复杂化,会遇到活性风险,例如死锁。A、B互相持有对方的资源,并需要拿到对方的资源才能继续执行。一般来说发生死锁的场景,经常是嵌套锁的场景。比如线程1先获取锁A,再索取锁B,线程2先获取锁B,再获取锁A。避免这种死锁的方式是,保证获取锁的顺序一样,都统一先取A锁再取B锁。另外,线程不要重复请求同一个锁,对于锁的方案力求简单,越复杂越容易出错。

当锁的竞争过于激烈,系统的性能会急剧下降,所以尽量避免使用锁,很多场景可以用cas的机制来达到相同的目的。还有另外一种解决的办法就是细化锁的粒度,例如concurrentHashmap。锁粒度细化后,在竞争激烈的情况下,性能会有很大的提升,但是锁粒度细化,也意味着系统要维护更多的数据结构,开销也会比较大(在无竞争的情况下,性能不如大粒度锁)。所以锁方案的设计也是要因地制宜。

12 锁

linux内核使用得最多的是自旋锁,即忙等待锁。由于自旋锁会浪费cpu时间,是一种轻量级加锁,锁不应该被长期持有。自旋锁是不可递归的,否则会死锁。自旋锁可以用在中断处理程序上面,因为中断处理程序是不会休眠的。

读写锁,多个读锁可以并发,读和写锁不可并发。

linux中的另外一种锁叫做信号量,是一种睡眠锁,线程获取不到锁,会进入休眠状态。如果一个线程试图获取一个被占用的信号量时,那么他会被放入等待队列,并且休眠,处理器就可以重获自由了。当持有信号量的线程释放信号量之后,处于等待队列中的那个线程就会被唤醒,获得该信号量(这个实现思路和jdk的asq是相似的)。

信号量还有一个特点是他是可计数的,也就是说可以支持多个线程同时占用信号量。那种只支持一个线程占用的信号量成为互斥信号量,二值信号量。

使用信号量一般是在以下场景:

  • 锁会被长时间持有。如果锁被短时间持有的话,那么使用信号量就不划算了。因为睡眠(如果是用户空间触发的睡眠,还会产生用户态和内核态的切换)、维护等待队列、进程上下文切换、唤醒所花费的开销可能被自旋时浪费的cpu时间还要多。
  • 锁会休眠所以适合在进程上下文中使用,中断上下文由于不能休眠所以不适用
  • 需要在执行过程中休眠
  • 占用信号量的同时不能使用自旋锁

(???在高级编程语言的世界里,信号量和自旋锁不是在同一个维度上面的,信号量仅仅是用于表示可以计数的,而一个锁是要自旋、还是要休眠,和是否是信号量无关,仅仅是在获取锁失败后,线程采用的处理方式,根据锁持有时间来考虑是自旋还是休眠,信号量的实现统一可以采用自旋,详情参看ASQ http://hill007299.iteye.com/blog/1800986)

13 文件系统

虚拟文件系统作为内核子系统,为用户空间程序提供了文件系统的读写接口。linux系统中所有具体的文件系统都依赖于虚拟文件系统,依靠虚拟文件系统协同工作。VFS使得用户可以直接用open\read\write进行系统调用而无需考虑具体的文件系统实现和物理介质。这些调用可以跨越所有的介质和文件系统,例如从一个文件系统拷贝数据到另外一个文件系统。


文件其实是一个字节序列,第一个字节表示文件头,最后一个字节表示尾。与面向记录结构化的文件相比,面向字节流的文件抽象更为简单和灵活。

VSF有几个核心对象:

超级块对象,所有的文件系统都必须实现超级块对象。该对象用于存储特定文件系统的相关信息,通常存放在磁盘特定扇区的文件系统超级块或者文件系统控制块。

索引节点对象。索引节点包括内核在操作文件或目录时所需的所有信息。对于unix的文件系统来说,这些信息都可以直接从磁盘索引节点读取。
目录项对象。vfs把所有的目录页当成文件看待,例如在路径/bin/vi里面,bin和vi都是文件,bin是特殊目录文件,vi是普通文件,路径每个组成部分都有一个索引节点表示。由于unix经常会进行一些目录相关的操作,例如查找路径名,出于性能的考虑,必须引入目录项概念,/、bin、vi都是目录项,目录项会通过缓存的方式来提高性能。

文件对象。文件对象表示进程已打开的文件。用户进程直接处理的是文件,而不是超级块、索引节点和目录项。文件对象是已打开的文件在内存中的表示,他包含了访问模式、偏移量等信息。由open系统调用打开,close销毁。

14 块io

字符设备是按照字节流的方式被访问,例如串口和键盘。而块设备则是可以支持随机无序访问的,例如磁盘。

块设备的最小可寻址单位是扇区,扇区大小一般是2的整数倍,常见的是512byte。扇区是物理层面的概念,而在逻辑层面,文件系统是基于块来访问的,块是文件系统的最小可寻址单元。块必须大于扇区或者倍数于扇区,可能为512byte,1kb或者4kb。

块被调入内存时(在读入后或是等待写出前),都要存储在一个缓冲区里。每个缓存区都能与一个块对应,缓冲区是块在内存中的表现,缓存区还包括操作块的一些相关控制信息。

块设备将挂起的块io请求放到请求队列里。只要请求队列不为空,块设备对应的驱动程序就会从队列中获取请求,把请求送到响应的块设备中。

如果简单的把每个请求单独处理的话,那么性能会非常糟糕。因为每次请求都要进行磁盘寻址和延迟,耗时很久,所以需要io调度程序来统筹块请求,减少全局的磁盘寻道时间。进程调度程序可以说是把cpu资源虚拟化分配给消耗cpu的进程,而io调度程序则是把块设备资源虚拟给多个磁盘请求。

io调度程序的工作是管理io请求队列,会对请求进行合并和重排序,以此来减少总的磁盘寻道时间。例如两个请求需要访问相邻的扇区,那么调度程序会将两个请求合并,只需要给磁盘发出一个寻址命令,一次性访问到多个相邻扇区。如果队列没有相邻扇区的io操作,那么会采用排序的方式,按照扇区号增长的方式排序,让磁头尽可能的直线移动,以减少每次单独请求的寻道时间。和电梯调度相似,因此io调度程序也成为电梯调度。电梯不能随便从一层移动到另外一层,会尽可能的保证直线移动。采用电梯调度,需要考虑饥饿现象,可能会有一些不幸运的偏远扇区的请求永远排不上号,因此需要有最后期限调度来做补充。

完整的最后期限调度策略图如下



 每个请求都有一个超时时间,一般读请求较短,因为它的同步的,对响应时间要求较高,一般为500ms,而写请求是异步的,所以一般为5s。

上图的排序队列就是按照电梯方法来调度的,依据扇区号来排序。而读写队列都是先进先出的。当一个请求过来的时候,除了插入排序队列外,还会依据类型插入另外两个队列。对于普通操作,最后期限调度程序会把请求从排序队列中取出,推到派发队列,派发队列再将请求交给磁盘驱动,最小化寻址。当在读或者写fifo队列头时发现请求超时,那么调度程序会马上进行服务,把队列头取出放到派发队列里。他不严格保证响应时间,但是可以保证会在超时前、或者超时的时候马上提交。

15 内存管理

物理页是内核管理内存的基本单元。尽管cpu的最小可寻址单位是字甚至字节,但是内存管理单元(MMU用来管理内存,并把虚拟内存转换为物理内存的硬件)通常是以页为单位进行处理。从虚拟内存的角度来看页是最小单位。

对于不同的体系结构页大小不一样,32位的体系结构一般是4kb每页,64位一般是8kb每页。

由于硬件限制,内核不能对所有的页一视同仁。有些页位于内存中的特定物理地址上,不能用于某些特殊任务。所以内核对不同特性的页进行分区。

linux使用了3种区:

DMA区,因为一些硬件只能在特定的内存地址进行DMA操作

Normal区,包含正常映射的页

HighMem区,称为高端内存,其中的页不能永久的映射到内核地址空间。

开源世界旅行手册

http://i.linuxtoy.org/docs/guide/
 

猜你喜欢

转载自hill007299.iteye.com/blog/1797243