I/O总结

I/O

1.定义

I/O 描述了计算机系统与外部设备之间通信的过程。

从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。

当应用程序发起 I/O 调用后,会经历两个步骤:

  1. 内核等待 I/O 设备准备好数据
  2. 内核将数据从内核空间拷贝到用户空间。

2.Linux I/O 读写方式

1. I/O中断

在 DMA 技术出现前,应用程序与磁盘间的 I/O 操作都通过 CPU 中断完成。外部存储设备采用中断方式主动通知 CPU,CPU 负责拷贝数据到内核缓冲区,再拷贝到用户缓冲区,每次就会有上下文切换的开销及 CPU 拷贝的时间

screen-1527758

2.DMA直接存储

DMA 全称叫直接内存存取(Direct Memory Access),是一种允许外围设备绕过CPU直接访问系统主存的机制。每次交换可以传送一个数据块,在每个数据块的传送期间无需CPU的干预。

特点:

(1)内存与设备之间以数据块为单位进行数据传输,即每次至少传输一个数据块;

(2)DMA控制器获得总线控制权而直接与内存进行数据交换,CPU不介入数据传输事宜;

(3)CPU仅仅在数据块传送开始和结束时进行干预,而数据块的传输和I/O管理均由DMA控制器负责。

screen-1531490

3.I/O模型

1.BIO

应用程序中进程在发起IO调用后至内核执行IO操作返回结果之前,若发起系统调用的线程一直处于等待状态,则此次IO操作为阻塞IO。阻塞IO简称BIO,Blocking IO。其处理流程如下图所示:

BIO

从上图可知当用户进程发起IO系统调用后,内核从准备数据到拷贝数据到用户空间的两个阶段期间用户调用线程选择阻塞等待数据返回。

缺点:

如果内核数据需要耗时很久才能准备好,那么用户进程将被阻塞,浪费性能。为了提升应用的性能,虽然可以通过多线程来提升性能,但线程的创建依然会借助系统调用,同时多线程会导致频繁的线程上下文的切换。

一个socket连接一个处理线程,典型的一请求一应答,缺点是缺乏弹性伸缩能力,线程可是很宝贵的资源。

2.NIO

传统的****NIO 模型

那就是用户进程在发起系统调用时指定为非阻塞,内核接收到请求后,就会立即返回,然后用户进程通过轮询的方式来拉取处理结果。也就是如下图所示:

NIO

非阻塞IO虽然相对于阻塞IO大幅提升了性能,但依旧不是完美的解决方案,其依然存在性能问题,也就是频繁的轮询导致频繁的系统调用,会耗费大量的CPU资源。比如当并发很高时,假设有1000个并发,那么单位时间循环内将会有1000次系统调用去轮询执行结果,而实际上可能只有2个请求结果执行完毕,这就会有998次无效的系统调用,造成严重的性能浪费。有问题就要解决,那NIO问题的本质就是频繁轮询导致的无效系统调用

java中NIO

是基于I/ O多路复用实现的【实际上是epoll】

一个socket连接只有在特点时候才会发生数据传输IO操作,大部分时间这个“数据通道”是空闲的,但还是占用着线程。NIO作出的改进就是“一个请求一个线程”,在连接到服务端的众多socket中,只有需要进行IO操作的才能获取服务端的处理线程进行IO。这样就不会因为线程不够用而限制了socket的接入。epoll是NIO的具体实现。NIO的底层实现原理

IO多路复用之select/poll/epoll

1.select

I/O Multiplexing

(1)进行select系统调用,查询可以读的连接。kernel会查询所有select的可查询socket列表,当任何一个socket中的数据准备好了,select就会返回。

当用户进程调用了select,那么整个线程会被block(阻塞掉)。

(2)用户线程获得了目标连接后,发起read系统调用,用户线程阻塞。内核开始复制数据。它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存),然后kernel返回结果。

(3)用户线程才解除block的状态,用户线程终于真正读取到数据,继续执行。

缺点:

select有一个限制,就是存在连接数限制,针对于此,又提出了poll。其与select相比,主要是解决了连接限制。

select/poll 虽然解决了NIO重复无效系统调用用的问题,但同时又引入了新的问题。问题是:

  1. 用户空间和内核空间之间,大量的数据拷贝
  2. 内核循环遍历IO状态,浪费CPU时间

