支持百万并发的“零拷贝”手艺,你领会吗?

Flutter即学即用环境搭建

前言工欲善其事,必先利其器所以第一篇我们来说说Flutter环境的搭建。笔者这边使用的是MAC电脑,因此以MAC电脑的环境搭建为例。Windows或者Linux也是类似的操作。Flutter有英文版的官网和中文网,大家可以根据自己的喜好和情况进行选择。点 前言 工欲善其事,必先利其器 所以第一篇我们来说说Flutter环境的搭建。 笔者这边使用的是MAC电脑,因此以MAC电脑的环境搭建为例。 Wi...

零拷贝(Zero-copy)手艺指在盘算机执行操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域,从而可以削减上下文切换以及 CPU 的拷贝时间。 它的作用是在数据报从网络装备到用户程序空间转达的历程中,削减数据拷贝次数,削减系统挪用,实现 CPU

零拷贝(Zero-copy)手艺指在盘算机执行操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域,从而可以削减上下文切换以及 CPU 的拷贝时间。

它的作用是在数据报从网络装备到用户程序空间转达的历程中,削减数据拷贝次数,削减系统挪用,实现 CPU 的零介入,彻底消除 CPU 在这方面的负载。

实现零拷贝用到的最主要手艺是 DMA 数据传输手艺和内存区域映射手艺:

零拷贝机制可以削减数据在内核缓冲区和用户历程缓冲区之间频频的 I/O 拷贝操作。

零拷贝机制可以削减用户历程地址空间和内核地址空间之间由于上下文切换而带来的 CPU 开销。

物理内存和虚拟内存

由于操作系统的历程与历程之间是共享 CPU 和内存资源的,因此需要一套完善的内存治理机制防止历程之间内存泄露的问题。

为了加倍有用地治理内存并削减失足,现代操作系统提供了一种对主存的抽象看法,即虚拟内存(Virtual Memory)。

虚拟内存为每个历程提供了一个一致的、私有的地址空间,它让每个历程发生了一种自己在独享主存的错觉(每个历程拥有一片延续完整的内存空间)。

物理内存

物理内存(Physical Memory)是相对于虚拟内存(Virtual Memory)而言的。

物理内存指通过物理内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来作为内存。内存主要作用是在盘算机运行时为操作系统和种种程序提供暂且储存。

在应用中,自然是顾名思义,物理上,真实存在的插在主板内存槽上的内存条的容量的巨细。

虚拟内存

虚拟内存是盘算机系统内存治理的一种手艺。它使得应用程序以为它拥有延续的可用的内存(一个延续完整的地址空间)。

而现实上,虚拟内存通常是被脱离成多个物理内存碎片,另有部门暂时存储在外部磁盘存储器上,在需要时举行数据交流,加载到物理内存中来。

现在,大多数操作系统都使用了虚拟内存,如 Windows 系统的虚拟内存、Linux 系统的交流空间等等。

虚拟内存地址和用户历程慎密相关,一样平常来说差异历程里的统一个虚拟地址指向的物理地址是纷歧样的,以是脱离历程谈虚拟内存没有任何意义。每个历程所能使用的虚拟地址巨细和 CPU 位数有关。

在 32 位的系统上,虚拟地址空间巨细是 2^32=4G,在 64 位系统上,虚拟地址空间巨细是 2^64=16G,而现实的物理内存可能远远小于虚拟内存的巨细。

每个用户历程维护了一个单独的页表(Page Table),虚拟内存和物理内存就是通过这个页表实现地址空间的映射的。

下面给出两个历程 A、B 各自的虚拟内存空间以及对应的物理内存之间的地址映射示意图:

当历程执行一个程序时,需要先从内存中读取该历程的指令,然后执行,获取指令时用到的就是虚拟地址。

这个虚拟地址是程序链接时确定的(内核加载并初始化历程时会调整动态库的地址局限)。

为了获取到现实的数据,CPU 需要将虚拟地址转换成物理地址,CPU 转换地址时需要用到历程的页表(Page Table),而页表(Page Table)内里的数据由操作系统维护。

其中页表(Page Table)可以简朴的明白为单个内存映射(Memory Mapping)的链表(固然现实结构很庞大)。

内里的每个内存映射(Memory Mapping)都将一块虚拟地址映射到一个特定的地址空间(物理内存或者磁盘存储空间)。

每个历程拥有自己的页表(Page Table),和其他历程的页表(Page Table)没有关系。

通过上面的先容,我们可以简朴的将用户历程申请并接见物理内存(或磁盘存储空间)的历程总结如下:

用户历程向操作系统发出内存申请请求。

系统会检查历程的虚拟地址空间是否被用完,若是有剩余,给历程分配虚拟地址。

系统为这块虚拟地址确立内存映射(Memory Mapping),并将它放进该历程的页表(Page Table)

系统返回虚拟地址给用户历程,用户历程最先接见该虚拟地址。

CPU 凭证虚拟地址在此历程的页表(Page Table)中找到了响应的内存映射(Memory Mapping),然则这个内存映射(Memory Mapping)没有和物理内存关联,于是发生缺页中止。

操作系统收到缺页中止后,分配真正的物理内存并将它关联到页表响应的内存映射(Memory Mapping)。中止处置完成后,CPU 就可以接见内存了

