I/O

I/O是什么

在计算机系统中I/O就是输入(Input)和输出(Output)的意思。针对不同的操作对象,可以划分为磁盘 I/O 模型,网络 I/O 模型,内存映射 I/O , Direct I/O 、数据库 I/O 等,只要具有输入输出类型的交互系统都可以认为是I/O系统,也可以说I/O是整个操作系统数据交换与人机交互的通道,这个概念与选用的开发语言没有关系,是一个通用的概念。进程中的 IO 调用步骤大致可以分为以下四步:

  • 进程向操作系统请求数据
  • 操作系统把外部数据加载到内核缓冲区中
  • 操作系统把内核的缓冲区拷贝到进程缓冲区
  • 进程获得数据完成自己的功能

也可以精简为两个过程:

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

I/O分类

针对操作对象的大致分类

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

Java相关分类

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

阻塞与同步分类

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

Unix中的I/O模型

IO过程主要分两个阶段:

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

无论阻塞式 IO 还是非阻塞式 IO ,都是同步 IO 模型,区别就在与第一步是否完成后才返回,但第二步都需要当前进程去完成。
异步 IO 就是从第一步开始就返回,直到第二步完成后才会返回一个消息,也就是说,非阻塞能够让你在第一步时去做其它的事情,而真正的异步 IO 能让你第二步的过程也能去做其它事情。
以 epoll 为例,在 epoll 开发的服务器模型中,epoll_wait() 这个函数会阻塞等待就绪的 fd ,将就绪的 fd 拷贝到 epoll_events 集合这个过程中也不能做其它事(虽然这段时间很短,所以 epoll 配合非阻塞 IO 是很高效也是很普遍的服务器开发模式--同步非阻塞IO模型)。有人把 epoll 这种方式叫做同步非阻塞(NIO),因为用户线程需要不停地轮询,自己读取数据,看上去好像只有一个线程在做事情。也有人把这种方式叫做异步非阻塞(AIO),因为毕竟是内核线程负责扫描 fd 列表,并填充事件链表。

Unix中I/O的分类

阻塞I/O

image

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

非阻塞I/O

image

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

I/O复用

image

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

信号驱动

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

异步I/O

image

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

总结

image

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

图源:https://blog.csdn.net/u011910350/article/details/78251736

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 。

select/poll/epoll对比

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

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 的描述符存储在内核,不容易调试

简述PIO与DMA

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

零拷贝技术

朴素暴力法

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

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

image

上述两行代码实质上分为四步,导致目标数据至少被复制了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);

image

步骤如下:

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

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

Sendfile

image

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

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

零拷贝

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

sendfile(socket, file, len);

image

步骤如下:

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

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

Java-IO

Java中IO的基本分类

  • InputStream、OutputStream 基于字节操作的I/O
  • Writer、Reader 基于字符操作的I/O
  • File 基于磁盘操作的I/O
  • Socket 基于网络操作的I/O
  • 同步/异步

BIO

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

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 进行数据的读,写和同时读写操作(全双工?)。通道分为两大类:一类是网络读写,一类是用于文件操作
  • 多路复用器Selector:NIO 编程的基础。多路复用器提供选择已经就绪的任务的能力。就是 Selector 会不断地轮询注册在其上的通道(Channel),如果某个通道处于就绪状态,会被 Selector 轮询出来,然后通过 SelectionKey 可以取得就绪的 Channel 集合,从而进行后续的 IO 操作。服务器端只要提供一个线程负责 Selector 的轮询,就可以接入成千上万个客户端,这就是JDK NIO库的巨大进步
  • NIO 模型中通过 SocketChannel 和 ServerSocketChannel 完成套接字通道的实现。非阻塞/阻塞,同步,避免TCP建立连接使用三次握手带来的开销

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

AIO

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

从编程模式上来看 AIO 相对于 NIO 的区别在于,NIO 需要使用者线程不停的轮询 IO 对象,来确定是否有数据准备好可以读了。而 AIO 则是在数据准备好之后,才会通知数据使用者,这样使用者就不需要不停地轮询了。当然 AIO 的异步特性并不是 Java 实现的伪异步,而是使用了系统底层 API 的支持。在 Unix 系统下,采用了epoll IO 模型,而 windows 便是使用了 IOCP 模型。

BIO vs NIO

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

AIO vs NIO

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

Java-IO中涉及到的设计模式

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

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

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

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

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

在处理 I/O 时,阻塞和非阻塞都是同步 I/O 。只有使用特殊的 API 才是异步 I/O 。个人认为没有异步非阻塞一说,欢迎大佬指教。

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

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

同步与异步

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

阻塞与非阻塞

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

阻塞这个词是与系统调用 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 本身依然是同步的
  • 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销
  • 可扩展性,可以方便的通过增加 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 通过 accept 处理连接请求,然后创建一个
    Handler 对象处理连接完成后的后续业务处理
  • 如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler来响应
  • Handler 会完成 read -> 业务处理 -> send 的完整业务流程

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

PIO与DMA

DMA(Direct Memory Access, 直接内存访问)。
在介绍 I/O 模型之前,有必要简单地说说慢速 I/O 设备和内存之间的数据传输方式。

我们拿磁盘来说,很早以前,磁盘和内存之间的数据传输是需要 CPU 控制的,也就是如果我们读取磁盘文件到内存中, 数据要经过 CPU 存储转发,这种方式称为 PIO。显然这种方式非常不合理,需要占用大量的 CPU 时间来读取文件, 造成文件访问时系统几乎停止响应。

后来, DMA(Direct Memory Access, 直接内存访问) 取代了 PIO,它可以不经过 CPU 而直接进行磁盘和内存的数据交换。 在 DMA 模式下,CPU 只需要向 DMA 控制器下达指令,让 DMA 控制器来处理数据的传送即可, DMA 控制器通过系统总线来传输数据,传送完毕再通知 CPU,这样就在很大程度上降低了 CPU 占用率, 大大节省了系统资源,而它的传输速度与 PIO 的差异其实并不十分明显,因为这主要取决于慢速设备的速度。 可以肯定的是,PIO 模式的计算机我们现在已经很少见到了。