换句话说,select/poll虽然减少了用户进程的发起的系统调用,但内核的工作量只增不减。在高并发的情况下,内核的性能问题依旧。所以select/poll的问题本质是:内核存在无效的循环遍历。

2.epoll

epoll

epoll相较于select/poll,多了两次系统调用,其中epoll_create建立与内核的连接,epoll_ctl注册事件,epoll_wait阻塞用户进程,等待IO事件。

NIO的底层实现原理

Java NIO的实现是这样的,NIO包中主要有Channel、Buffer、Selector这几种对象。

  • Channel:NIO把它支持的I/O对象抽象为Channel,模拟了通信连接,用户可以通过它读取和写入数据。
  • Buffer:Buffer是一块连续的内存区域,一般作为Channel收发数据的载体出现。所有数据都通过Buffer对象来处理。
  • Selector:Selector类提供了监控一个和多个通道当前状态的机制。只要Channel向Selector注册了某种特定的事件,Selector就会监听这些事件是否会发生,一旦发生某个事件,便会通知对应的Channel。使用选择器,借助单一线程,就可对数量庞大的活动I/O通道实施监控和维护。

流程:

  1. 向Selector对象注册感兴趣的事件
  2. 从Selector中获取感兴趣的事件
  3. 根据不同的事件进行相应的处理

举个例子:

服务端的selector上注册了读事件,某时刻客户端给服务端发送了一些数据,阻塞I/O这时会调用read()方法阻塞地读取数据,而NIO的服务端会在selector中添加一个读事件。服务端的处理线程会轮询地访问selector,如果访问selector时发现有感兴趣的事件到达,则处理这些事件,如果没有感兴趣的事件到达,则处理线程会一直阻塞直到感兴趣的事件到达为止。

select\poll\epoll

select, poll, epoll 都是I/O多路复用的具体的实现,epoll是NIO的具体实现

Nginx的高并发得益于其采用了epoll模型,Apache就没采用

  1. 因为一个线程只能处理一个套接字的I/O事件,如果想同时处理多个,可以利用非阻塞忙轮询的方式。
  2. 我们只要把所有流从头到尾查询一遍,就可以处理多个流了,但这样做很不好,因为如果所有的流都没有I/O事件,白白浪费CPU时间片。
  3. 正如有一位科学家所说,计算机所有的问题都可以增加一个中间层来解决,同样,为了避免这里cpu的空转,我们不让这个线程亲自去检查流中是否有事件,而是引进了一个代理(一开始是select,后来是poll,select只能监听1024个连接,而且会修改传入的参数数组,poll去掉了连接限制,而且不再修改传入的参数数组),这个代理很牛,它可以同时观察许多流的I/O事件,如果没有事件,代理就阻塞,线程就不会挨个挨个去轮询了。很简单很感动后方可恢复到
  4. 但是依然有个问题,我们从select\poll那里仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
  5. 因此有了epoll,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。复杂度降低到了O(1)。不能不说epoll跟select相比,是质的飞跃。
epoll概述
  1. 设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?

  2. 在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

  3. epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统,把原先的select/poll调用分成了3个部分:

1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)

2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字

3)调用epoll_wait收集发生的事件的连接

  1. 如此一来,要实现上面说的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。

应用:

Nginx的高并发得益于其采用了epoll模型,Apache就没采用,传统Apache都是多进程或者多线程来工作

epoll底层实现原理

一句话描述就是:三步曲。

第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。

第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。

第三部:epoll_wait()系统调用。通过此调用收集收集在epoll监控中已经发生的事件。

流程概述:

epoll_create()创建一个eventpoll结构体,里面包含红黑树和双链表。epoll_ctl()将所有连接句柄数据放入红黑树,如果连接有动作,就把连接放入双链表。epoll_wait()检查双链表,有连接就返回给用户。

流程详解:

  1. 当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示

    struct eventpoll{
        ……
     /*红黑树的根节点,这棵树中储存这所有要添加到epoll中需要监控的事情 */
        struct rb_root rbr;
     /* 双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件  */
        struct list_head rdllink;
    }
    
  2. 每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

  3. 而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。

  4. 在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:

struct epitem{
    struct rb_node rbn;//红黑树节点
    struct list_head rdllink;//双向链表节点
    struct epoll_filefd ffd;//事件句柄信息
    struct eventpolll *ep;//指向其所属的eventpoll对象
    struct epoll_event enent;//期待发生的事件
}
  1. 当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。

从上面的讲解可知:通过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。

