ZhouXiangの博客 后端工程师&机器学习爱好者

Java学习系列之IO

2019-10-19
 

本文系记录对Java中I/O的学习资料,如有异议,欢迎联系我讨论修改。PS:图侵删!如果看不清图请访问: 传送门

PIO与DMA

PIO是上古时代的I/O方式,要想从磁盘中读取文件到内存中,数据必须经过CPU存储转发。显然,这种方式并不合理,使得CPU消耗大量时间读取文件。后来,DMA(直接内存访问)取代了PIO,它可以不经过CPU而直接令磁盘和内存进行数据交换。在 DMA 模式下,CPU 只需要向 DMA 控制器下达指令,让DMA控制器来处理数据的传送即可,DMA控制器通过系统总线来传输数据,传送完毕再通知CPU,这样就在很大程度上降低了CPU占用率,大大节省了系统资源,而它的传输速度与PIO的差异其实并不十分明显,因为这主要取决于慢速设备的速度。

系统及Linux中的I/O

I/O针对操作对象的大致分类

  • 磁盘I/O
  • 网络I/O
  • 内存映射I/O
  • Direct I/O
  • 数据库I/O

I/O按传统概念传统分类

  • 基于字节操作的InputStream/OutputStream
  • 基于字符操作的Writer/Reader
  • 基于磁盘的文件I/O
  • 基于网络的Socket-I/O

I/O基于是否阻塞,是否同步分类

  • BIO(同步&阻塞)
  • NIO(同步&非阻塞)
  • AIO(异步)

I/O的大致流程

  • 进程向OS请求数据
  • OS把系统外部数据加载到内核的缓冲区中
  • OS把内核的缓冲区拷贝到进程的缓冲区中
  • 进程获得数据,执行相关功能

Unix中的I/O模型

IO过程主要分两个阶段:

  • 数据准备阶段
  • 内核空间复制回用户进程缓冲区空间

无论阻塞式IO还是非阻塞式IO,都是同步IO模型,区别就在与第一步是否完成后才返回,但第二步都需要当前进程去完成。异步IO就是从第一步开始就返回,直到第二步完成后才会返回一个消息,也就是说,非阻塞能够让你在第一步时去做其它的事情,而真正的异步IO能让你第二步的过程也能去做其它事情。这里就在说一下select,poll和epoll这几个IO复用方式,这时你就会了解它们为什么是同步IO了,以epoll为例,在epoll开发的服务器模型中,epoll_wait()这个函数会阻塞等待就绪的fd,将就绪的fd拷贝到epoll_events集合这个过程中也不能做其它事(虽然这段时间很短,所以epoll配合非阻塞IO是很高效也是很普遍的服务器开发模式–同步非阻塞IO模型)。有人把epoll这种方式叫做同步非阻塞(NIO),因为用户线程需要不停地轮询,自己读取数据,看上去好像只有一个线程在做事情,也有人把这种方式叫做异步非阻塞(AIO),因为毕竟是内核线程负责扫描fd列表,并填充事件链表的,个人认为真正理想的异步非阻塞,应该是内核线程填充事件链表后,主动通知用户线程,或者调用应用程序事先注册的回调函数来处理数据,如果还需要用户线程不停的轮询来获取事件信息,就不是太完美了,所以也有不少人认为epoll是伪AIO,还是有道理的。

