我们知道计算机由 CPU、存储器、输入 / 输出设备三大核心部分组成,如下
CPU 运行速度很快,在完全理想的状态下,存储器应该要同时具备以下三种特性:
- 速度足够快:这样 CPU 的效率才不会受限于存储器;
- 容量足够大:容量能够存储计算机所需的全部数据;
- 价格足够便宜:价格低廉,所有类型的计算机都能配备;
上图分别为寄存器、高速缓存、主存和磁盘,它们的速度逐级递减、成本逐级递减,在计算机中的容量逐级递增。通常我们所说的物理内存即上文中的主存,常作为操作系统或其他正在运行中的程序的临时资料存储介质。在嵌入式以及一些老的操作系统中,系统直接通过物理寻址方式和主存打交道。然而,随着科技发展,遇到如下窘境:
- 一台机器可能同时运行多台大型应用程序;
- 每个应用程序都需要在主存存储大量临时数据;
- 早期,单个 CPU 寻址能力 2^32,导致内存最大 4G。
以 32 位操作系统为例,虚拟内存的引入,使得操作系统可以为每个进程分配大小为 4GB 的虚拟内存空间,而实际上物理内存在需要时才会被加载,有效解决了物理内存有限空间带来的瓶颈。在虚拟内存到物理内存转换的过程中,有很重要的一步就是进行地址翻译,下面介绍。
(二) 地址翻译
进程在运行期间产生的内存地址都是虚拟地址,如果计算机没有引入虚拟内存这种存储器抽象技术的话,则 CPU 会把这些地址直接发送到内存地址总线上,然后访问和虚拟地址相同值的物理地址;如果使用虚拟内存技术的话,CPU 则是把这些虚拟地址通过地址总线送到内存管理单元(Memory Management Unit,简称 MMU),MMU 将虚拟地址翻译成物理地址之后再通过内存总线去访问物理内存:
虚拟地址(比如 16 位地址 8196=0010 000000000100)分为两部分:虚拟页号(Virtual Page Number,简称 VPN,这里是高 4 位部分)和偏移量(Virtual Page Offset,简称 VPO,这里是低 12 位部分),虚拟地址转换成物理地址是通过页表(page table)来实现的。页表由多个页表项(Page Table Entry, 简称 PTE)组成,一般页表项中都会存储物理页框号、修改位、访问位、保护位和 "在 / 不在" 位(有效位)等信息。这里我们基于一个例子来分析当页面命中时,计算机各个硬件是如何交互的:
- 第 1 步:处理器生成一个虚拟地址 VA,通过总线发送到 MMU;
- 第 2 步:MMU 通过虚拟页号得到页表项的地址 PTEA,通过内存总线从 CPU 高速缓存 / 主存读取这个页表项 PTE;
- 第 3 步:CPU 高速缓存或者主存通过内存总线向 MMU 返回页表项 PTE;
- 第 4 步:MMU 先把页表项中的物理页框号 PPN 复制到寄存器的高三位中,接着把 12 位的偏移量 VPO 复制到寄存器的末 12 位构成 15 位的物理地址,即可以把该寄存器存储的物理内存地址 PA 发送到内存总线,访问高速缓存 / 主存;
- 第 5 步:CPU 高速缓存 / 主存返回该物理地址对应的数据给处理器。
- 第 1 步到第 3 步:和前面的页面命中的前 3 步是一致的;
- 第 4 步:检查返回的页表项 PTE 发现其有效位是 0,则 MMU 触发一次缺页中断异常,然后 CPU 转入到操作系统内核中的缺页中断处理器;
- 第 5 步:缺页中断处理程序检查所需的虚拟地址是否合法,确认合法后系统则检查是否有空闲物理页框号 PPN 可以映射给该缺失的虚拟页面,如果没有空闲页框,则执行页面置换算法寻找一个现有的虚拟页面淘汰,如果该页面已经被修改过,则写回磁盘,更新该页面在磁盘上的副本;
- 第 6 步:缺页中断处理程序从磁盘调入新的页面到内存,更新页表项 PTE;
- 第 7 步:缺页中断程序返回到原先的进程,重新执行引起缺页中断的指令,CPU 将引起缺页中断的虚拟地址重新发送给 MMU,此时该虚拟地址已经有了映射的物理页框号 PPN,因此会按照前面『Page Hit』的流程走一遍,最后主存把请求的数据返回给处理器。
- 高速缓存
如果一台计算机同时配备了虚拟内存技术和 CPU 高速缓存,那么 MMU 每次都会优先尝试到高速缓存中进行寻址,如果缓存命中则会直接返回,只有当缓存不命中之后才去主存寻址。通常来说,大多数系统都会选择利用物理内存地址去访问高速缓存,因为高速缓存相比于主存要小得多,所以使用物理寻址也不会太复杂;另外也因为高速缓存容量很小,所以系统需要尽量在多个进程之间共享数据块,而使用物理地址能够使得多进程同时在高速缓存中存储数据块以及共享来自相同虚拟内存页的数据块变得更加直观。
- 加速翻译 & 优化页表
- 虚拟地址到物理地址的映射过程必须要非常快,地址翻译如何加速;
- 虚拟地址范围的增大必然会导致页表的膨胀,形成大页表。
- TLB 加速
- TLB 命中:
第 1 步:CPU 产生一个虚拟地址 VA;
第 2 步和第 3 步:MMU 从 TLB 中取出对应的 PTE;
第 4 步:MMU 将这个虚拟地址 VA 翻译成一个真实的物理地址 PA,通过地址总线发送到高速缓存 / 主存中去;
第 5 步:高速缓存 / 主存将物理地址 PA 上的数据返回给 CPU。
- TLB 不命中:
第 1 步:CPU 产生一个虚拟地址 VA;
第 2 步至第 4 步:查询 TLB 失败,走正常的主存页表查询流程拿到 PTE,然后把它放入 TLB 缓存,以备下次查询,如果 TLB 此时的存储空间不足,则这个操作会汰换掉 TLB 中另一个已存在的 PTE;
第 5 步:MMU 将这个虚拟地址 VA 翻译成一个真实的物理地址 PA,通过地址总线发送到高速缓存 / 主存中去;
第 6 步:高速缓存 / 主存将物理地址 PA 上的数据返回给 CPU。
- 多级页表
内核空间 & 用户空间
对 32 位操作系统而言,它的寻址空间(虚拟地址空间,或叫线性地址空间)为 4G(2 的 32 次方)。也就是说一个进程的最大地址空间为 4G。操作系统的核心是内核 (kernel),它独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,现在的操作系统一般都强制用户进程不能直接操作内核。具体的实现方式基本都是由操作系统将虚拟地址空间划分为两部分,一部分为内核空间,另一部分为用户空间。针对 Linux 操作系统而言,最高的 1G 字节 (从虚拟地址 0xC0000000 到 0xFFFFFFFF) 由内核使用,称为内核空间。而较低的 3G 字节 (从虚拟地址 0x00000000 到 0xBFFFFFFF) 由各个进程使用,称为用户空间。
为什么需要区分内核空间与用户空间?
在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。所以,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。区分内核空间和用户空间本质上是要提高操作系统的稳定性及可用性。
(一)内核态与用户态
当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。 在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段 (TSS) 中 I/O 许可位图 (I/O Permission Bitmap) 中规定的可访问端口进行直接访问。
对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码 (操作系统的代码要比应用程序的代码健壮很多) 与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行。
如何从用户空间进入内核空间?
其实所有的系统资源管理都是在内核空间中完成的。比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。我们的应用程序是无法直接进行这样的操作的。但是我们可以通过内核提供的接口来完成这样的任务。比如应用程序要读取磁盘上的一个文件,它可以向内核发起一个 “系统调用” 告诉内核:“我要读取磁盘上的某某文件”。其实就是通过一个特殊的指令让进程从用户态进入到内核态 (到了内核空间),在内核空间中,CPU 可以执行任何的指令,当然也包括从磁盘上读取数据。具体过程是先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。此时应用程序已经从系统调用中返回并且拿到了想要的数据,可以开开心心的往下执行了。简单说就是应用程序把高科技的事情 (从磁盘读取文件) 外包给了系统内核,系统内核做这些事情既专业又高效。
IO
在进行 IO 操作时,通常需要经过如下两个阶段
- 数据准备阶段:数据从硬件到内核空间
- 数据拷贝阶段:数据从内核空间到用户空间
通常我们所说的 IO 的阻塞 / 非阻塞以及同步 / 异步,和这两个阶段关系密切:
- 阻塞 IO 和非阻塞 IO 判定标准:数据准备阶段,应用程序是否阻塞等待操作系统将数据从硬件加载到内核空间;
- 同步 IO 和异步 IO 判定标准:数据拷贝阶段,数据是否备好后直接通知应用程序使用,无需等待拷贝;
阻塞 IO :当用户发生了系统调用后,如果数据未从网卡到达内核态,内核态数据未准备好,此时会一直阻塞。直到数据就绪,然后从内核态拷贝到用户态再返回。
阻塞 IO 每个连接一个单独的线程进行处理,通常搭配多线程来应对大流量,但是,开辟线程的开销比较大,一个程序可以开辟的线程是有限的,面对百万连接的情况,是无法处理。非阻塞 IO 可以一定程度上解决上述问题。
(二)(同步) 非阻塞 IO
非阻塞 IO :在第一阶段 (网卡 - 内核态) 数据未到达时不等待,然后直接返回。数据就绪后,从内核态拷贝到用户态再返回。
非阻塞 IO 解决了阻塞 IO 每个连接一个线程处理的问题,所以其最大的优点就是 一个线程可以处理多个连接。然而,非阻塞 IO 需要用户多次发起系统调用。频繁的系统调用是比较消耗系统资源的。
(三)IO 多路复用
为了解决非阻塞 IO 存在的频繁的系统调用这个问题,随着内核的发展,出现了 IO 多路复用模型。
IO 多路复用:通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,就可以返回。
IO 多路复用本质上复用了系统调用,使多个文件的状态可以复用一个系统调用获取,有效减少了系统调用。select、poll、epoll 均是基于 IO 多路复用思想实现的。
select 和 poll 的工作原理比较相似,通过 select () 或者 poll () 将多个 socket fds 批量通过系统调用传递给内核,由内核进行循环遍历判断哪些 fd 上数据就绪了,然后将就绪的 readyfds 返回给用户。再由用户进行挨个遍历就绪好的 fd,读取或者写入数据。所以通过 IO 多路复用 + 非阻塞 IO,一方面降低了系统调用次数,另一方面可以用极少的线程来处理多个网络连接。select 和 poll 的最大区别是:select 默认能处理的最大连接是 1024 个,可以通过修改配置来改变,但终究是有限个;而 poll 理论上可以支持无限个。而 select 和 poll 则面临相似的问题在管理海量的连接时,会频繁地从用户态拷贝到内核态,比较消耗资源。
epoll 有效规避了将 fd 频繁的从用户态拷贝到内核态,通过使用红黑树 (RB-tree) 搜索被监视的文件描述符 (file descriptor)。在 epoll 实例上注册事件时,epoll 会将该事件添加到 epoll 实例的红黑树上并注册一个回调函数,当事件发生时会将事件添加到就绪链表中。
- epoll 数据结构 + 算法
- 监视 socket 索引 - 红黑树
- 因为链表在查询,删除的时候毫无疑问时间复杂度是 O (n);
- 数组查询很快,但是删除和新增时间复杂度是 O (n);
- 二叉搜索树虽然查询效率是 lgn,但是如果不是平衡的,那么就会退化为线性查找,复杂度直接来到 O (n);
- B + 树是平衡多路查找树,主要是通过降低树的高度来存储上亿级别的数据,但是它的应用场景是内存放不下的时候能够用最少的 IO 访问次数从磁盘获取数据。比如数据库聚簇索引,成百上千万的数据内存无法满足查找就需要到内存查找,而因为 B + 树层高很低,只需要几次磁盘 IO 就能获取数据到内存,所以在这种磁盘到内存访问上 B + 树更适合。
- 就绪 socket 列表 - 双向链表
- 三个 API
- int epoll_create(int size)
- int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
- int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 工作模式
- LT 模式
- ET 模式
(四)网络 IO 模型
实际的网络模型常结合 I/O 复用和线程池实现,如 Reactor 模式:
- 单 reactor 单线程模型
- 优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成
- 缺点:单线程无法完全发挥多核 CPU 的性能;I/O 操作和非 I/O 的业务操作在一个 Reactor 线程完成,这可能会大大延迟 I/O 请求的响应;线程意外终止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障;
- 使用场景:客户端的数量有限,业务处理非常快速,比如 Redis 在业务处理的时间复杂度 O (1) 的情况
- 单 reactor 多线程模型
- 优点:充分利用多核 cpu 的处理能力,提升 I/O 响应速度;
- 缺点:在该模式中,虽然非 I/O 操作交给了线程池来处理,但是所有的 I/O 操作依然由 Reactor 单线程执行,在高负载、高并发或大数据量的应用场景,依然容易成为瓶颈。
- multi-reactor 多线程模型
- 优点:父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据。
- 缺点:编程复杂度较高。
- 主流的中间件所采用的网络模型
(五)异步 IO
前面介绍的所有网络 IO 都是同步 IO,因为当数据在内核态就绪时,在内核态拷贝用户态的过程中,仍然会有短暂时间的阻塞等待。而异步 IO 指:内核态拷贝数据到用户态这种方式也是交给系统线程来实现,不由用户线程完成,如 windows 的 IOCP ,Linux 的 AIO。
零拷贝
(一)传统 IO 流程
传统 IO 流程会经过如下两个过程:
- 数据准备阶段:数据从硬件到内核空间
- 数据拷贝阶段:数据从内核空间到用户空间
零拷贝:指数据无需从硬件到内核空间或从内核空间到用户空间。下面介绍常见的零拷贝实现
(二)mmap + write mmap 将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
(三)sendfile
通过 sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝,sendfile 调用中 I/O 数据对用户空间是完全不可见的,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
(四) Sendfile + DMA gather copy Linux2.4 引入 ,将内核空间的读缓冲区(read buffer)中对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区(socketbuffer)中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡设备中,这样就省去了内核空间中仅剩的 1 次 CPU 拷贝操作,发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝;
(五)splice Linux2.6.17 版本引入,在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作,2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝。
(六)写时复制
通过尽量延迟产生私有对象中的副本,写时复制最充分地利用了稀有的物理资源。
(七)Java 中零拷贝 MappedByteBuffer:基于内存映射(mmap)这种零拷贝方式的提供的一种实现。
FileChannel 基于 sendfile 定义了 transferFrom () 和 transferTo () 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。
参考 https://mp.weixin.qq.com/s/c81Fvws0J2tHjcdTgxvv6g https://blog.csdn.net/Chasing__Dreams/article/details/106297351
https://mp.weixin.qq.com/s/EDzFOo3gcivOe_RgipkTkQ
https://mp.weixin.qq.com/s/G6TfGbc4U8Zhv30wnN0HIg
https://www.modb.pro/db/189656
https://mp.weixin.qq.com/s/r9RU4RoE-qrzXPAwiej5sw
本文转载自:http://navo.top/BRV3qq