固然缺页中止不是每次都市发生,只有系统以为有需要延迟分配内存的时刻才用的着,也即许多时刻在上面的第 3 步系统会分配真正的物理内存并和内存映射(Memory Mapping)举行关联。

在用户历程和物理内存(磁盘存储器)之间引入虚拟内存主要有以下的优点:

地址空间:提供更大的地址空间,而且地址空间是延续的,使得程序编写、链接加倍简朴。

历程隔离:差异历程的虚拟地址之间没有关系,以是一个历程的操作不会对其他历程造成影响。

数据珍爱:每块虚拟内存都有响应的读写属性,这样就能珍爱程序的代码段不被修改,数据块不能被执行等,增添了系统的平安性。

内存映射:有了虚拟内存之后,可以直接映射磁盘上的文件(可执行文件或动态库)到虚拟地址空间。

这样可以做到物理内存延时分配,只有在需要读响应的文件的时刻,才将它真正的从磁盘上加载到内存中来,而在内存吃紧的时刻又可以将这部门内存清空掉,提高物理内存行使效率,而且所有这些对应用程序都是透明的。

共享内存:好比动态库只需要在内存中存储一份,然后将它映射到差异历程的虚拟地址空间中,让历程以为自己独占了这个文件。

历程间的内存共享也可以通过映射统一块物理内存到历程的差异虚拟地址空间来实现共享。

物理内存治理:物理地址空间所有由操作系统治理,历程无法直接分配和接纳,从而系统可以更好的行使内存,平衡历程间对内存的需求。

内核空间和用户空间

操作系统的焦点是内核,自力于通俗的应用程序,可以接见受珍爱的内存空间,也有接见底层硬件装备的权限。

为了阻止用户历程直接操作内核,保证内核平安,操作系统将虚拟内存划分为两部门,一部门是内核空间(Kernel-space),一部门是用户空间(User-space)。

在 Linux 系统中,内核模块运行在内核空间,对应的历程处于内核态;而用户程序运行在用户空间,对应的历程处于用户态。

内核历程和用户历程所占的虚拟内存比例是 1:3,而 Linux x86_32 系统的寻址空间(虚拟存储空间)为 4G(2 的 32 次方),将最高的 1G 的字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)供内核历程使用,称为内核空间。

而较低的 3G 的字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个用户历程使用,称为用户空间。

下图是一个历程的用户空间和内核空间的内存结构:

内核空间

内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域举行读写或直接挪用内核代码界说的函数的。

上图左侧区域为内核历程对应的虚拟内存,按接见权限可以分为历程私有和历程共享两块区域:

历程私有的虚拟内存:每个历程都有单独的内核栈、页表、task 结构以及 mem_map 结构等。

历程共享的虚拟内存:属于所有历程共享的内存区域,包罗物理存储器、内核数据和内核代码区域。

用户空间

每个通俗的用户历程都有一个单独的用户空间,处于用户态的历程不能接见内核空间中的数据,也不能直接挪用内核函数的 ,因此要举行系统挪用的时刻,就要将历程切换到内核态才行。

用户空间包罗以下几个内存区域:

运行时栈:由编译器自动释放,存放函数的参数值,局部变量和方式返回值等。每当一个函数被挪用时,该函数的返回类型和一些挪用的信息被存储到栈顶,挪用竣事后挪用信息会被弹出并释放掉内存。

栈区是从高地址位向低地址位增进的,是一块延续的内在区域,最大容量是由系统预先界说好的,申请的栈空间跨越这个界线时会提醒溢出,用户能从栈中获取的空间较小。

运行时堆:用于存放历程运行中被动态分配的内存段,位于 BSS 和栈中央的地址位。由卡发职员申请分配(malloc)和释放(free)。堆是从低地址位向高地址位增进,接纳链式存储结构。

频仍地 malloc/free 造成内存空间的不延续,发生大量碎片。当申请堆空间时,库函数根据一定的算法搜索可用的足够大的空间。因此堆的效率比栈要低的多。

代码段:存放 CPU 可以执行的机械指令,该部门内存只能读不能写。通常代码区是共享的,即其他执行程序可挪用它。若是机械中有数个历程运行相同的一个程序,那么它们就可以使用统一个代码段。

未初始化的数据段:存放未初始化的全局变量,BSS 的数据在程序最先执行之前被初始化为 0 或 NULL。

已初始化的数据段:存放已初始化的全局变量,包罗静态全局变量、静态局部变量以及常量。

内存映射区域:例如将动态库,共享内存等虚拟空间的内存映射到物理空间的内存,一样平常是 mmap 函数所分配的虚拟内存空间。

Linux 的内部层级结构

内核态可以执行随便下令,挪用系统的一切资源,而用户态只能执行简朴的运算,不能直接挪用系统资源。用户态必须通过系统接口(System Call),才气向内核发出指令。

好比,当用户历程启动一个 bash 时,它会通过 getpid() 对内核的 pid 服务提议系统挪用,获取当前用户历程的 ID。

当用户历程通过 cat 下令查看主机设置时,它会对内核的文件子系统提议系统挪用:

内核空间可以接见所有的 CPU 指令和所有的内存空间、I/O 空间和硬件装备。

用户空间只能接见受限的资源,若是需要特殊权限,可以通过系统挪用获取响应的资源。