Unix中I/O的分类

  • 阻塞I/O:等到数据返回前不执行其他操作。阻塞I/O是socket的默认设置,程序调用recvfrom产生一个系统调用,kernel收到该调用请求后有两个步骤,第一是等待数据准备好,第二是将数据从内核空间拷贝到用户空间然后返回OK,用户空间收到系统调用返回后才会继续程序流的执行

    阻塞I/O是socket的默认设置,其模型如上图所示,程序调用recvfrom产生一个系统调用,kernel收到该调用请求后有两个步骤,第一是等待数据准备好,第二是将数据从内核空间拷贝到用户空间然后返回OK,用户空间收到系统调用返回后才会继续程序流的执行。

  • 非阻塞I/O:立即返回,设置描述符为非阻塞,进程自己一直检查是否可读。Socket使用非阻塞IO模型需要对socket进行另行设置。内核收到系统调用后,若数据未准备好立即返回error,用户进程收到error会继续产生系统调用,直到数据准备好了并被拷贝到用户空间

  • I/O复用:相比于非阻塞I/O,具有更多的描述符,有一定异步的感觉,但是检查是否可读时需要阻塞。select/poll/epoll对应的是IO复用模型,优势是能够监听多个socket。用户进程调用select产生系统调用,kernel会监听所有select负责的socket,一旦有一个socket数据准备好了,kernel即返回,用户再去recvfrom产生系统调用将数据从内核空间读到用户空间

  • 信号驱动:采用信号机制等待,不用监视描述符,而且不用阻塞着等待数据到来,被动等待信号通知。用户程序注册一个信号handler,然后继续做后续的事情,当内核数据准备好了会发送一个信号,程序调用recvfrom进行系统调用将数据从内核空间拷贝到用户空间

  • 异步I/O:完全异步。aio_read产生系统调用,kernel在数据准备好后将数据从内核空间拷贝到用户空间后返回一个信号告知read数据成功,整个过程程序调用aio_read后就继续执行其他部分直到收到信号,调用handler处理


严格意义上的异步,没有任何阻塞。而前四种I/O,都有不同程度上的阻塞,而且都有一个共同的阻塞:内核拷贝数据到进程空间时需要等待。

Linux内核中select/poll/epoll工作原理

综述:select,poll,epoll都是I/O多路复用的机制。I/O多路复用可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

select:首先创建事件的描述符集合。对于一个描述符,可以关注其上的读事件、写事件以及异常事件,所以要创建三类事件的描述符集合,分别用来收集读事件描述符、写事件描述符以及异常事件描述符。select调用时,首先将时间描述符集合fd_set从用户空间拷贝到内核空间;注册回调函数并遍历所有fd,调用其poll方法,poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个掩码给fd赋值,如果遍历完所有fd后依旧没有一个可以读写就绪的mask掩码,则会使进程睡眠;如果已过超时时间还是未被唤醒,则调用select的进程会被唤醒并获得CPU,重新遍历fd判断是否有就绪的fd;最后将fd_set从内核空间拷贝回用户空间。

select缺点:

  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  • select支持的文件描述符数量太小了,默认是1024

poll:poll是select的优化实现版,poll使用pollfd结构而不是select的fd_set结构。select需要为读事件、写事件和异常事件分别创建一个描述符集合,轮询时需要分别轮询这三个集合。而poll库只需要创建一个集合,在每个描述符对应的结构上分别设置读事件、写事件或者异常事件,最后轮询时可同时检查这三类时间是否发生。

epoll:select与poll中,都创建一个待处理事件列表,然后把这个列表发送给内核,返回的时候再去轮训这个列表,以判断事件是否发生。在描述符比较多的时候,效率极低。epoll将文件描述符列表的管理交给内核负责,每次注册新的事件时,将fd拷贝仅内核,epoll保证fd在整个过程中仅被拷贝一次,避免了反复拷贝重复fd的巨大开销。此外,一旦某个事件发生时,内核就把发生事件的描述符列表通知进程,避免对所有描述符列表进行轮询。最后,epoll没有文件描述符的限制,fd上限是系统可以打开的最大文件数量,通常远远大于2048。

Linux内核中select/poll/epoll区别

select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

Linux内核中select/poll/epoll各自的应用场景

  • select:timeout 参数精度为 1ns,而 poll 和 epoll 为 1ms,因此select更加适用于实时性要求比较高的场景,比如核反应堆的控制。select 可移植性更好,几乎被所有主流平台所支持。
  • poll:poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select
  • epoll:只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接;需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势;需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试

Java中的IO


源码:https://github.com/ITDragonBlog/daydayup/tree/master/Netty/socket-io

BIO

