NIO相关基础篇

原文链接

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

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

空间分配如下图所示:

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

如下图所示:

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

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

2.Linux 网络 I/O模型

我们都知道,为了OS的安全性等的考虑,进程是无法直接操作I/O设备的,其必须通过系统调用请求内核来协助完成I/O动作,而内核会为每个I/O设备维护一个buffer。

整个请求过程为:用户进程发起请求,内核接收到请求后,从I/O设备中获取数据到buffer,在buffer中的数据copy到用户进程的地址空间,该进程获取到数据后再响应客户端。

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

  • 阻塞I/0(Blocking I/0)
  • 非阻塞I/0(Non-Blocking I/0)
  • I/0复用(I/0 Multiplexing)
  • 信号驱动的I/0(Signal Driven I/0)
  • 异步I/0(Asynchrnous I/0)

说明:如果想了解更多可能需要linux/unix方面的知识了,可自行去学习一些网络编程原理。不过对大多数的Java程序员来说,不需要了解底层细节,知道个概念就行,知道对于系统而言,底层是支持的。

记住这两点很重要

  • 等待数据准备(Waiting for the data to be ready)
  • 将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

3.阻塞I/O (Blocking I/O)

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

当用户进程调用了 recvfrom 这个系统调用,内核就开始了IO的第一个阶段:等待数据准备。对于 Network IO 来说,很多时候数据再一开始还没有到达(比如,还没有收到一个完成的UDP包),这个时候内核就要等待足够数据的到来。而在用户进程这边,整个进程就会被阻塞。当内核一直等到数据准备好了,它就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户进程才接触 block 的装填,重新运行起来。

所有,Blocking IO的特点就是在IO执行的连个阶段都被block了

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

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

当用户进程调用 recvfrom 时,系统不会阻塞用户进程,而是立即返回一个 ewouldblock 错误,从用户角度讲,并不需要等待,而是马上得到了一个结果。用户进程判断标志是 ewouldblock  时,就知道数据还没有准备好,于是它就可以去做其他事了,于是它可以再次发送 recvfrom,一旦内核中的数据准备好了。并且又再次受到用户进程的 system-call,那么它马上就将数据拷贝到了用户内存,然后返回。

当一个应用程序在一个循环里对一个非阻塞调用 recvfrom,我们成为轮询。应用程序不断轮询内核,看看是否已经准备好了某些操作。这通常就是 浪费CPU时间, 但这种模式偶尔会遇到。

4.1 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。

4.2 文件描述符fd

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

4.3 select

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

缺点:

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

4.4 poll

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

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有缺点

  • 大量的fd的数组被复制于用户空间和内核地址空间之间,而不管这样的复制是不是有意义。
  • poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

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

4.5 epoll

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

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

epoll 的优点

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

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

备注:JDK NIO的BUG,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU100%。官方声称JDK1.6版本的update18修复了该问题,但是直到DK1.7该问题仍旧存在,只不过该BUG发生的概率降低了一些而已,它并没有根本解决。

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

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

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

6.异步I/O

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

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

7.五种I/O模型的对比

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

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

8.零拷贝

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

9.缓存 IO

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

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

10.零拷贝技术分类

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

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

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

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

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

接下来,write系统调用再把用户缓冲区的内容拷贝到网络堆栈相关的内核缓冲区中,最后socket再把内核缓冲区的内容发送到网卡上。

从上图中可以看出,共产生了4此数据拷贝,即使使用了DMA来处理了与硬件的通讯,CPU仍然需要处理两次数据拷贝,与此同时,在用户态与内核态也发生了多次上下文切换,无疑也是加重了CPU负担。

在此过程中,我们并没有对文件内容做任何修改,那么在内核空间和用户空间来回拷贝数据无疑就是一种浪费,而零拷贝主要就是为了解决这种低效。

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

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

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

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

同样的,看图很简单:

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

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

  • 为SIGBUS信号建立信号处理程序,当遇到SIGBUS信号时,信号处理程序简单的返回,write系统调用在被中断之前会返回已经写入的字节数,并且errno会被设置成success,但是这是一种糟糕的处理办法,因为你并没有解决问题的实质核心。
  • 使用文件租借锁,通常我们使用这种办法,在文件描述符上使用租借锁,我们为一个文件向内核申请租借锁,当其他进程想要截断这个文件的时候,内核会向他们发送一个实时的 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;
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值