用户空间允许页面中止,而内核空间则不允许。

内核空间和用户空间是针对线性地址空间的。

x86 CPU 中用户空间是 0-3G 的地址局限,内核空间是 3G-4G 的地址局限。

x86_64 CPU 用户空间地址局限为0x0000000000000000–0x00007fffffffffff,内核地址空间为 0xffff880000000000-最大地址。

所有内核历程(线程)共用一个地址空间,而用户历程都有各自的地址空间。

有了用户空间和内核空间的划分后,Linux 内部层级结构可以分为三部门,从最底层到最上层依次是硬件、内核空间和用户空间,如下图所示:

Linux I/O 读写方式

Linux 提供了轮询、I/O 中止以及 DMA 传输这 3 种磁盘与主存之间的数据传输机制。其中轮询方式是基于死循环对 I/O 端口举行不停检测。

I/O 中止方式是指当数据到达时,磁盘自动向 CPU 提议中止请求,由 CPU 自身认真数据的传输历程。

DMA 传输则在 I/O 中止的基础上引入了 DMA 磁盘控制器,由 DMA 磁盘控制器认真数据的传输,降低了 I/O 中止操作对 CPU 资源的大量消耗。

I/O 中止原理

在 DMA 手艺泛起之前,应用程序与磁盘之间的 I/O 操作都是通过 CPU 的中止完成的。

每次用户历程读取磁盘数据时,都需要 CPU 中止,然后提议 I/O 请求守候数据读取和拷贝完成,每次的 I/O 中止都导致 CPU 的上下文切换:

用户历程向 CPU 提议 read 系统挪用读取数据,由用户态切换为内核态,然后一直壅闭守候数据的返回。

CPU 在吸收到指令以后对磁盘提议 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区。

数据准备完成以后,磁盘向 CPU 提议 I/O 中止。

CPU 收到 I/O 中止以后将磁盘缓冲区中的数据拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区。

用户历程由内核态切换回用户态,排除壅闭状态,然后守候 CPU 的下一个执行时间钟。

DMA 传输原理

DMA 的全称叫直接内存存取(Direct Memory Access),是一种允许外围装备(硬件子系统)直接接见系统主内存的机制。

也就是说,基于 DMA 接见方式,系统主内存于硬盘或网卡之间的数据传输可以绕开 CPU 的全程调剂。

现在大多数的硬件装备,包罗磁盘控制器、网卡、显卡以及声卡等都支持 DMA 手艺。

整个数据传输操作在一个 DMA 控制器的控制下举行的。CPU 除了在数据传输最先和竣事时做一点处置外(最先和竣事时刻要做中止处置),在传输历程中 CPU 可以继续举行其他的事情。

这样在大部门时间里,CPU 盘算和 I/O 操作都处于并行操作,使整个盘算机系统的效率大大提高。

有了 DMA 磁盘控制器接受数据读写请求以后,CPU 从繁重的 I/O 操作中解脱,数据读取操作的流程如下:

用户历程向 CPU 提议 read 系统挪用读取数据,由用户态切换为内核态,然后一直壅闭守候数据的返回。

CPU 在吸收到指令以后对 DMA 磁盘控制器提议调剂指令。

DMA 磁盘控制器对磁盘提议 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区,CPU 全程不介入此历程。

数据读取完成后,DMA 磁盘控制器会接受到磁盘的通知,将数据从磁盘控制器缓冲区拷贝到内核缓冲区。

DMA 磁盘控制器向 CPU 发出数据读完的信号,由 CPU 认真将数据从内核缓冲区拷贝到用户缓冲区。

用户历程由内核态切换回用户态,排除壅闭状态,然后守候 CPU 的下一个执行时间钟。

传统 I/O 方式

为了更好的明白零拷贝解决的问题,我们首先领会一下传统 I/O 方式存在的问题。

在 Linux 系统中,传统的接见方式是通过 write() 和 read() 两个系统挪用实现的,通过 read() 函数读取文件到到缓存区中,然后通过 write() 方式把缓存中的数据输出到网络端口。

伪代码如下:

read(file_fd, tmp_buf, len);write(socket_fd, tmp_buf, len);

下图划分对应传统 I/O 操作的数据读写流程,整个历程涉及 2 次 CPU 拷贝、2 次 DMA 拷贝,总共 4 次拷贝,以及 4 次上下文切换。

下面简朴地论述一下相关的看法:

上下文切换:当用户程序向内核提议系统挪用时,CPU 将用户历程从用户态切换到内核态;当系统挪用返回时,CPU 将用户历程从内核态切换回用户态。

CPU 拷贝:由 CPU 直接处置数据的传送,数据拷贝时会一直占用 CPU 的资源。

DMA 拷贝:由 CPU 向DMA磁盘控制器下达指令,让 DMA 控制器来处置数据的传送,数据传送完毕再把信息反馈给 CPU,从而减轻了 CPU 资源的占有率。

传统读操作

当应用程序执行 read 系统挪用读取一块数据的时刻,若是这块数据已经存在于用户历程的页内存中,就直接从内存中读取数据。

若是数据不存在,则先将数据从磁盘加载数据到内核空间的读缓存(read buffer)中,再从读缓存拷贝到用户历程的页内存中。

read(file_fd, tmp_buf, len);

