书籍推荐《Linux内核的设计与实现 第三版》
OS的主要作用
- 管理硬件
- 管理应用
以下所有内容均以 Linux 为例
内核
内核是OS的核心,它管理着系统的各种资源。
内核的主要作用
内核的分类
宏内核
宏内核:kernel + 一些高级的虚拟接口(控制硬件)
简单的说,宏内核相当于一个是一个中央集权控制中心,把内存管理,文件管理等功能全部管理。PC上用的比较多,比如常见的windows、Linux。
微内核
微内核:提供操作系统核心功能的内核的精简版本,它设计成在很小的内存空间内增加移植性,提供模块化设计,以使用户安装不同的接口。
比如DOS、华为的鸿蒙。如嵌入式系统一样,可针对不同需求组装进来不同的模块。
外核
存在理论实验中。为应用定制操作系统。
类似淘宝的多租户JVM,比如可以专门为浏览器定制一个OS。
用户态与内核态
一般的操作系统对执行权限进行分级(Linux分为0~3),分别为用用户态(ring 3)和内核态(ring 0)。大多数时间各类程序都是执行在用户态下。
内核态相当于一个介于硬件与应用之间的层,内核有ring 0的权限,可以执行任何cpu指令,也可以引用任何内存地址,包括外围设备, 例如硬盘, 网卡,权限等级最高。
用户态则权利有限,例如在内存分配中,有一部分内存是仅为内核态使用的,用户态code则不允许访问那些内存地址,每个进程只允许访问自己申请到的内存。而且不允许访问外围设备。另外在执行cpu指令的时候也可以被高优先级抢占。
为了保障OS的安全,用户态相较于内核态有较低的执行权限,很多操作是不被操作系统允许的。对于系统的关键访问,需要经过kernel的同意,以保证系统健壮性。
一个程序的执行过程,要么处于用户态,要么处于内核态。
进程、线程、纤程
进程
进程是OS分配资源的基本单位。Linux中也称为task。
资源:独立的地址空间。里面存放着PCB、全局变量、数据段等信息。
PCB(进程描述符):PCB是维护进程信息的数据结构。每一个进程都跟着一个PCB。PCB的大小不固定,因为每个进程的信息不一样。
linux通过系统函数 fork() 来创建进程,通过exec() 来运行进程。从进程A中fork进程B时,A被称之为B的父进程
线程
线程是执行调度的基本单位。
在linux中,线程就是一个普通的进程,它和其它进程共享资源(内存空间、全局数据等)。
和GC一样,线程也有后台线程,在OS中叫内核线程。它会在内核启动后做一些后台操作(如计时, 定期清理某些垃圾)。内核线程只在内核空间运行。
在java中,调用thread对象的start方法时,会调用native的start0方法,该方法将JVM中的一个线程对应上OS中的一个线程。这是一个重量级的线程,需要先从用户态切换到内核态,向内核申请资源,然后由内核态再切换到用户态。由于这种操作太重了,所以引入了fiber。
纤程(Fiber)
纤程是线程中的线程。它是用户态的线程,切换和调度时不需要经过OS。
优势:
- 占有资源很少。通常开启一个线程,需要分配1M内存, 而Fiber只需4K
- 切换比较简单,不和OS打交道,并发量很高
- 同机器上可启动的fiber数量远大于线程数量
基于以上优点,Fiber比较适合具有很多很短的计算任务的场景。
支持Fiber的语言有Go、Scala、Kotlin等。Java目前(14)不支持,需要利用Quaser库(不成熟)。Go语言最大的优势就是Go内置 Fiber,更适合并发编程。
僵尸进程与孤儿进程
什么是僵尸进程
父进程产生子进程后,会维护子进程的一个PCB结构,子进程退出,由父进程释放,如果父进程没有释放,那么子进程成为一个僵尸进程。
僵尸进程只占PCB,对系统影响不大。可通过 ps-ef | grep defult
来查看
什么是孤儿进程
子进程结束之前,父进程已经退出。孤儿进程会成为init进程的孩子,由init进程(1号进程)维护。
进程调度
进程调度基本概念
- 进程类型:
- IO密集型 大部分时间用于等待IO
- CPU密集型 大部分时间用于计算
- 进程优先级:
- 实时进程 > 普通进程(0 - 99)
- 普通进程 nice 值(-20 - 19)
- 时间分配:
- Linux采用按优先级的CPU时间比
- 其它系统多采用按优先级的时间片
进程的调度由内核进程调度器负责。它决定该哪个进程运行,何时开始,运行多长时间。
Linux内核中每个进程都有专属的调度方案并且可以自定义。
进程调度的常见方式
- 独占式:除非进程主动让出CPU(yielding),否则将一直运行。
- 抢占式:由进程调度器强制开始或暂停(抢占)某一进程的执行。 现在多用该种方式。
Linux内核的进程调度(了解)
- 经典Unix O(1) 调度策略:每个进程所分配的时间片都一样(绝对公平),偏向服务器。这种方式对UI交互不友好,需要显示时如果没有被分配到时间片会产生较长延迟。
- CFS完全公平调度算法:按优先级分配时间片的比例,记录每个进程的执行时间,如果有一个进程执行时间不到他应该分配的比例,优先执行。在linux2.6.23后的内核中使用。
Linux默认调度策略 :
- 对于实时进程:使用 SCHED_FIFO(first in first out) 和 SCHED_RR(round robbin)两种。
- 对于普通进程:使用CFS完全公平调度算法。
实时进程犹如急诊病人,当有多个急诊病人时,按FIFO的方式排队,如果有相同优先级的急诊病人,则在此基础上按RR方式执行。当急诊病人(实时进程)处理完毕或主动放弃治疗(主动让出)后,普通病人(普通进程)才会按CFS算法得到诊断的机会。
中断
中断是硬件跟操作系统内核打交道的一种机制。
在office软件中按下键盘上的一个键会发生什么?
首先,在键盘上按下一个键时,会触发一个电信号,这个信号会通过总线发送到中断控制器来处理,中断控制器检测键盘按下这个中断是否激活,如果是则将该信号发送给CPU,CPU接受到该信号后就会立即停止自己正在做的事,然后通知kernel,kernel再根据中断向量表查询出该中断的类型和要调用的中断处理函数(里面已经写好的一堆处理程序,如处理键盘,处理打印机等等),随后kernel调用相应的函数进行处理,最后再由office(应用程序)处理。
中断的分类
中断又分硬中断和软中断:
- 硬中断:由外设硬件产生的,会直接中断CPU。主要是用来通知操作系统系统外设状态的变化。
- 软中断:由当前正在运行的进程所产生的,不会直接中断CPU。通常是软件在做系统调用时触发。
硬中断处理程序要确保它能快速地完成任务,这样程序执行时才不会等待较长时间,称为上半部。
软中断处理硬中断未完成的工作,是一种推后执行的机制,属于下半部。
硬中断中,有一张中断向量表来记录每一种中断信号的中断处理函数的关系。比如:
- 1-键盘-键盘处理程序
- 2-鼠标-鼠标处理程序
- 0x80-软件-处理程序
0x80H是所有软中断的信号,这个号通常对应的一堆的中断处理函数。比如 read(),write() 等等。
当一个应用程序想要读取网卡上的数据时,必须先经过内核来进行系统调用,此时它就会发出0x80信号来通知kernel。向ax寄存器中填入调用号(比如read函数是1号,write函数是2号,exit函数是-1号等),参数通过寄存器bx、cx、dx、si、di传入内核,返回值通过ax返回。
从汇编角度理解软中断
;hello.asm
;write(int fd, const void *buffer, size_t nbytes)
;fd 文件描述符 file descriptor , 比如fd=0表示标准输入,fd=1表示标准输出,fd=2表示标准错误输出
;buffer 文件内容
;nbytes 输出长度
section data
msg db "Hello", 0xA
len equ $ - msg
section .text
global _start
_start:
mov edx, len
mov ecx, msg
mov ebx, 1 ;文件描述符1 std_out
mov eax, 4 ;write函数系统调用号 4
int 0x80
mov ebx, 0
mov eax, 1 ;exit函数系统调用号
int 0x80
系统调用函数write接受3个参数,文件描述符、文件内容,输出长度,它们分别存放在ebx、ecx、edx中,随后向eax寄存器中填入调用号4,表示调用系统函数write,最后触发软中断。这样应用程序就会中断通知kernel它要进行系统调用write。再调用结束后再触发系统调用exit,告诉kernel系统调用结束。
常见的,比如java程序要读网络,首先在jvm层会调用一个read相关的函数,它会调用c库中的read相关函数,这时就会触发软中断,程序由用户态进入内核态,在内核空间执行系统调用处理程序,获取内容后,程序又从内核态恢复到了用户态。
从OS角度来说,一个程序在进行IO操作时,如果在用户态时被阻塞,需要等待,则该操作是BIO,如果用户态时不被阻塞,则该操作是NIO。
内存管理
早期,内存容量有限,程序员必须将写好的代码分段,然后一段一段地转入到内存进行读取。为了解决这个问题,提出了虚拟内存的概念,并引入内存映射机制,将程序员从大量烦琐的管理工作中解放了出来。
早期的DOS,同一时间只能有一个进程在运行;目前的OS都引入了虚拟内存,可以将多个进程转入内存。由于内存容量有限,这就产生了两个主要问题:
- 内存不够用怎么办
- 多个进程之间如何避免互相打扰
内存不够用
为了解决内存不够用的问题,引入了分页机制。即将内存分成多份固定大小的页帧(通常为4K),把程序(硬盘上)也分成4K大小的块,在启动程序时,实际上就是将程序进行切分,然后和内存中的地址进行映射,并将内存映射信息保存到页表中。当程序在执行过程中,用到程序中的哪一块,就去加载哪一块。在加载的过程中,如果内存已经满了,通过LRU算法,将内存中最不常用的一块放到硬盘中的swap分区, 并把最新的一块加载到内存。
常用的缓存算法:LRU、LFU、FIFO
互相打扰
虚拟内存解决了多个进程相互打扰的问题。
虚拟内存的优势:
- 隔离应用程序
- 每个程序都认为自己有连续可用的内存
- 突破物理内存限制(64位机的虚拟内存大小为2^64 byte)
- 应用程序不需要考虑物理内存是否够用,是否能够分配等底层问题
- 安全
- 保护物理内存,不被恶意程序访问
由于进程工作在虚拟内存,程序中用到的空间地址不再是直接的物理地址,而是虚拟的地址,这样,A进程永远不可能访问到B进程的空间。每一个进程都觉得自己独享了整个物理内存和CPU。
而实际的内存地址,通过MMU(内存管理单元,硬件),完成内存映射。类似汇编的偏移寻址,即偏移量 + 段的基地址 = 线性地址。