BIO全称Block-IO是一种阻塞同步的通信模式。我们常说的Stock IO一般指的是BIO。BIO是一种比较传统的通信方式,模式简单,使用方便。但并发处理能力低,通信耗时,依赖网速。服务器通过一个Acceptor线程负责监听客户端请求和为每个客户端创建一个新的线程进行链路处理。典型的一请求一应答模式。若客户端数量增多,频繁地创建和销毁线程会给服务器打开很大的压力。后改良为用线程池的方式代替新增线程,被称为伪异步IO。BIO模型中通过Socket和ServerSocket完成套接字通道的实现。阻塞,同步,建立连接耗时。

NIO

NIO 全称New IO,也叫Non-Block IO是一种非阻塞同步的通信模式。NIO 相对于BIO来说一大进步。客户端和服务器之间通过Channel通信。NIO可以在Channel进行读写操作。这些Channel都会被注册在Selector多路复用器上。Selector通过一个线程不停的轮询这些Channel。找出已经准备就绪的Channel执行IO操作。NIO 通过一个线程轮询,实现千万个客户端的请求,这就是非阻塞NIO的特点。

NIO的几个关键技术点:

  • 缓冲区Buffer:它是NIO与BIO的一个重要区别。BIO是将数据直接写入或读取到Stream对象中。而NIO的数据操作都是在缓冲区中进行的。缓冲区实际上是一个数组。Buffer最常见的类型是ByteBuffer,另外还有CharBuffer,ShortBuffer,IntBuffer,LongBuffer,FloatBuffer,DoubleBuffer。
  • 通道Channel:和流不同,通道是双向的。NIO可以通过Channel进行数据的读,写和同时读写操作。通道分为两大类:一类是网络读写(SelectableChannel),一类是用于文件操作(FileChannel),我们使用的SocketChannel和ServerSocketChannel都是SelectableChannel的子类。
  • 多路复用器Selector:NIO编程的基础。多路复用器提供选择已经就绪的任务的能力。就是Selector会不断地轮询注册在其上的通道(Channel),如果某个通道处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以取得就绪的Channel集合,从而进行后续的IO操作。服务器端只要提供一个线程负责Selector的轮询,就可以接入成千上万个客户端,这就是JDK NIO库的巨大进步。
  • NIO模型中通过SocketChannel和ServerSocketChannel完成套接字通道的实现。非阻塞/阻塞,同步,避免TCP建立连接使用三次握手带来的开销。

AIO

AIO 也叫NIO2.0 是一种非阻塞异步的通信模式。在NIO的基础上引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现。AIO并没有采用NIO的多路复用器,而是使用异步通道的概念。其read,write方法的返回类型都是Future对象。而Future模型是异步的,其核心思想是:去主函数等待时间。AIO模型中通过AsynchronousSocketChannel和AsynchronousServerSocketChannel完成套接字通道的实现。非阻塞,异步。

BIO和NIO的区别是什么?分别适合于什么场景?

  • IO是面向流(字节流和字符流)操作的,而NIO是面向缓冲区操作的
  • IO是阻塞性IO,而NIO是非阻塞性IO
  • IO不支持选择器,而NIO支持选择器

AIO和NIO的区别

  • NIO需要使用者线程不停地轮训IO对象,来确定是否有数据准备好并可读
  • AIO在数据准备好后通知使用者,避免了使用者的不断轮训

BIO、NIO和AIO的区别

  • IO 阻塞同步通信模式,客户端和服务器连接需要三次握手,使用简单,但吞吐量小
  • NIO 非阻塞同步通信模式,客户端与服务器通过Channel连接,采用多路复用器轮询注册的Channel。提高吞吐量和可靠性
  • AIO 非阻塞异步通信模式,NIO的升级版,采用异步通道实现异步通信,其read和write方法均是异步方法

同步阻塞,同步非阻塞,异步非阻塞的工作流程

  • 同步阻塞IO : 用户进程发起一个IO操作以后,必须等待IO操作的真正完成后,才能继续运行
  • 同步非阻塞IO: 用户进程发起一个IO操作以后,可做其它事情,但用户进程需要经常询问IO操作是否完成,这样造成不必要的CPU资源浪费
  • 异步非阻塞IO: 用户进程发起一个IO操作然后,立即返回,等IO操作真正的完成以后,应用程序会得到IO操作完成的通知