基于传统的 I/O 读取方式,read 系统挪用会触发 2 次上下文切换,1 次 DMA 拷贝和 1 次 CPU 拷贝。

提议数据读取的流程如下:

用户历程通过 read() 函数向内核(kernel)提议系统挪用,上下文从用户态(user space)切换为内核态(kernel space)。

CPU 行使 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。

CPU 将读缓冲区(read buffer)中的数据拷贝到用户空间(user space)的用户缓冲区(user buffer)。

上下文从内核态(kernel space)切换回用户态(user space),read 挪用执行返回。

传统写操作

当应用程序准备好数据,执行 write 系统挪用发送网络数据时,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(socket buffer)中,然后再将写缓存中的数据拷贝到网卡装备完成数据发送。

write(socket_fd, tmp_buf, len);

基于传统的 I/O 写入方式,write() 系统挪用会触发 2 次上下文切换,1 次 CPU 拷贝和 1 次 DMA 拷贝。

用户程序发送网络数据的流程如下:

用户历程通过 write() 函数向内核(kernel)提议系统挪用,上下文从用户态(user space)切换为内核态(kernel space)。

CPU 将用户缓冲区(user buffer)中的数据拷贝到内核空间(kernel space)的网络缓冲区(socket buffer)。

CPU 行使 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡举行数据传输。

上下文从内核态(kernel space)切换回用户态(user space),write 系统挪用执行返回。

零拷贝方式

在 Linux 中零拷贝手艺主要有 3 个实现思绪:

用户态直接 I/O:应用程序可以直接接见硬件存储,操作系统内核只是辅助数据传输。

这种方式依旧存在用户空间和内核空间的上下文切换,硬件上的数据直接拷贝至了用户空间,不经由内核空间。因此,直接 I/O 不存在内核空间缓冲区和用户空间缓冲区之间的数据拷贝。

削减数据拷贝次数:在数据传输历程中,阻止数据在用户空间缓冲区和系统内核空间缓冲区之间的 CPU 拷贝,以及数据在系统内核空间内的 CPU 拷贝,这也是当前主流零拷贝手艺的实现思绪。

写时复制手艺:写时复制指的是当多个历程共享统一块数据时,若是其中一个历程需要对这份数据举行修改,那么将其拷贝到自己的历程地址空间中,若是只是数据读取操作则不需要举行拷贝操作。

用户态直接 I/O

用户态直接 I/O 使得应用历程或运行在用户态(user space)下的库函数直接接见硬件装备。

数据直接跨过内核举行传输,内核在数据传输历程除了举行需要的虚拟存储设置事情之外,不介入任何其他事情,这种方式能够直接绕过内核,极大提高了性能。

用户态直接 I/O 只能适用于不需要内核缓冲区处置的应用程序,这些应用程序通常在历程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库治理系统就是一个代表。

其次,这种零拷贝机制会直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成大量资源的虚耗,解决方案是配合异步 I/O 使用。

mmap+write

一种零拷贝方式是使用 mmap+write 取代原来的 read+write 方式,削减了 1 次 CPU 拷贝操作。

mmap 是 Linux 提供的一种内存映射文件方式,即将一个历程的地址空间中的一段虚拟地址映射到磁盘文件地址,mmap+write 的伪代码如下:

tmp_buf = mmap(file_fd, len);write(socket_fd, tmp_buf, len);

使用 mmap 的目的是将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)举行映射。

从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的历程。

然而内核读缓冲区(read buffer)仍需将数据拷贝到内核写缓冲区(socket buffer),大致的流程如下图所示:

基于 mmap+write 系统挪用的零拷贝方式,整个拷贝历程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。

用户程序读写数据的流程如下:

[网页特效]纯css3云彩动画效果

效果描述: 纯CSS3实现的云彩动画飘动效果 非常逼真实用 使用方法: 1、将body中的代码部分拷贝到你的页面中 2、引入对应的CSS文件即可

用户历程通过 mmap() 函数向内核(kernel)提议系统挪用,上下文从用户态(user space)切换为内核态(kernel space)。

将用户历程的内核空间的读缓冲区(read buffer)与用户空间的缓存区(user buffer)举行内存地址映射。

CPU 行使 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。

上下文从内核态(kernel space)切换回用户态(user space),mmap 系统挪用执行返回。

用户历程通过 write() 函数向内核(kernel)提议系统挪用,上下文从用户态(user space)切换为内核态(kernel space)。

CPU 将读缓冲区(read buffer)中的数据拷贝到网络缓冲区(socket buffer)。

CPU 行使 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡举行数据传输。

上下文从内核态(kernel space)切换回用户态(user space),write 系统挪用执行返回。

mmap 主要的用处是提高 I/O 性能,稀奇是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的虚耗。

由于内存映射总是要对齐页界限,最小单元是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会虚耗 3 KB 内存。

mmap 的拷贝虽然削减了 1 次拷贝,提升了效率,但也存在一些隐藏的问题。

当 mmap 一个文件时,若是这个文件被另一个历程所截获,那么 write 系统挪用会由于接见非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死历程并发生一个 coredump,服务器可能因此被终止。

Sendfile

Sendfile 系统挪用在 Linux 内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间举行的数据传输历程。

Sendfile 系统挪用的引入,不仅削减了 CPU 拷贝的次数,还削减了上下文切换的次数,它的伪代码如下:

