V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
jiangxinlingdu
V2EX  ›  程序员

NIO 相关基础篇三

  •  
  •   jiangxinlingdu · 2019-06-06 12:51:47 +08:00 · 978 次点击
    这是一个创建于 1784 天前的主题,其中的信息可能已经有所发展或是发生改变。

    说在前面

    上篇NIO 相关基础篇二,主要介绍了文件锁、以及比较关键的 Selector,本篇继续 NIO 相关话题内容,主要谈谈一些 Linux 网络 I/O 模型、零拷贝等一些内容,目前能理解到的就这些了,后续还会继续有一到二篇左右与 NIO 内容相关,估计在后续 netty 等一些学习完成之后,在回过头来看看 NIO 系列,再补充补充。

    用户空间以及内核空间概念

    我们知道现在操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 4G ( 2 的 32 次方)。操心系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF ),供内核使用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF ),供各个进程使用,称为用户空间。每个进程可以通过系统调用进入内核,因此,Linux 内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有 4G 字节的虚拟空间。

    空间分配如下图所示:

    有了用户空间和内核空间,整个 linux 内部结构可以分为三部分,从最底层到最上层依次是:硬件-->内核空间-->用户空间。 如下图所示:

    需要注意的细节问题,从上图可以看出内核的组成:

    1. 内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。
    2. Linux 使用两级保护机制:0 级供内核使用,3 级供用户程序使用。

    Linux 网络 I/O 模型

    我们都知道,为了 OS 的安全性等的考虑,进程是无法直接操作 I/O 设备的,其必须通过系统调用请求内核来协助完成 I/O 动作,而内核会为每个 I/O 设备维护一个 buffer。 如下图所示: 整个请求过程为: 用户进程发起请求,内核接受到请求后,从 I/O 设备中获取数据到 buffer 中,再将 buffer 中的数据 copy 到用户进程的地址空间,该用户进程获取到数据后再响应客户端。

    在整个请求过程中,数据输入至 buffer 需要时间,而从 buffer 复制数据至进程也需要时间。因此根据在这两段时间内等待方式的不同,I/O 动作可以分为以下五种模式

    • 阻塞 I/O (Blocking I/O)
    • 非阻塞 I/O (Non-Blocking I/O)
    • I/O 复用( I/O Multiplexing)
    • 信号驱动的 I/O (Signal Driven I/O)
    • 异步 I/O (Asynchrnous I/O) **说明:**如果像了解更多可能需要 linux/unix 方面的知识了,可自行去学习一些网络编程原理应该有详细说明,不过对大多数 java 程序员来说,不需要了解底层细节,知道个概念就行,知道对于系统而言,底层是支持的

    本文最重要的参考文献是 Richard Stevens 的“ UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking ”,6.2 节“ I/O Models ”,公众号 [匠心零度] 回复:linux ,获取该资料,建议电脑下载(比较大以及 chm 格式),本文中的流程图也是截取自中

    记住这两点很重要 1 等待数据准备 (Waiting for the data to be ready) 2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

    阻塞 I/O (Blocking I/O)

    在 linux 中,默认情况下所有的 socket 都是 blocking,一个典型的读操作流程大概是这样:

    当用户进程调用了 recvfrom 这个系统调用,内核就开始了 IO 的第一个阶段:等待数据准备。对于 network io 来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的 UDP 包),这个时候内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备好了,它就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户进程才解除 block 的状态,重新运行起来。 所以,blocking IO 的特点就是在 IO 执行的两个阶段都被 block 了。

    非阻塞 I/O (Non-Blocking I/O)

    linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:

    当用户进程调用 recvfrom 时,系统不会阻塞用户进程,而是立刻返回一个 ewouldblock 错误,从用户进程角度讲 ,并不需要等待,而是马上就得到了一个结果。用户进程判断标志是 ewouldblock 时,就知道数据还没准备好,于是它就可以去做其他的事了,于是它可以再次发送 recvfrom,一旦内核中的数据准备好了。并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。 当一个应用程序在一个循环里对一个非阻塞调用 recvfrom,我们称为轮询。应用程序不断轮询内核,看看是否已经准备好了某些操作。这通常是 浪费 CPU 时间,但这种模式偶尔会遇到。

    I/O 复用( I/O Multiplexing)

    IO multiplexing 这个词可能有点陌生,但是如果我说 select,epoll,大概就都能明白了。有些地方也称这种 IO 方式为 event driven IO。我们都知道,select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select/epoll 这个 function 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。它的流程如图:

    当用户进程调用了 select,那么整个进程会被 block,而同时,内核会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从内核拷贝到用户进程。 这个图和 blocking IO 的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call (select 和 recvfrom),而 blocking IO 只调用了一个 system call (recvfrom)。但是,用 select 的优势在于它可以同时处理多个 connection。(多说一句。所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking IO 的 web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。) 在 IO multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。

    文件描述符 fd

    Linux 的内核将所有外部设备都可以看做一个文件来操作。那么我们对与外部设备的操作都可以看做对文件进行操作。我们对一个文件的读写,都通过调用内核提供的系统调用;内核给我们返回一个 filede scriptor ( fd,文件描述符)。而对一个 socket 的读写也会有相应的描述符,称为 socketfd(socket 描述符)。描述符就是一个数字,指向内核中一个结构体(文件路径,数据区,等一些属性)。那么我们的应用程序对文件的读写就通过对描述符的读写完成。

    select

    基本原理: select 函数监视的文件描述符分 3 类,分别是 writefds、readfds、和 exceptfds。调用后 select 函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有 except ),或者超时( timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。当 select 函数返回后,可以通过遍历 fdset,来找到就绪的描述符。

    缺点: 1、select 最大的缺陷就是单个进程所打开的 FD 是有一定限制的,它由 FD_SETSIZE 设置,32 位机默认是 1024 个,64 位机默认是 2048。 一般来说这个数目和系统内存关系很大,”具体数目可以 cat /proc/sys/fs/file-max 察看”。32 位机默认是 1024 个。64 位机默认是 2048. 2、对 socket 进行扫描时是线性扫描,即采用轮询的方法,效率较低。 当套接字比较多的时候,每次 select()都要通过遍历 FD_SETSIZE 个 Socket 来完成调度,不管哪个 Socket 是活跃的,都遍历一遍。这会浪费很多 CPU 时间。”如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询”,这正是 epoll 与 kqueue 做的。 3、需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

    poll

    基本原理: poll 本质上和 select 没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有 fd 后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历 fd。这个过程经历了多次无谓的遍历。

    它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点: 1、大量的 fd 的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。 2、poll 还有一个特点是“水平触发”,如果报告了 fd 后,没有被处理,那么下次 poll 时会再次报告该 fd。

    注意: 从上面看,select 和 poll 都需要在返回后,通过遍历文件描述符来获取已经就绪的 socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

    epoll

    epoll 是在 2.6 内核中提出的,是之前的 select 和 poll 的增强版本。相对于 select 和 poll 来说,epoll 更加灵活,没有描述符限制。epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。

    基本原理: epoll 支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些 fd 刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll 使用“事件”的就绪通知方式,通过 epoll_ctl 注册 fd,一旦该 fd 就绪,内核就会采用类似 callback 的回调机制来激活该 fd,epoll_wait 便可以收到通知。

    epoll 的优点: 1、没有最大并发连接的限制,能打开的 FD 的上限远大于 1024 ( 1G 的内存上能监听约 10 万个端口)。 2、效率提升,不是轮询的方式,不会随着 FD 数目的增加效率下降。 只有活跃可用的 FD 才会调用 callback 函数;即 Epoll 最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll 的效率就会远远高于 select 和 poll。 3、内存拷贝,利用 mmap()文件映射内存加速与内核空间的消息传递;即 epoll 使用 mmap 减少复制开销。

    JDK1.5_update10 版本使用 epoll 替代了传统的 select/poll,极大的提升了 NIO 通信的性能。

    **备注:**JDK NIO 的 BUG,例如臭名昭著的 epoll bug,它会导致 Selector 空轮询,最终导致 CPU 100%。官方声称在 JDK1.6 版本的 update18 修复了该问题,但是直到 JDK1.7 版本该问题仍旧存在,只不过该 BUG 发生概率降低了一些而已,它并没有被根本解决。这个可以在后续 netty 系列里面进行说明下。

    信号驱动的 I/O (Signal Driven I/O)

    由于 signal driven IO 在实际中并不常用,所以简单提下。

    很明显可以看出用户进程不是阻塞的。首先用户进程建立 SIGIO 信号处理程序,并通过系统调用 sigaction 执行一个信号处理函数,这时用户进程便可以做其他的事了,一旦数据准备好,系统便为该进程生成一个 SIGIO 信号,去通知它数据已经准备好了,于是用户进程便调用 recvfrom 把数据从内核拷贝出来,并返回结果。

    异步 I/O

    一般来说,这些函数通过告诉内核启动操作并在整个操作(包括内核的数据到缓冲区的副本)完成时通知我们。这个模型和前面的信号驱动 I/O 模型的主要区别是,在信号驱动的 I/O 中,内核告诉我们何时可以启动 I/O 操作,但是异步 I/O 时,内核告诉我们何时 I/O 操作完成。

    当用户进程向内核发起某个操作后,会立刻得到返回,并把所有的任务都交给内核去完成(包括将数据从内核拷贝到用户自己的缓冲区),内核完成之后,只需返回一个信号告诉用户进程已经完成就可以了。

    5 中 I/O 模型的对比

    **结果表明:**前四个模型之间的主要区别是第一阶段,四个模型的第二阶段是一样的:过程受阻在调用 recvfrom 当数据从内核拷贝到用户缓冲区。然而,异步 I/O 处理两个阶段,与前四个不同。

    从同步、异步,以及阻塞、非阻塞两个维度来划分来看:

    零拷贝

    CPU 不执行拷贝数据从一个存储区域到另一个存储区域的任务,这通常用于在网络上传输文件时节省 CPU 周期和内存带宽。

    缓存 IO

    缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

    缓存 IO 的缺点:数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

    零拷贝技术分类

    零拷贝技术的发展很多样化,现有的零拷贝技术种类也非常多,而当前并没有一个适合于所有场景的零拷贝技术的出现。对于 Linux 来说,现存的零拷贝技术也比较多,这些零拷贝技术大部分存在于不同的 Linux 内核版本,有些旧的技术在不同的 Linux 内核版本间得到了很大的发展或者已经渐渐被新的技术所代替。本文针对这些零拷贝技术所适用的不同场景对它们进行了划分。概括起来,Linux 中的零拷贝技术主要有下面这几种:

    • 直接 I/O:对于这种数据传输方式来说,应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输:这类零拷贝技术针对的是操作系统内核并不需要对数据进行直接处理的情况,数据可以在应用程序地址空间的缓冲区和磁盘之间直接进行传输,完全不需要 Linux 操作系统内核提供的页缓存的支持。
    • 在数据传输的过程中,避免数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间进行拷贝。有的时候,应用程序在数据进行传输的过程中不需要对数据进行访问,那么,将数据从 Linux 的页缓存拷贝到用户进程的缓冲区中就可以完全避免,传输的数据在页缓存中就可以得到处理。在某些特殊的情况下,这种零拷贝技术可以获得较好的性能。Linux 中提供类似的系统调用主要有 mmap(),sendfile() 以及 splice()。
    • 对数据在 Linux 的页缓存和用户进程的缓冲区之间的传输过程进行优化。该零拷贝技术侧重于灵活地处理数据在用户进程的缓冲区和操作系统的页缓存之间的拷贝操作。这种方法延续了传统的通信方式,但是更加灵活。在 Linux 中,该方法主要利用了写时复制技术。

    前两类方法的目的主要是为了避免应用程序地址空间和操作系统内核地址空间这两者之间的缓冲区拷贝操作。这两类零拷贝技术通常适用在某些特殊的情况下,比如要传送的数据不需要经过操作系统内核的处理或者不需要经过应用程序的处理。第三类方法则继承了传统的应用程序地址空间和操作系统内核地址空间之间数据传输的概念,进而针对数据传输本身进行优化。我们知道,硬件和软件之间的数据传输可以通过使用 DMA 来进行,DMA 进行数据传输的过程中几乎不需要 CPU 参与,这样就可以把 CPU 解放出来去做更多其他的事情,但是当数据需要在用户地址空间的缓冲区和 Linux 操作系统内核的页缓存之间进行传输的时候,并没有类似 DMA 这种工具可以使用,CPU 需要全程参与到这种数据拷贝操作中,所以这第三类方法的目的是可以有效地改善数据在用户地址空间和操作系统内核地址空间之间传递的效率。

    注意,对于各种零拷贝机制是否能够实现都是依赖于操作系统底层是否提供相应的支持。

    当应用程序访问某块数据时,操作系统首先会检查,是不是最近访问过此文件,文件内容是否缓存在内核缓冲区,如果是,操作系统则直接根据 read 系统调用提供的 buf 地址,将内核缓冲区的内容拷贝到 buf 所指定的用户空间缓冲区中去。如果不是,操作系统则首先将磁盘上的数据拷贝的内核缓冲区,这一步目前主要依靠 DMA 来传输,然后再把内核缓冲区上的内容拷贝到用户缓冲区中。 接下来,write 系统调用再把用户缓冲区的内容拷贝到网络堆栈相关的内核缓冲区中,最后 socket 再把内核缓冲区的内容发送到网卡上。

    从上图中可以看出,共产生了四次数据拷贝,即使使用了 DMA 来处理了与硬件的通讯,CPU 仍然需要处理两次数据拷贝,与此同时,在用户态与内核态也发生了多次上下文切换,无疑也加重了 CPU 负担。 在此过程中,我们没有对文件内容做任何修改,那么在内核空间和用户空间来回拷贝数据无疑就是一种浪费,而零拷贝主要就是为了解决这种低效性。

    让数据传输不需要经过 user space,使用 mmap

    我们减少拷贝次数的一种方法是调用 mmap()来代替 read 调用:

    buf = mmap(diskfd, len);
    write(sockfd, buf, len);
    

    应用程序调用mmap(),磁盘上的数据会通过DMA被拷贝的内核缓冲区,接着操作系统会把这段内核缓冲区与应用程序共享,这样就不需要把内核缓冲区的内容往用户空间拷贝。应用程序再调用write(),操作系统直接将内核缓冲区的内容拷贝到socket缓冲区中,这一切都发生在内核态,最后,socket缓冲区再把数据发到网卡去。

    同样的,看图很简单:

    使用 mmap 替代 read 很明显减少了一次拷贝,当拷贝数据量很大时,无疑提升了效率。但是使用mmap是有代价的。当你使用mmap时,你可能会遇到一些隐藏的陷阱。例如,当你的程序map了一个文件,但是当这个文件被另一个进程截断(truncate)时, write 系统调用会因为访问非法地址而被SIGBUS信号终止。SIGBUS信号默认会杀死你的进程并产生一个coredump,如果你的服务器这样被中止了,那会产生一笔损失。

    通常我们使用以下解决方案避免这种问题:

    1. 为 SIGBUS 信号建立信号处理程序 当遇到SIGBUS信号时,信号处理程序简单地返回,write系统调用在被中断之前会返回已经写入的字节数,并且errno会被设置成 success,但是这是一种糟糕的处理办法,因为你并没有解决问题的实质核心。

    2. 使用文件租借锁 通常我们使用这种方法,在文件描述符上使用租借锁,我们为文件向内核申请一个租借锁,当其它进程想要截断这个文件时,内核会向我们发送一个实时的RT_SIGNAL_LEASE信号,告诉我们内核正在破坏你加持在文件上的读写锁。这样在程序访问非法内存并且被SIGBUS杀死之前,你的write系统调用会被中断。write会返回已经写入的字节数,并且置errno为 success。 我们应该在mmap文件之前加锁,并且在操作完文件后解锁:

    if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
        perror("kernel lease set signal");
        return -1;
    }
    /* l_type can be F_RDLCK F_WRLCK  加锁*/
    /* l_type can be  F_UNLCK 解锁*/
    if(fcntl(diskfd, F_SETLEASE, l_type)){
        perror("kernel lease set type");
        return -1;
    }
    

    参考: https://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy1/ https://www.jianshu.com/p/fad3339e3448

    说明: 零拷贝目前水平有限,大概先写这么多,零拷贝还在持续学习,到时候 netty 系列在看看是否来再来一篇。

    结束语

    本人水平有限,难免会有一些理解偏差的地方,如果发现,欢迎各位积极指出,感谢!!!

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   5617 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 31ms · UTC 03:32 · PVG 11:32 · LAX 20:32 · JFK 23:32
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.