Java-I/O中涉及到的设计模式

在Java的I/O中使用到装饰器模式。下面以InputStream为例:

  • InputStream是抽象组件;
  • FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作;
  • FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能。例如 BufferedInputStream 为 FileInputStream 提供缓存的功能

在实际开发中,实例化一个具有缓存功能的字节流对象时,只需要在 FileInputStream 对象上再套一层 BufferedInputStream 对象即可,例如:

FileInputStream fileInputStream = new FileInputStream(filePath);
BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);

代码实例

NIO伪代码示例:

struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000};
ssize_t nbytes;
while (1) {
    /* 尝试读取 */
    if ((nbytes = read(fd, buf, sizeof(buf))) < 0) {
        if (errno == EAGAIN) { // 没数据到
            perror("nothing can be read");
        } else {
            perror("fatal error");
            exit(EXIT_FAILURE);
        }
    } else { // 有数据
        process_data(buf, nbytes);
    }
    // 处理其他事情,做完了就等一会,再尝试
    nanosleep(sleep_interval, NULL);
}

select逻辑伪代码:

struct timeval tv = {.tv_sec = 1, .tv_usec = 0};
ssize_t nbytes;
while(1) {
    FD_ZERO(&read_fds);
    setnonblocking(fd1);
    setnonblocking(fd2);
    FD_SET(fd1, &read_fds);
    FD_SET(fd2, &read_fds);
    // 把要监听的fd拼到一个数组里,而且每次循环都得重来一次...
    if (select(FD_SETSIZE, &read_fds, NULL, NULL, &tv) < 0) { // block住,直到有事件到达
        perror("select出错了");
        exit(EXIT_FAILURE);
    }
    for (int i = 0; i < FD_SETSIZE; i++) {
        if (FD_ISSET(i, &read_fds)) {
            /* 检测到第[i]个读取fd已经收到了,这里假设buf总是大于到达的数据,所以可以一次read完 */
            if ((nbytes = read(i, buf, sizeof(buf))) >= 0) {
                process_data(nbytes, buf);
            } else {
                perror("读取出错了");
                exit(EXIT_FAILURE);
            }
        }
    }
}

epoll逻辑伪代码:

#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int nfds, epfd, fd1, fd2;

// 假设这里有两个socket,fd1和fd2,被初始化好。
// 设置为non blocking
setnonblocking(fd1);
setnonblocking(fd2);

// 创建epoll
epfd = epoll_create(MAX_EVENTS);
if (epollfd == -1) {
    perror("epoll_create1");
    exit(EXIT_FAILURE);
}

//注册事件
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = fd1;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd1, &ev) == -1) {
    perror("epoll_ctl: error register fd1");
    exit(EXIT_FAILURE);
}
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd2, &ev) == -1) {
    perror("epoll_ctl: error register fd2");
    exit(EXIT_FAILURE);
}

// 监听事件
for (;;) {
    nfds = epoll_wait(epdf, events, MAX_EVENTS, -1);
    if (nfds == -1) {
        perror("epoll_wait");
        exit(EXIT_FAILURE);
    }

    for (n = 0; n < nfds; ++n) { // 处理所有发生IO事件的fd
        process_event(events[n].data.fd);
        // 如果有必要,可以利用epoll_ctl继续对本fd注册下一次监听,然后重新epoll_wait
    }
}

其他NIO详细介绍:https://www.jianshu.com/p/ef418ccf2f7d

阻塞vs非阻塞vs同步vs异步


在处理I/O时,阻塞和非阻塞都是同步I/O。只有使用特殊的API才是异步I/O。

同步与异步

同步与异步关注的是消息通信机制。同步指的是发出调用后,在没得到结果之前,该调用不返回。异步则相反,调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。同步可以保证同样的操作具有可复现性。异步一定是非阻塞的,不存在异步阻塞这种定义。

阻塞与非阻塞