epoll的水平触发和边缘触发

一句话解释:

  • 水平触发会一直通知,边缘触发只会通知一次。
  • select(),poll()模型都是水平触发模式,信号驱动IO是边缘触发模式,epoll()模型即支持水平触发,也支持边缘触发,默认是水平触发。

Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你!!!如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率!!!

Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符!!!

阻塞IO:当你去读一个阻塞的文件描述符时,如果在该文件描述符上没有数据可读,那么它会一直阻塞(通俗一点就是一直卡在调用函数那里),直到有数据可读。当你去写一个阻塞的文件描述符时,如果在该文件描述符上没有空间(通常是缓冲区)可写,那么它会一直阻塞,直到有空间可写。以上的读和写我们统一指在某个文件描述符进行的操作,不单单指真正的读数据,写数据,还包括接收连接accept(),发起连接connect()等操作…

非阻塞IO:当你去读写一个非阻塞的文件描述符时,不管可不可以读写,它都会立即返回,返回成功说明读写操作完成了,返回失败会设置相应errno状态码,根据这个errno可以进一步执行其他处理。它不会像阻塞IO那样,卡在那里不动!!!

应用场景:

监听的socket文件描述符我们用sockfd,对于即要读写的文件描述符用connfd代替

  1. 对于监听的sockfd,最好使用水平触发模式,边缘触发模式会导致高并发情况下,有的客户端会连接不上。如果非要使用边缘触发,网上有的方案是用while来循环accept()。
  2. 对于读写的connfd,水平触发模式下,阻塞和非阻塞效果都一样,不过为了防止特殊情况,还是建议设置非阻塞。
  3. 对于读写的connfd,边缘触发模式下,必须使用非阻塞IO,并要一次性全部读写完数据。
select、poll、epoll 区别总结:

1、支持一个进程所能打开的最大连接数

select:单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。

poll:poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的。

epoll:虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。

2、FD剧增后带来的IO效率问题

select:因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。

poll:同上

epoll:因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。

3、 消息传递方式

select:内核需要将消息传递到用户空间,都需要内核拷贝动作

poll:同上

epoll:epoll通过内核和用户空间共享一块内存来实现的。

零拷贝技术

1.传统 I/O

先来看看传统方式,在进行一次读写时共涉及了4次上下文切换,2次 DMA 拷贝以及2次 CPU 拷贝。

screen-1535441

1.mmap技术

MMAP 是数据不会到达用户空间内存,只会存在于系统空间的内存上,用户空间与系统空间共用同一个缓冲区【内存映射技术】,两者通过映射关联

screen-1773287

整个 MMAP 过程,发生了 4 次上下文切换 + 1 次 CPU 拷贝 + 2 次 DMA 拷贝。

工作原理是直接利用操作系统的Page来实现文件到物理内存的直接映射。完成映射之后你对物理内存的操作会被同步到硬盘上(操作系统在适当的时候)。

缺点:

写到mmap中的数据并没有被真正的写到硬盘,操作系统会在程序主动调用flush的时候才把数据真正的写到硬盘

javaNIO/mmap的API

Java NIO,提供了一个 MappedByteBuffer 类可以用来实现内存映射。MappedByteBuffer只能通过调用FileChannel的map()取得,再没有其他方式。

由于MappedByteBuffer申请的是直接内存【jvm】,因此不受Minor GC控制,只能在发生Full GC时才能被回收,因此Java提供了DirectByteBuffer类来改善这一情况。它是MappedByteBuffer类的子类,同时它实现了DirectBuffer接口,维护一个Cleaner对象来完成内存回收。因此它既可以通过Full GC来回收内存,也可以调用clean()方法来进行回收

2.sendfile

从 Linux 2.4 版本开始,DMA 自带了收集功能,可以将对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区( socket buffer),由DMA 根据这些信息直接将内核缓冲区的数据拷贝到网卡设备中.

image-20200610154454498

这次只发生 2 次上下文切换 + 2 次 DMA 数据拷贝。

Java中NIO对sendFile的API

Java NIO的FileChannel的transferTo()和transferFrom()方法实现零拷贝。transferTo()和transferFrom()并不保证一定能使用零拷贝,实际上是否能使用零拷贝与操作系统相关,如果操作系统提供sendfile这样的零拷贝系统调用,则这两个方法会通过这样的系统调用充分利用零拷贝的优势,否则并不能通过这两个方法本身实现零拷贝。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值