sendfile(socket_fd, file_fd, len);

通过 Sendfile 系统挪用,数据可以直接在内核空间内部举行 I/O 传输,从而省去了数据在用户空间和内核空间之间的往返拷贝。

与 mmap 内存映射方式差其余是, Sendfile 挪用中 I/O 数据对用户空间是完全不能见的。也就是说,这是一次完全意义上的数据传输历程。

基于 Sendfile 系统挪用的零拷贝方式,整个拷贝历程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。

用户程序读写数据的流程如下:

用户历程通过 sendfile() 函数向内核(kernel)提议系统挪用,上下文从用户态(user space)切换为内核态(kernel space)。

CPU 行使 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。

CPU 将读缓冲区(read buffer)中的数据拷贝到的网络缓冲区(socket buffer)。

CPU 行使 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡举行数据传输。

上下文从内核态(kernel space)切换回用户态(user space),Sendfile 系统挪用执行返回。

相对照于 mmap 内存映射的方式,Sendfile 少了 2 次上下文切换,然则仍然有 1 次 CPU 拷贝操作。

Sendfile 存在的问题是用户程序不能对数据举行修改,而只是单纯地完成了一次数据传输历程。

Sendfile+DMA gather copy

Linux 2.4 版本的内核对 Sendfile 系统挪用举行修改,为 DMA 拷贝引入了 gather 操作。

它将内核空间(kernel space)的读缓冲区(read buffer)中对应的数据形貌信息(内存地址、地址偏移量)纪录到响应的网络缓冲区( socket buffer)中,由 DMA 凭证内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡装备中。

这样就省去了内核空间中仅剩的 1 次 CPU 拷贝操作,Sendfile 的伪代码如下:

sendfile(socket_fd, file_fd, len);

在硬件的支持下,Sendfile 拷贝方式不再从内核缓冲区的数据拷贝到 socket 缓冲区,取而代之的仅仅是缓冲区文件形貌符和数据长度的拷贝。

这样 DMA 引擎直接行使 gather 操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思绪类似。

基于 Sendfile+DMA gather copy 系统挪用的零拷贝方式,整个拷贝历程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝。

用户程序读写数据的流程如下:

用户历程通过 sendfile() 函数向内核(kernel)提议系统挪用,上下文从用户态(user space)切换为内核态(kernel space)。

CPU 行使 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。

CPU 把读缓冲区(read buffer)的文件形貌符(file descriptor)和数据长度拷贝到网络缓冲区(socket buffer)。

基于已拷贝的文件形貌符(file descriptor)和数据长度,CPU 行使 DMA 控制器的 gather/scatter 操作直接批量地将数据从内核的读缓冲区(read buffer)拷贝到网卡举行数据传输。

上下文从内核态(kernel space)切换回用户态(user space),Sendfile 系统挪用执行返回。

Sendfile+DMA gather copy 拷贝方式同样存在用户程序不能对数据举行修改的问题,而且自己需要硬件的支持,它只适用于将数据从文件拷贝到 socket 套接字上的传输历程。

Splice

Sendfile 只适用于将数据从文件拷贝到 socket 套接字上,同时需要硬件的支持,这也限制了它的使用局限。

Linux 在 2.6.17 版本引入 Splice 系统挪用,不仅不需要硬件支持,还实现了两个文件形貌符之间的数据零拷贝。

Splice 的伪代码如下:

splice(fd_in, off_in, fd_out, off_out, len, flags);

Splice 系统挪用可以在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间确立管道(pipeline),从而阻止了两者之间的 CPU 拷贝操作。

基于 Splice 系统挪用的零拷贝方式,整个拷贝历程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝。

用户程序读写数据的流程如下:

用户历程通过 splice() 函数向内核(kernel)提议系统挪用,上下文从用户态(user space)切换为内核态(kernel space)。

CPU 行使 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。

CPU 在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间确立管道(pipeline)。

CPU 行使 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡举行数据传输。

上下文从内核态(kernel space)切换回用户态(user space),Splice 系统挪用执行返回。

Splice 拷贝方式也同样存在用户程序不能对数据举行修改的问题。除此之外,它使用了 Linux 的管道缓冲机制,可以用于随便两个文件形貌符中传输数据,然则它的两个文件形貌符参数中有一个必须是管道装备。

写时复制

在某些情形下,内核缓冲区可能被多个历程所共享,若是某个历程想要这个共享区举行 write 操作,由于 write 不提供任何的锁操作,那么就会对共享区中的数据造成损坏,写时复制的引入就是 Linux 用来珍爱数据的。

写时复制指的是当多个历程共享统一块数据时,若是其中一个历程需要对这份数据举行修改,那么就需要将其拷贝到自己的历程地址空间中。

这样做并不影响其他历程对这块数据的操作,每个历程要修改的时刻才会举行拷贝,以是叫写时拷贝。

这种方式在某种水平上能够降低系统开销,若是某个历程永远不会对所接见的数据举行更改,那么也就永远不需要拷贝。

缓冲区共享

缓冲区共享方式完全改写了传统的 I/O 操作,由于传统 I/O 接口都是基于数据拷贝举行的,要阻止拷贝就得去掉原先的那套接口并重新改写。