阻塞与非阻塞关注的是程序在等待调用结果(消息、返回值)时的状态。阻塞调用是指调用结果返回之前,当前线程会被挂起。非阻塞调用不会阻塞当前线程,会继续做其他事情或偶尔来询问下调用结果(轮询)。

阻塞这个词是与系统调用System Call紧紧联系在一起的,因为要让一个进程进入 等待(waiting)的状态,要么是它主动调用wait()或sleep()等挂起自己的操作, 另一种就是它调用 System Call, 而 System Call 因为涉及到了I/O操作,不能立即完成,于是内核就会先将该进程置为等待状态,调度其他进程的运行,等到它所请求的I/O操作完成了以后,再将其状态更改回ready。

操作系统内核在执行SystemCall时,CPU需要与I/O设备完成一系列物理通信上的交互, 其实再一次会涉及到阻塞和非阻塞的问题, 例如,操作系统发起了一个读硬盘的请求后,其实是向硬盘设备通过总线发出了一个请求,它即可以阻塞式地等待IO设备的返回结果,也可以非阻塞式的继续其他的操作。在现代计算机中,这些物理通信操作基本都是异步完成的,即发出请求后,等待I/O设备的中断信号后,再来读取相应的设备缓冲区。但是,大部分操作系统默认为用户级应用程序提供的都是阻塞式的系统调用 (blocking systemcall)接口,因为阻塞式的调用,使得应用级代码的编写更容易(代码的执!行顺序和编写顺序是一致的)。

非阻塞I/O 系统调用( nonblocking system call)的另一个替代品是异步I/O系统调用 (asychronous system call)。与非阻塞I/O系统调用类似,asychronous system call 也是会立即返回,不会等待I/O操作的完成,应用程序可以继续执行其他的操作,等到I/O操作完成了以后,操作系统会通知调用进程(设置一个用户空间特殊的变量值或者触发一个signal或者产生一个软中断或者调用应用程序的回调函数)。

非阻塞和异步的区别

  • 一个非阻塞I/O系统调用read()操作立即返回的是任何可以立即拿到的数据,可以是完整的结果,也可以是不完整的结果,还可以是一个空值
  • 异步I/O系统调用read()结果必须是完整的,但是这个操作完成的通知可以延迟到将来的一个时间点

Reactor模式

传统I/O模式的弊端

原始的I/O模式使用while循环,不断监听端口是否有新的套接字连接,如果有,那么就调用一个处理函数处理,类似:

while (true) { 
    socket = accept(); 
    handle(socket) 
} 

然而,这么做的并发性就很差。前一个请求没处理完,后面的请求要阻塞着。如果改用多线程方式,按照一请求一线程的方式,则性能开销过大。

Reactor模式简介

Reactor是一种和I/O相关的设计模式,一种基于事件驱动的模型,有一个或多个并发输入源,采用多路复用将事件分发给相应的Handler处理。Reactor实际上采用了分而治之和事件驱动的思想。一个连接里完整的网络处理过程一般分为accept,read,decode,process,encode,send这几步。而Reactor模式将每个步骤(read,decode,process,encode,send)映射为一个Task,服务端线程执行的最小逻辑单元不再是一个完整的网络请求,而是Task,且采用非阻塞方式执行。每个Task对应特定的网络事件,当Task准备就绪时,Reactor收到对应的网络事件通知,并将Task分发给绑定了对应网络事件的Handler执行。维基百科上对 Reactor模式定义如下:

  • 一种事件驱动模型
  • 处理多个输入
  • 采用多路复用将事件分发给相应的Handler处理

Reactor的优缺点

优点:

  • 响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
  • 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
  • 可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源;
  • 可复用性,Reactor框架本身与具体事件处理逻辑无关,具有很高的复用性; 缺点:
  • 相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
  • Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持,如果要自己实现Synchronous Event Demultiplexer可能不会有那么高效。
  • Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-Per-Connection或许是一个更好的选择,或则此时使用Proactor模式。

