本文主要是讲解本人对操作系统部分核心功能的理解(后续有时间得系统性优化)
1. 操作系统的演进
- 只是一些库,由“OS”编写低级IO的处理代码。
- 超越库:新增保护能力;提供系统调用(系统调用将控制跳转到OS中,同时提高硬件特权级别,用户程序以用户模式运行),能够限制应用程序的功能,保护系统安全。
- 为了更好的利用系统资源,多任务运行需要解决并发问题,以及应用程序之间的内存保护问题。
2. 虚拟化
虚拟化需要遵循隔离原则,即如果两个实体相互隔离,这意味着一个实体的失败不会影响另一个实体。操作系统力求让进程彼此隔离,从而防止相互造成伤害。
2.1 虚拟化CPU
虚拟化CPU的机制:
1)受限直接执行,既用户模式和内核模式的区分。访问文件系统、创建销毁进程以及与其他进程通信等都需要内核模式才有权限调用。用户模式需要执行系统调用,则必须执行特殊的现陷阱(trap)指令。
2)时分共享,即时间片轮转。衍生出的问题:如何高效的任务调度。
内核如何重获CPU资源用于系统资源分配:
协同:需要在应用程序执行期间,自己释放CPU让内核重新掌握CPU资源,用于系统资源的调度。或者应用程序执行非法的操作,陷入(trap)操作系统。
时钟中断(time interrupt):每几毫秒进行一次中断,使内核重获CPU资源。
2.2 隔离的体现
进程:必须遵守隔离原则,即每个进程都有自己资源空间,因此,进程是资源调度的最小单位。
线程:线程依附于进程而存在,线程共享进程的资源。因为单CPU同一时间只能执行一个任务,因此线程是CPU调度的最小单位。
2.3 CPU任务调度
- 先进先出(FIFO):解释略。
- 最短任务优先(SJF):按任务执行时间分配。如果非同时到达,执行最长的任务期间来了更短的任务,也需要等待长任务的执行完毕。
- 最短完成时间优先(STCF):最短任务优先中添加抢断机制,每当新工作到来,判断当前任务和新工作的时间,如果当前任务高于新任务,则新任务抢占运行。会引发第一个长任务在持续被抢占后,响应时间超长的问题。
- 时间片轮转:多个任务在不同的时间片中交替执行。如果任务存在I/O操作,可在其他任务占用CPU资源的时候发起I/O请求,提高资源的利用率。时间片的定义是一个比较困难的操作,短了上下文切换成本大,长了响应时间不理想。
- 多级反馈队列(MLFQ):可单独成章。
2.4 fork和exec的区别
fork:fork会有两次返回,分别为:父进程的返回,返回为子进程的pid;子进程的返回,返回值为0。可以通过返回值的不同,分别在子进程和父进程执行相应的逻辑。fork相当于拷贝了一份父进程的资源数据。
exec:exec() 会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。然后操作系统就执行该程序,将参数通过 argv 传递给该进程。因此,它并谁有创建新进程,而是直接将当前运行的程序替换为我同的运行程序。子进程执行 exec() 之后,几乎就像从未运行过一样。对 exec() 的成功调用永远不会返回。
说人话就是 fork 是 copy 一份自己的资源给子进程然后两个自己干自己的活,exec 是直接在原有的进程替换成其他的执行程序。
fork和exec结合的范例:shell 命令重定向。
wc test.txt >>> result.txt
执行流程:shell 在调用 exec() 之前先关闭了标准输出(standard output),打开了文件result.txt。
这样,即将运行的程序 wc 的输出结果就被发送到该文件,而不是打印在屏幕上。
2.5 虚拟化内存
虚拟内存系统主要目标:透明(transparency)、效率(efficiency)、保护(protection)。
- 透明:程序感知不到虚拟内存的存在,就好像拥有自己的私有的物理内存。
- 效率:追求时间上和空间上的高效。
- 保护:进程之间不会相互影响,操作系统不受进程影响。
3. sendfile(零拷贝)和 mmap(内存映射)
3.1 传统IO
- 发出read系统调用:导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将文件中的数据从磁盘上读取到内核空间缓冲区(第一次拷贝: hard drive ——> kernel buffer)。
- 将内核空间缓冲区的数据拷贝到用户空间缓冲区(第二次拷贝: kernel buffer ——> user buffer),然后read系统调用返回。而系统调用的返回又会导致一次内核空间到用户空间的上下文切换(第二次上下文切换)。
- 发出write系统调用:导致用户空间到内核空间的上下文切换(第三次上下文切换)。将用户空间缓冲区中的数据拷贝到内核空间中与socket相关联的缓冲区中(即,第②步中从内核空间缓冲区拷贝而来的数据原封不动的再次拷贝到内核空间的socket缓冲区中。)(第三次拷贝: user buffer ——> socket buffer)。
- write系统调用返回,导致内核空间到用户空间的再次上下文切换(第四次上下文切换)。通过DMA引擎将内核缓冲区中的数据传递到协议引擎(第四次拷贝: socket buffer ——> protocol engine),这次拷贝是一个独立且异步的过程。
3.2 sendfile(零拷贝)
旧版本:
- 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。然后再将数据从内核空间缓冲区拷贝到内核中与socket相关的缓冲区中(第二次拷贝: kernel buffer ——> socket buffer)。
- sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)
带DMA(直接内存访问)版本:
- 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。
- 没有数据拷贝到socket缓冲区。取而代之的是只有相应的描述符信息会被拷贝到相应的socket缓冲区当中。该描述符包含了两方面的信息:a)kernel buffer的内存地址;b)kernel buffer的偏移量。
- sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。DMA gather copy根据socket缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝: kernel buffer ——> protocol engine),这样就避免了最后一次CPU数据拷贝。
总的来说,带有DMA收集拷贝功能的sendfile实现的I/O只使用了2次用户空间与内核空间的上下文切换,以及2次数据的拷贝,而且这2次的数据拷贝都是非CPU拷贝。这样一来我们就实现了最理想的零拷贝I/O传输了,不需要任何一次的CPU拷贝,以及最少的上下文切换。
3.3 mmap(内存映射)
- 发出mmap系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。
- mmap系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。接着用户空间和内核空间共享这个缓冲区,而不需要将数据从内核空间拷贝到用户空间。因为用户空间和内核空间共享了这个缓冲区数据,所以用户空间就可以像在操作自己缓冲区中数据一般操作这个由内核空间共享的缓冲区数据。
- 发出write系统调用,导致用户空间到内核空间的上下文切换(第三次上下文切换)。将数据从内核空间缓冲区拷贝到内核空间socket相关联的缓冲区(第二次拷贝: kernel buffer ——> socket buffer)。
- write系统调用返回,导致内核空间到用户空间的上下文切换(第四次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)
总的来说,通过mmap实现的零拷贝I/O进行了4次用户空间与内核空间的上下文切换,以及3次数据拷贝。其中3次数据拷贝中包括了2次DMA拷贝和1次CPU拷贝。
3.4 sendfile or mmap?
其实从流程上来看,sendfile 在数据的复制上是要优于 mmap。因此,如果是单纯的文件复制功能,使用 sendfile 是一个明智的选择。但是还有一种场景,那就是应用需要对原始数据进行写入或修改。这个时候,使用 mmap 将数据在用户态和内核态映射后,任何的写入都不需要再经过用户态和内核态的切换,从而达到高效的读写能力。因此,kafka 和 rocketMQ 都采用 mmap 来实现高性能的顺序写入。
4 水平触发和边缘触发
4.1 水平触发(level trigger)
对于读操作
只要缓冲内容不为空,LT模式返回读就绪。对于写操作
只要缓冲区还不满,LT模式会返回写就绪。
4.2 边缘触发(edge trigger)
对于读操作
(1)当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
(2)当有新数据到达时,即缓冲区中的待读数据变多的时候。
(3)当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。对于写操作
(1)当缓冲区由不可写变为可写时。
(2)当有旧数据被发送走,即缓冲区中的内容变少的时候。
(3)当缓冲区有空间可写,且应用进程对相应的描述符进行 EPOLL_CTL_MOD 修改 EPOLLOUT 事件时。