以是这种方式是对照周全的零拷贝手艺,现在对照成熟的一个方案是在 Solaris 上实现的 fbuf(Fast Buffer,快速缓冲区)。

fbuf 的头脑是每个历程都维护着一个缓冲区池,这个缓冲区池能被同时映射到用户空间(user space)和内核态(kernel space),内核和用户共享这个缓冲区池,这样就阻止了一系列的拷贝操作。

缓冲区共享的难度在于治理共享缓冲区池需要应用程序、网络软件以及装备驱动程序之间的慎密互助,而且若何改写 API 现在还处于试验阶段并不成熟。

Linux 零拷贝对比

无论是传统 I/O 拷贝方式照样引入零拷贝的方式,2 次 DMA Copy 是都少不了的,由于两次 DMA 都是依赖硬件完成的。

下面从 CPU 拷贝次数、DMA 拷贝次数以及系统挪用几个方面总结一下上述几种 I/O 拷贝方式的差异:

Java NIO 零拷贝实现

在 Java NIO 中的通道(Channel)就相当于操作系统的内核空间(kernel space)的缓冲区。

而缓冲区(Buffer)对应的相当于操作系统的用户空间(user space)中的用户缓冲区(user buffer):

通道(Channel)是全双工的(双向传输),它既可能是读缓冲区(read buffer),也可能是网络缓冲区(socket buffer)。

缓冲区(Buffer)分为堆内存(HeapBuffer)和堆外内存(DirectBuffer),这是通过 malloc() 分配出来的用户态内存。

堆外内存(DirectBuffer)在使用后需要应用程序手动接纳,而堆内存(HeapBuffer)的数据在 GC 时可能会被自动接纳。

因此,在使用 HeapBuffer 读写数据时,为了阻止缓冲区数据由于 GC 而丢失,NIO 会先把 HeapBuffer 内部的数据拷贝到一个暂且的 DirectBuffer 中的内陆内存(native memory)。

这个拷贝涉及到 sun.misc.Unsafe.copyMemory() 的挪用,背后的实现原理与 memcpy() 类似。

最后,将暂且天生的 DirectBuffer 内部的数据的内存地址传给 I/O 挪用函数,这样就阻止了再去接见 Java 工具处置 I/O 读写。

MappedByteBuffer

MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式提供的一种实现,它继续自 ByteBuffer。

FileChannel 界说了一个 map() 方式,它可以把一个文件从 position 位置最先的 size 巨细的区域映射为内存映像文件。

抽象方式 map() 方式在 FileChannel 中的界说如下:

public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;

Mode:限制内存映射区域(MappedByteBuffer)对内存映像文件的接见模式,包罗只可读(READ_ONLY)、可读可写(READ_WRITE)和写时拷贝(PRIVATE)三种模式。

Position:文件映射的起始地址,对应内存映射区域(MappedByteBuffer)的首地址。

Size:文件映射的字节长度,从 Position 往后的字节数,对应内存映射区域(MappedByteBuffer)的巨细。

MappedByteBuffer 相比 ByteBuffer 新增了三个主要的方式:

fore():对于处于 READ_WRITE 模式下的缓冲区,把对缓冲区内容的修改强制刷新到内陆文件。

load():将缓冲区的内容载入物理内存中,并返回这个缓冲区的引用。

isLoaded():若是缓冲区的内容在物理内存中,则返回 true,否则返回 false。

下面给出一个行使 MappedByteBuffer 对文件举行读写的使用示例:

private final static String CONTENT = “Zero copy implemented by MappedByteBuffer”;private final static String FILE_NAME = “/mmap.txt”;private final static String CHARSET = “UTF-8”;

FileChannel

FileChannel 是一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程平安的。

基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方式可以确立并打开一个文件通道。

FileChannel 界说了 transferFrom() 和 transferTo() 两个抽象方式,它通过在通道和通道之间确立毗邻实现数据传输的。

transferTo():通过 FileChannel 把文件内里的源数据写入一个 WritableByteChannel 的目的通道。

public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;

transferFrom():把一个源通道 ReadableByteChannel 中的数据读取到当前 FileChannel 的文件内里。

public abstract long transferFrom(ReadableByteChannel src, long position, long count) throws IOException;

下面给出 FileChannel 行使 transferTo() 和 transferFrom() 方式举行数据传输的使用示例:

private static final String CONTENT = “Zero copy implemented by FileChannel”;private static final String SOURCE_FILE = “/source.txt”;private static final String TARGET_FILE = “/target.txt”;private static final String CHARSET = “UTF-8”;

首先在类加载根路径下确立 source.txt 和 target.txt 两个文件,对源文件 source.txt 文件写入初始化数据。