Reactor中的几类角色

  • Reactor: 负责响应事件,将事件分发绑定了该事件的Handler处理
  • Handler: 事件处理器,绑定了某类事件,负责执行对应事件的任务对事件进行处理
  • Acceptor:Handler的一种,绑定了connect事件,当客户端发起connect请求时,Reactor会将accept事件分发给Acceptor处理(相当于计算前先握个手)
  • Processor:实际处理业务的Handler
  • Dispatch:为事件分配指定的Handler

Reactor中单线程模型及其执行流程

  • 主线程中,Reactor对象通过select监听连接事件,收到事件后通过dispatch进行分发
  • 如果是连接建立的事件,则由Acceptor处理,Acceptor通过 accept接受连接,并创建一个 Handler 来处理连接后续的各种事件。
  • 如果不是连接建立事件,则Reactor会调用连接对应的Handler来进行相应Handler只负责响应事件,不进行业务处理,Handler通过read读取到数据后,会发给processor进行业务处理
  • Processor会在独立的子线程中完成真正的业务处理,然后将响应结果发给主进程的Handler处理,Handler收到响应后通过send将响应结果返回给client

单Reactor单线程的优点:模型简单,没有多线程,进程通信,竞争的问题,全部都在一个线程中完成。缺点:只有一个进程,无法发挥多核CPU的性能,只能采取部署多个系统来利用多核CPU,但这样会带来运维复杂度;Handler在处理某个连接上的业务时,整个进程无法处理其他连接的事件,很容易导致性能瓶颈。

Reactor中多线程模型及其执行流程

  • 主线程中,Reactor对象通过select 监听连接事件,收到事件后通过 dispatch进行分发
  • 如果是连接建立的事件,则由Acceptor处理,Acceptor通过accept接受连接,并创建一个Handler来处理连接后续的各种事件。
  • 如果不是连接建立事件,则Reactor会调用连接对应的Handler来进行相应Handler只负责响应事件,不进行业务处理,Handler通过read读取到数据后,会发给processor进行业务处理
  • Processor 会在独立的子线程中完成真正的业务处理,然后将响应结果发给主进程的Handler处理,Handler收到响应后通过send将响应结果返回给client

单Reactor多线程优点:能够充分利用多核多CPU的处理能力。缺点:多线程数据共享和访问比较复杂;Reactor承担所有事件的监听和响应,只在主线程中运行,瞬间高并发时会成为性能瓶颈。

Reactor中多进程模型及其执行流程

  • 主进程中mainReactor对象通过 select监控连接建立事件,收到事件后通过 Acceptor接收,将新的连接分配给某个子进程。
  • 子进程中的 subReactor 将 mainReactor 分配的连接加入连接队列进行监听,并创建一个 Handler 用于处理连接的各种事件
  • 当有新的事件发生时,subReactor 会调用里连接对应的 Handler 来响应
  • Handler完成read->业务处理->send的完整业务流程

多Reactor多进程/线程特点:主进程和子进程的职责非常明确,主进程只负责接收新连接,子进程负责完成后续的业务处理;主进程和子进程的交互很简单,主进程只需要把新的连接传递给子进程,子进程无需返回数据;子进程之间是相互独立的,无需同步共享之类的处理。

零拷贝技术

零拷贝相关的几种技术

朴素暴力法

以一个网络服务守护进程为例,考虑它在将存储在文件中的信息通过网络传送给客户这样的简单过程中,所涉及的操作。下面是其中的部分简单代码:

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

