内核的4种基本能力:
内核是怎么工作的?
内核具有很高的权限,可以控制CPU、内存、硬盘等硬件,而应用程序权限很小,因此大多数操作系统把内存分为两个区域:内核空间和用户空间。
用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间。应用程序如果需要进入内核空间,需要通过系统调用,产生一个中断。发生中断,CPU中断正在执行的用户程序,转而跳转到中断处理程序,开始执行内核程序。内核处理完成后,主动触发中断,CPU回到用户态继续工作。
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
进程持有的虚拟地址会通过CPU中的内存管理单元(MMU)的映射关系,来转换为物理地址,然后再通过物理地址访问内存。
操作系统如何管理虚拟地址与物理地址的关系?
内存分段和内存分页。
程序是由若干个逻辑分段组成,不同的段有不同属性,因此可用分段形式把段分离出来。
分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。
分段的好处是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小的内存空间,称为页(Page)。Linux中,每一页的大小为4KB。
虚拟地址与物理地址之间通过页表来映射。
页表是存储在内存里的,内存管理单元(MMU)负责将虚拟地址转换为内存地址的工作。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内存空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
由于内存空间是预先划分好的,释放都是以页为单位释放的,不会产生无法给进程使用的小内存。
如果内存空间不够,操作系统会把正在运行的进程中“最近没被使用”的内存页释放,暂时写在硬盘上,即换出(Swap out)。一旦需要,再加载进来,即换入(Swap in)。
分页不需要一次性把程序加载到物理内存中,只有在程序运行时,需要用到对应的虚拟内存页里面的指令和数据时,再加载到物理内存中。
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,基地址与页内偏移的组合就成了物理内存地址。
段页式内存管理实现的方式:
地址结构就由段号、段内页和页内位移三部分组成。
用于段页式地址变换的数据结构是每一个程序一张段表,每个段表又建立一张页表,段表中的地址是页表的起始地址,页表中的地址为某页的物理页号。
在Linux操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间。
用户空间内存,从低到高分别是7种不同的内存段:
在7个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用C标准库的malloc()或者mmap(),就可以分别在堆和文件映射段动态分配内存。
CPU可以从一个进程切换到另一个进程,在切换前必须要记录当前进程中运行的状态信息。
在一个进程的活动期间至少具备三种状态:
此外,还有两个基本状态:
在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。
这时,需要用挂起状态,描述进程没有占用实际的物理内存空间的情况。而阻塞状态是等待某个事件的返回。
挂起状态一共有两种:
导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况:
在操作系统中,用进程控制块(Process control block, PCB)数据结构来描述进程的。
PCB是进程存在的唯一标识,具体包含以下信息:
每个PCB通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。
01 创建进程:
02 终止进程:
03 阻塞进程
04 唤醒进程
进程的阻塞和唤醒是一对功能相反的语句。
CPU寄存器(CPU内部的一个容量小,但是速度极快的内存)和程序计数器(存储CPU正在执行的指令位置,或者即将执行的下一条指令位置)是CPU在运行任何任务前,所必须依赖的环境,这种环境就是CPU上下文。
CPU上下文切换分为:进程上下文切换、线程上下文切换和中断上下文切换。
一个进程切换到另一个进程运行,称为进程的上下文切换。进程是由内核管理和调度的,所以进程的上下文切换只能发生在内核态。进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
线程是进程当中的一条执行流程。同一个进程内的多个线程可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,可以确保线程的控制流是相对独立的。
主要有三种线程的实现方式:
用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户态线程库函数直接来完成线程的管理,包括线程的创建、终止、同步和调度等。
内核线程是由操作系统管理的,线程对应的TCB放在操作系统里,线程的创建、终止和管理由操作系统负责。
轻量级进程是内核支持的用户线程,一个线程可以有一个或多个LWP,每个LWP和内核线程是一对一映射的。
01 先来先服务调度算法(First Come First Served, FCFS)
02 最短作业优先调度算法(Short Job First,SJF)
03 高响应比优先调度算法(Highest response ratio next, HRRN)
04 时间片轮转调度算法(Round Robin, RR)
05 最高优先级调度算法(Highest Priority First,HPF)
06 多级反馈调度算法(Multilevel Feedback Queue)
管道比较简单,也很容易得知管道里的数据是否被另一个进程读取了,但是效率低,不适合程序间频繁地交换数据。管道传输的数据是无格式的流且大小受限。
管道实际上是内核里的一串缓存。匿名管道的通信范围是存在于父子关系的进程。命名管道提前创建了一个类型为管道的设备文件,在进程里面只要使用这个设备文件,可以在不想关的进程间通信。不管是匿名管道还是命名管道,进程写入数据都是缓存在内核中,另一个进程读取数据时也是从内核获取。
消息队列是保存在内核中的消息链表,发送数据时会分成独立的消息体(数据块)。
消息队列的通信不及时,附件大小也有限制,不适合比较大数据的传输。
消息队列通信过程中,存在用户态和内核态之间的数据拷贝开销。
将两个进程的虚拟地址映射到相同的物理内存中。
信号量是为了防止多进程竞争共享资源,而造成的数据错乱,使共享的资源在任意时刻只能被一个进程访问的保护机制。
信号量实质上是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号量表示资源的数量,控制信号量的方式有两种原子操作:
信号量初始化为1,代表着互斥信号量,可以保证共享内存在任何时刻只有一个进程在访问。
信号量初始化为0,为同步信号量,实现多进程的同步。
对于异常状态下的工作模式,需要用信号的方式来通知进程。
信号是进程间通信的唯一异步通信机制。
socket通信是跨网络与不同主机上的进程之间通信,也可以在同主机进程间通信。
创建socket的系统调用:
int socket(int domain, int type, int protocal)
三个参数分别代表:
针对TCP协议的socket编程模型
针对UDP协议的socket编程模型
线程是调度的基本单位,进程是资源分配的基本单位。线程之间是可以共享进程的资源,不如代码段、堆空间、数据段、打开的文件等资源,但每个线程都有自己独立的栈空间。
01 锁
分为忙等待锁和无忙等待锁。
忙等待锁,也称为自旋锁,当获取不到锁时,线程会一直等待,不做任何事。
无等待锁在没有或得到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把CPU让给其他线程执行。
02 信号量
信号量是操作系统提供的一种协调共享资源访问的方法。通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。
P操作是用在进入临界区之前,V操作是用在离开临界区之后,两个操作必须成对出现的。
互斥访问有限的竞争问题(如I/O设备)一类的建模过程。
方法1:奇数和偶数编号的哲学家不同顺序拿起左右叉子
方法2:用数组state记录每一个哲学家进程是在思考还是进食,只有在两个邻居都没有进食的情况下,才进入进餐状态。
数据库访问的模型。
读者优先和写者优先策略都会造成饥饿现象,因此实现公平策略:
死锁只有同时满足以下四种条件才会发生:
避免死锁的方法:使用资源有序分配法,破坏环路等待条件。
最底层的两种锁,对于加锁失败后的处理方式不一样:
线程互斥锁加锁失败后,会释放掉CPU,加锁的代码会被阻塞。互斥锁加锁失败而阻塞的现象是由操作系统内核实现的。加锁失败后,内核会将线程置为“睡眠”状态,锁被释放后,内核会在合适的时机唤醒线程。
互斥锁加锁失败后,会从用户态陷入到内核态,让内核帮忙切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本,即两次线程上下文切换的成本。
线程的上下文切换指的是:当两个线程同属一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存的资源保持不动,只需切换线程的私有数据、寄存器等不共享的数据。
自旋锁是通过CPU提供的CAS函数(Compare And Swap),在用户态完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销小一些。
一般加锁的过程包括两个步骤:
CAS把两个步骤合并成一个原子指令。在单核CPU上,需要抢占式的调度器(不断通过时钟中断一个线程,运行其他线程),否则自旋锁在单CPU上无法使用(自旋线程永远不会放弃CPU)。
当加锁失败时,互斥锁用线程切换来应对,自旋锁用忙等待来应对。自旋锁开销小,在多核系统下一般不会产生进程切换,适合异步、协程等在用户态切换请求的编程方法,但如果代码持锁时间过长,自旋的线程会长时间占用CPU。
读写锁适用于能明确区分读操作和写操作的场景。在读多写少的场景有优势。读写锁的工作原理:
互斥锁、自旋锁、读写锁都是悲观锁。悲观锁的多线程同时修改共享资源的概率比较高,很容易出现冲突,因此访问共享资源前,先要上锁。
乐观锁假定冲突的概率很低,先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过资源,就放弃本次操作。乐观锁只有在冲突概率非常低,且加锁成倍非常高的场景时,才考虑用乐观锁。
调度算法影响的是等待时间(进程在就绪队列中等待调度的时间的总和),而不能影响进程真正在使用CPU的时间和I/O时间。
常见的调度算法:
当CPU访问的页面不存在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。与一般中断的主要区别在于:
缺页中断的处理流程:
页表项的字段:
基本思路是置换“未来”最长时间不访问的页面。在实际系统中无法实现,作用是衡量效率。
选择在内存中驻留很长时间的页面进行置换。
选择最长时间没有被访问的页面进行置换。代价很高,要在内存中维护一个所有页面的链表。
把所有的页面都保存在一个类似钟面的“环形链表”中,一个表针指向最老的页面,当发生缺页中断时,算法首先检查表针指向的页面:
当发生缺页中断时,选择“访问次数”最少的那个页面,并将其淘汰。
对每个页面设置一个“访问计数器”,每当一个页面被访问时,该页面的访问计数器就累加1.在发生缺页中断时,淘汰计数器值最小的页面。
磁盘调度算法是为了提高磁盘访问的性能,一般是通过优化磁盘的访问请求顺序来实现的。
优先选择从当前磁头位置所需寻道时间最短的请求。可能会产生饥饿(磁头在一块区域来回移动)。
磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向。
只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最边缘的磁道,即复位磁头。该过程很快,并且返回中途不处理任何请求。磁道只响应一个方向上的请求。
针对SCAN、C-SCAN算法优化,优化思路是磁头在移动到最远的请求位置,然后立即反向移动。
LOOK算法:磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,不需要移动到磁盘的最始端和最末端,反向移动的途中会响应请求。
C-LOOK算法:磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,不需要移动到磁盘的最始端和最末端,反向移动的途中不会响应请求。
文件系统是操作系统中负责管理持久数据的子系统。文件系统的基本数据单位是文件,目的是对磁盘上的文件进行组织管理。
在LInux系统中,“一切皆文件”。不仅普通的文件和目录,块设备、管道、socket等也是统一交给文件系统管理的。
linux文件系统会为每个文件分配两个数据结构:索引节点(index node) 和 目录项(directory entry),主要用来记录文件的元信息和目录层次结构。
文件系统把多个扇区组成一个逻辑块,一次性读写多个扇区。
磁盘进行格式化的时候,会分为三个存储区域,分别是超级块、索引节点区和数据块区。
操作系统在打开文件表中维护着打开文件的状态和信息:
在用户视角里,文件就是一个持久化的数据结构,用户和操作系统对文件的读写操作是有差异的,用户习惯以字节的方式读写文件,而操作系统则是以数据块来读写文件,文件系统负责屏蔽掉这种差异。
读文件和写文件的过程:
文件系统的基本操作单位是数据块。
文件的数据存储在硬盘上的,数据在磁盘上的存放方式有以下两种:
连续空间存放方式
文件存放在磁盘连续的物理空间中。这种模式下,文件的数据都是紧密相连,读写效率很高。文件头里需要指定起始块的位置和长度。会产生磁盘空间碎片,文件长度不易扩展。
非连续空间存放方式
链表的方式存放是离散的,不连续的,可以消除磁盘碎片。分为隐式链表和显式链表。
隐式链表的文件头要包含第一块和最后一块的位置,并且每个数据块里面留出来一个指针空间,用来存放下一个数据块的位置。
无法直接访问数据块,只能通过指针顺序访问文件,以及数据块指针消耗了一定的存储空间。分配的稳定性较差,导致链表中的指针丢失或损坏,会导致文件数据的丢失。
显式链接把用于链表文件各数据的指针,显式地存放在内存的一张链表中,每个表项中存放链接指针,指向下一个数据库。提高了检索速度,大大减小了访问磁盘的次数,但不适用于大磁盘。
索引的实现是为每个文件创建一个索引数据块,里面存放的是指向文件数据块的指针列表。文件头需要包含指向索引数据块的指针。
索引的优点在于:
索引数据也是存储在磁盘,会带来额外的开销。
链式索引块的实现方式是在索引数据块留出一个存放下一个索引数据块的指针。
多级索引块的实现方式是通过一个索引块来存放多个索引数据块。
根据文件的大小,存放的方式会有所变化:
文件头(Inode)需要包含13个指针:
对于小文件使用直接查找的方式可以减少索引数据块的开销;
对于大文件则以多级索引的方式来支持,所以大文件的访问需要大量查询,效率较低。
空闲表法
为所有空闲空间创建一张表,内容包含空闲区的第一个块号和该空闲区的块个数。
请求分配裁判空间和回收空间,都需要顺序扫描空闲表。仅当有少量空闲区时效果好
空闲链表法
每一个空闲块里有一个指针指向下一个空闲块。
只需在主存中保存一个指针,令它指向第一个空闲块。特点是简单,但不能随机访问,效率低。
位图法
利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。Linux文件系统使用位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于inode空闲块的管理。
普通文件的块里保存的是文件数据,而目录文件的块里保存的是目录里面的一项文件信息。
在目录文件的块中,最简单的格式就是列表。
Linux的wxt文件系统采用的是哈希表格式的保存目录,对文件名进行哈希计算,把哈希值保存起来,如果我们要查找一个目录下面的文件名,可以通过名称取哈希。如果哈希能够匹配上,说明该文件的信息在相应的块里。查找非常迅速,插入和删除也比较简单,不过需要避免哈希冲突。
都是比较特殊的文件,在Linux中可以通过软硬链接给某个文件取名。
硬链接是多个目录项中的“索引节点”指向一个文件,不可用于跨文件系统。只有删除文件所有的硬链接以及源文件时,系统才会彻底删除该文件。
软链接相当于重新创建一个文件,该文件有独立的inode,文件的内容是另外一个文件的路径。软链接是可以跨系统的,如果目标文件被删除,软链接仍然存在。
根据“是否利用标准库缓冲”区分:
根据“是否用操作系统的缓存区分”:
以下场景会触发内核缓存的数据写入到磁盘:
阻塞I/O:当用户程序执行read,线程会被阻塞,直到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区,拷贝完成后,read才会返回。
阻塞等待的是“内核数据准备好”和“数据从内核态拷贝到用户态”两个过程。
非阻塞I/O:read请求在为准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read调用才可以获取到结果。
最后一次read调用,获取数据的过程,是一个同步的过程,是需要等待的过程。
都是同步调用。在read调用时,内核将数据从内核空间拷贝到用户空间,过程都是需要等待的。如果内核拷贝效率不高,read调用就会在这个同步过程中等待较长的时间。
真正的异步I/O是内核数据准备好和数据从内核拷贝到用户态都不用等待。
select:
aio_read(异步):
DMA:
DMA的工作方式如下:
设备驱动程序会提供统一的接口给操作系统。设备驱动程序初始化的时候,需要先注册一个该设备的中断处理函数。
Linux通过一个统一的块层,来管理不同的块设备。通用块层是处于文件系统和磁盘驱动中间的一个块设备抽象层,主要有两个功能:
Linux内存支持五种I/O调度算法:
Linux存储系统的I/O由上到下可以分为三个层次,分别是文件系统层、通用块层、设备层。
三个层次的作用是:
网卡是计算机里的一个硬件,专门负责接收和发送网络包,当网卡接收到一个网络包后,会通过DMA技术,将网络包放入到Ring Buffer。
为了解决频繁中断带来的开销,linux内核在2.6版本引入了NAPI机制,首先采用中断唤醒数据接收的服务程序,然后poll的方法来轮询数据。
mmap()代替read(),直接把内核缓冲区里的数据映射到用户空间,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
sendfile
SG - DMA
PageCache(磁盘缓存)
缓存最近被访问的数据;预读功能。但是大文件传输时会不起作用。
在高并发场景下,针对大文件的传输方式,应该选用异步I/O + 直接I/O,代替零拷贝。
服务器首先调用socket函数,创建网络协议为IPv4,以及传输协议为TCP的Socket,接着调用bind()函数,给Socket绑定一个IP地址和端口:
绑定完IP地址和端口后,就可以调用listen()函数进行监听,可以通过netstat命令查看对应端口号是否被监听。
服务器进入监听状态后,通过调用accept()函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。
客户端在创建好Socket后,调用connect()函数发起连接,指定服务端的IP地址和端口号。
在TCP连接的过程中,服务器的内核为每个Socket维护了两个队列:
当TCP全连接队列不为空后,服务端的accept函数就会从内核中的TCP全连接队列中取出一个socket返回应用程序,后续数据传输都用这个socket。
每一个进程都有一个数据结构task_struct,该结构里有一个指向“文件描述符组”的成员指针。该数组里列出这个进程打开文件的所有文件描述符。数组的下标是文件描述符,是一个整数,而数组的内容是一个指针,指向内核中所有打开的文件的列表,也就是说内核可以通过文件描述符找到对应打开的文件。
每个文件都有一个inode,Socket文件的inode指向了内核中的Socket结构,在这个结构体里有两个队列,分别是发送队列和接收队列,在两个队列里保存的是一个个struct sk_buff,用链表的组织形式串起来。
sk_buff可以表示各个层的数据包,在应用层数据包叫data,在TCP层称为segment,在IP层叫做packet,在数据链路层叫做frame。
为每个客户端分配一个进程来处理请求。
服务器的主进程负责监听客户端的连接,一旦与客户端连接完成,accept()函数就会返回一个“已连接Socket”,通过fork()函数创建一个子进程,把父进程的所有相关数据都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
两个进程刚复制完的时候几乎一模一样,会根据返回值来区分是父进程还是子进程。
每一个进程会占用一定系统资源,进程的上下文切换还包含了虚拟内存、栈、全局变量等用户空间资源,以及内核堆栈、寄存器等内核空间资源。
线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,如文件描述符、进程空间、代码、全局数据、堆、共享库等,上下文切换只需切换进程的私有数据、寄存器等不共享的数据。
当服务器与客户端TCP完成连接后,通过pthread_create()函数创建线程,然后将“已连接Socket”的文件描述符传递给线程函数,接着在线程里和客户端进行通信。
进程可以通过一个系统调用函数从内核获取多个事件
select和poll
将已连接的Socket都放到一个文件描述符集合,然后调用select函数将文件描述符集合拷贝到内核里,内核通过遍历文件描述符集合的方式,当检查到有事件产生后,socket标记为可读或可写,再把整个文件描述符集合拷贝回用户态,再通过遍历找到可读或可写socket。select使用固定长度的BitsMap表示文件描述符的集合。
poll通过使用动态数组,以链表形式组织文件描述符。
poll 和 select 都是使用线性结构存储进程关注的Socket集合,因此都需要遍历文件描述符集合来找到可读或可写的socket,也需要在用户态和内核态之间拷贝文件描述符集合。
epoll
在内核中使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的socket通过epoll_ctl()函数加入内核中的红黑树里,每次操作时只需要传入一个待检测的socket。
epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到该就绪事件列表中,当用户调用epoll_wait()函数时,只会返回事件发生的文件描述符的个数。
reactor模式是非阻塞同步网络模式,感知的是就绪可读写的事件,I/O多路复用监听事件,收到事件后,根据事件类型分配给某个进程/线程。
proactor是异步网络模式,感知的是已完成的读写事件。