@Beforepublic void setup() { Path source = Paths.get(getClassPath(SOURCE_FILE)); byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET)); try (FileChannel fromChannel = FileChannel.open(source, StandardOpenOption.READ, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { fromChannel.write(ByteBuffer.wrap(bytes)); } catch (IOException e) { e.printStackTrace(); }}

对于 transferTo() 方式而言,目的通道 toChannel 可以是随便的单向字节写通道 WritableByteChannel;而对于 transferFrom() 方式而言,源通道 fromChannel 可以是随便的单向字节读通道 ReadableByteChannel。

其中,FileChannel、SocketChannel 和 DatagramChannel 等通道实现了 WritableByteChannel 和 ReadableByteChannel 接口,都是同时支持读写的双向通道。

为了利便测试,下面给出基于 FileChannel 完成 channel-to-channel 的数据传输示例。

通过 transferTo() 将 fromChannel 中的数据拷贝到 toChannel:

@Testpublic void transferTo() throws Exception { try (FileChannel fromChannel = new RandomAccessFile( getClassPath(SOURCE_FILE), “rw”).getChannel(); FileChannel toChannel = new RandomAccessFile( getClassPath(TARGET_FILE), “rw”).getChannel()) { long position = 0L; long offset = fromChannel.size(); fromChannel.transferTo(position, offset, toChannel); }}

通过 transferFrom() 将 fromChannel 中的数据拷贝到 toChannel:

@Testpublic void transferFrom() throws Exception { try (FileChannel fromChannel = new RandomAccessFile( getClassPath(SOURCE_FILE), “rw”).getChannel(); FileChannel toChannel = new RandomAccessFile( getClassPath(TARGET_FILE), “rw”).getChannel()) { long position = 0L; long offset = fromChannel.size(); toChannel.transferFrom(fromChannel, position, offset); }}

下面先容 transferTo() 和 transferFrom() 方式的底层实现原理,这两个方式也是 java.nio.channels.FileChannel 的抽象方式,由子类 sun.nio.ch.FileChannelImpl.java 实现。

transferTo() 和 transferFrom() 底层都是基于 Sendfile 实现数据传输的,其中 FileChannelImpl.java 界说了 3 个常量,用于标示当前操作系统的内核是否支持 Sendfile 以及 Sendfile 的相关特征。

private static volatile boolean transferSupported = true;private static volatile boolean pipeSupported = true;private static volatile boolean fileSupported = true;

transferSupported:用于符号当前的系统内核是否支持 sendfile() 挪用,默以为 true。

pipeSupported:用于符号当前的系统内核是否支持文件形貌符(fd)基于管道(pipe)的 sendfile() 挪用,默以为 true。

fileSupported:用于符号当前的系统内核是否支持文件形貌符(fd)基于文件(file)的 sendfile() 挪用,默以为 true。

下面以 transferTo() 的源码实现为例。FileChannelImpl 首先执行 transferToDirectly() 方式,以 Sendfile 的零拷贝方式实验数据拷贝。

若是系统内核不支持 Sendfile,进一步执行 transferToTrustedChannel() 方式,以 mmap 的零拷贝方式举行内存映射,这种情形下目的通道必须是 FileChannelImpl 或者 SelChImpl 类型。

若是以上两步都失败了,则执行 transferToArbitraryChannel() 方式,基于传统的 I/O 方式完成读写,详细步骤是初始化一个暂且的 DirectBuffer,将源通道 FileChannel 的数据读取到 DirectBuffer,再写入目的通道 WritableByteChannel 内里。

public long transferTo(long position, long count, WritableByteChannel target) throws IOException { // 盘算文件的巨细 long sz = size(); // 校验起始位置 if (position > sz) return 0; int icount = (int)Math.min(count, Integer.MAX_VALUE); // 校验偏移量 if ((sz – position) < icount) icount = (int)(sz – position); long n; if ((n = transferToDirectly(position, icount, target)) >= 0) return n; if ((n = transferToTrustedChannel(position, icount, target)) >= 0) return n; return transferToArbitraryChannel(position, icount, target);}

接下来重点剖析一下 transferToDirectly() 方式的实现,也就是 transferTo() 通过 Sendfile 实现零拷贝的精髓所在。

可以看到,transferToDirectlyInternal() 方式先获取到目的通道 WritableByteChannel 的文件形貌符 targetFD,获取同步锁然后执行 transferToDirectlyInternal() 方式。

private long transferToDirectly(long position, int icount, WritableByteChannel target) throws IOException { // 省略从target获取targetFD的历程 if (nd.transferToDirectlyNeedsPositionLock()) { synchronized (positionLock) { long pos = position(); try { return transferToDirectlyInternal(position, icount, target, targetFD); } finally { position(pos); } } } else { return transferToDirectlyInternal(position, icount, target, targetFD); }}

最终由 transferToDirectlyInternal() 挪用内陆方式 transferTo0() ,实验以 Sendfile 的方式举行数据传输。

若是系统内核完全不支持 Sendfile,好比 Windows 操作系统,则返回 UNSUPPORTED 并把 transferSupported 标识为 false。

若是系统内核不支持 Sendfile 的一些特征,好比说低版本的 Linux 内核不支持 DMA gather copy 操作,则返回 UNSUPPORTED_CASE 并把 pipeSupported 或者 fileSupported 标识为 false。

private long transferToDirectlyInternal(long position, int icount, WritableByteChannel target, FileDescriptor targetFD) throws IOException { assert !nd.transferToDirectlyNeedsPositionLock() || Thread.holdsLock(positionLock); long n = -1; int ti = -1; try { begin(); ti = threads.add(); if (!isOpen()) return -1; do { n = transferTo0(fd, position, icount, targetFD); } while ((n == IOStatus.INTERRUPTED) && isOpen()); if (n == IOStatus.UNSUPPORTED_CASE) { if (target instanceof SinkChannelImpl) pipeSupported = false; if (target instanceof FileChannelImpl) fileSupported = false; return IOStatus.UNSUPPORTED_CASE; } if (n == IOStatus.UNSUPPORTED) { transferSupported = false; return IOStatus.UNSUPPORTED; } return IOStatus.normalize(n); } finally { threads.remove(ti); end (n > -1); }}

内陆方式(native method)transferTo0() 通过 JNI(Java Native Interface)挪用底层 C 的函数。

这个 native 函数(Java_sun_nio_ch_FileChannelImpl_transferTo0)同样位于 JDK 源码包下的 native/sun/nio/ch/FileChannelImpl.c 源文件内里。

JNI 函数 Java_sun_nio_ch_FileChannelImpl_transferTo0() 基于条件编译对差其余系统举行预编译,下面是 JDK 基于 Linux 系统内核对 transferTo() 提供的挪用封装。

#if defined(__linux__) || defined(__solaris__)#include #elif defined(_AIX)#include #elif defined(_ALLBSD_SOURCE)#include #include #include #define lseek64 lseek#define mmap64 mmap#endifJNIEXPORT jlong JNICALLJava_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this, jobject srcFDO, jlong position, jlong count, jobject dstFDO){ jint srcFD = fdval(env, srcFDO); jint dstFD = fdval(env, dstFDO);#if defined(__linux__) off64_t offset = (off64_t)position; jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count); return n;#elif defined(__solaris__) result = sendfilev64(dstFD, &sfv, 1, &numBytes); return result;#elif defined(__APPLE__) result = sendfile(srcFD, dstFD, position, &numBytes, NULL, 0); return result;#endif}

对 Linux、Solaris 以及 Apple 系统而言,transferTo0() 函数底层会执行 sendfile64 这个系统挪用完成零拷贝操作,sendfile64() 函数的原型如下:

#include ssize_t sendfile64(int out_fd, int in_fd, off_t *offset, size_t count);

下面简朴先容一下 sendfile64() 函数各个参数的寄义:

out_fd:待写入的文件形貌符。

in_fd:待读取的文件形貌符。

offset:指定 in_fd 对应文件流的读取位置,若是为空,则默认从起始位置最先。

count:指定在文件形貌符 in_fd 和 out_fd 之间传输的字节数。

在 Linux 2.6.3 之前,out_fd 必须是一个 socket,而从 Linux 2.6.3 以后,out_fd 可以是任何文件。

也就是说,sendfile64() 函数不仅可以举行网络文件传输,还可以对内陆文件实现零拷贝操作。

其它的零拷贝实现

Netty 零拷贝

Netty 中的零拷贝和上面提到的操作系统层面上的零拷贝不太一样, 我们所说的 Netty 零拷贝完全是基于(Java 层面)用户态的,它的更多的是偏向于数据操作优化这样的看法。

详细显示在以下几个方面:

Netty 通过 DefaultFileRegion 类对 java.nio.channels.FileChannel 的 tranferTo() 方式举行包装,在文件传输时可以将文件缓冲区的数据直接发送到目的通道(Channel)。

ByteBuf 可以通过 wrap 操作把字节数组、ByteBuf、ByteBuffer 包装成一个 ByteBuf 工具, 进而阻止了拷贝操作。

ByteBuf 支持 Slice 操作, 因此可以将 ByteBuf 剖析为多个共享统一个存储区域的 ByteBuf,阻止了内存的拷贝。

Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,阻止了各个 ByteBuf 之间的拷贝。

其中第 1 条属于操作系统层面的零拷贝操作,后面 3 条只能算用户层面的数据操作优化。

RocketMQ 和 Kafka 对比

RocketMQ 选择了 mmap+write 这种零拷贝方式,适用于营业级新闻这种小块文件的数据持久化和传输。

而 Kafka 接纳的是 Sendfile 这种零拷贝方式,适用于系统日志新闻这种高吞吐量的大块文件的数据持久化和传输。

然则值得注重的一点是,Kafka 的索引文件使用的是 mmap+write 方式,数据文件使用的是 Sendfile 方式。

总结

本文开篇详述了 Linux 操作系统中的物理内存和虚拟内存,内核空间和用户空间的看法以及 Linux 内部的层级结构。

在此基础上,进一步剖析和对比传统 I/O 方式和零拷贝方式的区别,然后先容了 Linux 内核提供的几种零拷贝实现。

包罗内存映射 mmap、Sendfile、Sendfile+DMA gather copy 以及 Splice 几种机制,并从系统挪用和拷贝次数层面临它们举行了对比。

接下来从源码着手剖析了 Java NIO 对零拷贝的实现,主要包罗基于内存映射(mmap)方式的 MappedByteBuffer 以及基于 Sendfile 方式的 FileChannel。

最后在篇末简朴的论述了一下 Netty 中的零拷贝机制,以及 RocketMQ 和 Kafka 两种新闻行列在零拷贝实现方式上的区别。

Amaze UI仿电脑版微信聊天界面代码

代码简介 AmazeUI仿电脑版微信聊天界面代码是一款相似率相当高的微信网页版聊天界面样式,当然只是仿了一些基本的东西,不可能像官网那样功能全面。 js代码

转载请说明出处内容投诉
八爷源码网 » 支持百万并发的“零拷贝”手艺,你领会吗?