上述两行代码实质上分为四步,导致目标数据至少被复制了4次,同时发生了4次用户/内核态交换:

  • 系统调用read导致了从用户空间到内核空间的上下文切换。DMA模块从磁盘中读取文件内容,并将其存储在内核空间的缓冲区内,完成了第1次复制。
  • 数据从内核空间缓冲区复制到用户空间缓冲区,之后系统调用read返回,这导致了从内核空间向用户空间的上下文切换。此时,需要的数据已存放在指定的用户空间缓冲区内(参数tmp_buf),程序可以继续下面的操作。
  • 系统调用write导致从用户空间到内核空间的上下文切换。数据从用户空间缓冲区被再次复制到内核空间缓冲区,完成了第3次复制。不过,这次数据存放在内核空间中与使用的socket相关的特定缓冲区中,而不是步骤一中的缓冲区。
  • 系统调用返回,导致了第4次上下文切换。第4次复制在DMA模块将数据从内核空间缓冲区传递至协议引擎的时候发生,这与我们的代码的执行是独立且异步发生的。你可能会疑惑:“为何要说是独立、异步?难道不是在write系统调用返回前数据已经被传送了?write系统调用的返回,并不意味着传输成功——它甚至无法保证传输的开始。调用的返回,只是表明以太网驱动程序在其传输队列中有空位,并已经接受我们的数据用于传输。可能有众多的数据排在我们的数据之前。除非驱动程序或硬件采用优先级队列的方法,各组数据是依照FIFO的次序被传输的(图1中叉状的DMA copy表明这最后一次复制可以被延后)。
mmap系统调用

为了消除冗余的复制,产生了一种新的系统调用,该调用产生4次上下文交换以及三次拷贝,可替代read系统调用,例如:

tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);

步骤如下:

  • mmap系统调用导致文件的内容通过DMA模块被复制到内核缓冲区中,该缓冲区之后与用户进程共享,这样就内核缓冲区与用户缓冲区之间的复制就不会发生。
  • write系统调用导致内核将数据从内核缓冲区复制到与socket相关联的内核缓冲区中。
  • DMA模块将数据由socket的缓冲区传递给协议引擎时,第3次复制发生

通过mmap系统调用,内核复制次数被减半(直接从内核的buffer向同在内核的socket-buffer拷贝),一定程度上提高了效率。然而,性能的改进需要付出代价的;是用mmap与write这种组合方法,存在着一些隐藏的陷阱。例如,考虑一下在内存中对文件进行映射后调用write,与此同时另外一个进程将同一文件截断的情形。此时write系统调用会被进程接收到的SIGBUS信号中断,因为当前进程访问了非法内存地址。对SIGBUS信号的默认处理是杀死当前进程并生成dump core文件——而这对于网络服务器程序而言不是最期望的操作。

Sendfile

sendfile系统调用在内核版本2.1中被引入,目的是简化通过网络在两个本地文件之间进行的数据传输过程。sendfile系统调用的引入,不仅减少了数据复制(仅3次),还减少了上下文切换的次数(仅两次)。使用方法如下:

  • sendfile系统调用导致文件内容通过DMA模块被复制到某个内核缓冲区,之后再被复制到与socket相关联的缓冲区内
  • 当DMA模块将位于socket相关联缓冲区中的数据传递给协议引擎时,执行第3次复制
零拷贝

到此为止,我们已经能够避免内核进行多次复制,然而我们还存在一分多余的副本。这份副本也可以消除吗?当然,在硬件提供的一些帮助下是可以的。为了消除内核产生的素有数据冗余,需要网络适配器支持聚合操作特性。该特性意味着待发送的数据不要求存放在地址连续的内存空间中;相反,可以是分散在各个内存位置。在内核版本2.4中,socket缓冲区描述符结构发生了改动,以适应聚合操作的要求——这就是Linux中所谓的”零拷贝“。这种方式不仅减少了多个上下文切换,而且消除了数据冗余。从用户层应用程序的角度来开,没有发生任何改动,所有代码仍然是类似下面的形式:

sendfile(socket, file, len);

步骤如下:

  • sendfile系统调用导致文件内容通过DMA模块被复制到内核缓冲区中
  • 数据并未被复制到socket关联的缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到socket缓冲区中。DMA模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制

由于数据实际上仍然由磁盘复制到内存,再由内存复制到发送设备,有人可能会声称这并不是真正的”零拷贝”。然而,从操作系统的角度来看,这就是”零拷贝”,因为内核空间内不存在冗余数据。应用”零拷贝”特性,出了避免复制之外,还能获得其他性能优势,例如更少的上下文切换,更少的CPU cache污染以及没有CPU必要计算校验和。


Similar Posts

Comments