网络 IO 模型简单介绍
一、同步阻塞 IO(BIO)
当用户线程调用了 read 系统调用,内核(kernel)就开始了 IO 的第一个阶段:准备数据。很多时候,数据在一开始还没有到达(比如,还没有收到一个完整的Socket数据包),这个时候 kernel 就要等待足够的数据到来。
当 kernel 一直等到数据准备好了,它就会将数据从 kernel 内核缓冲区,拷贝到用户缓冲区(用户内存),然后 kernel 返回结果。
从用户线程 read 系统调用开始,用户线程就进入阻塞状态,一直到 kernel 返回结果后,用户线程才解除 block 的状态,重新运行起来。
BIO 的特点:在内核进行 IO 执行的两个阶段,用户线程都被 block 了。
BIO 的优点:程序简单,在阻塞等待数据期间,用户线程挂起,用户线程基本不会占用 CPU 资源。
BIO 的缺点:一般情况下,会为每个连接配套一条独立的线程,或者说一条线程维护一个连接成功的 IO 流的读写。在并发量小的情况下,这个没有什么问题。但是,当在高并发的场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。因此,基本上,BIO 模型在高并发场景下是不可用的。
应用场景:文件的读写;桌面程序;网络情况非常良好同时连接数不多;
二、同步非阻塞 IO(NIO)
当用户线程调用了 read 系统调用,立即返回,不阻塞线程,用户线程需要不断地发起 IO 系统调用轮询数据是否准备好;
kernel 数据准备好后,用户线程发起系统调用,用户线程阻塞。内核开始复制数据,它就会将数据从 kernel 内核缓冲区,拷贝到用户缓冲区(用户内存),然后 kernel 返回结果。
用户线程解除 block 状态,重新运作起来。
NIO 的特点:应用程序的线程需要不断的进行 I/O 系统调用,轮询数据是否已经准备好,如果没有准备好,继续轮询,直到完成系统调用为止。
NIO 的优点:每次发起的 IO 系统调用,在内核的等待数据过程中可以立即返回,用户线程不会阻塞,实时性较好。
NIO 的缺点:需要不断的重复发起 IO 系统调用,这种不断的轮询,将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低。
NIO 模型在高并发场景下,也是不可用的。一般 web 服务器不直接使用这种 IO 模型,而是在其他 IO 模型中使用非阻塞 IO 这一特性。java 的实际开发中,也不会涉及这种 IO 模型。
应用场景:连接数不高(C < 1000),并且网络稳定,此种网络模型应用场景太好了,都是因为有 IO 多路复用。
三、IO 多路复用
当用户线程调用了 read 系统调用,用户线程不直接访问 kernel ,而是进行 select/poll/epoll(多路复用器)系统调用。当然,这里有一个前提,需要将目标网络连接,提前注册到 select/poll/epoll 的可查询 socket 列表中(这部分由 kernel 完成)。
用户线程进行 select/poll/epoll 系统调用,线程阻塞,kernel 会查询所有 select/poll/epoll 的可查询 socket 列表,当任何一个 socket 中的数据准备好了,select/poll/epoll 就会返回。
用户线程获得了目标连接后,发起 read 系统调用,线程阻塞,内核开始复制数据,它就会将数据从 kernel 内核缓冲区,拷贝到用户缓冲区(用户内存),然后 kernel 返回结果。
用户线程才解除 block 的状态,用户线程终于真正读取到数据,继续执行。
多路复用 IO 的特点:
- 建立在操作系统 kernel 内核能够提供的多路复用系统调用 select/poll/epoll 基础之上的,多路复用 IO 需要用到两个系统调用(system call), 一个 select/poll/epoll 查询调用,一个是 IO 的读取调用。
- 和 NIO 模型类似,多路复用 IO 需要轮询,需要有单独的线程不断的进行 select/poll/epoll 轮询,查找出可以进行 IO 操作的连接。
- 多路复用 IO 模型与前面的 NIO 模型是有关系的,对于每一个可以查询的 socket,一般都设置成为 non-blocking 模型。
多路复用 IO 的优点:用 select/poll/epoll 的优势在于,它可以同时处理成千上万个连接(connection)。与一条线程维护一个连接相比,I/O 多路复用不必创建线程,也不必维护这些线程,从而大大减小了系统的开销。
多路复用 IO 的缺点:本质上,select/poll/epoll 系统调用,属于同步 IO,也是阻塞 IO,需要在读写事件就绪后,自己负责进行读写,也就是说这个读写过程是阻塞的。
tips:
- "多路"指的是多个连接;"复用"指的是复用一个进程/线程进行监控。
- Java 的 NIO(New IO)技术,使用的就是 IO 多路复用模型。在 linux 系统上,使用的是 epoll 系统调用。
多路复用器
select 是一个主动模型,需要线程自己通过一个集合存放所有的 Socket,然后发生 I/O 变化的时候遍历。在 select 模型下,操作系统不知道哪个线程应该响应哪个事件,而是由线程自己去操作系统看有没有发生网络 I/O 事件,然后再遍历自己管理的所有 Socket,看看这些 Socket 有没有发生变化。
poll 提供了更优质的编程接口,但是本质和 select 模型相同。因此千级并发以下的 I/O,你可以考虑 select 和 poll,但是如果出现更大的并发量,就需要用 epoll 模型。
select 支持的文件描述符数量默认是1024;poll 没有最大连接数限制,因其基于链表存储。
select 和 poll 的主动式的 I/O 多路复用,对负责 I/O 的线程压力过大,因此通常会设计一个高效的中间数据结构作为 I/O 事件的观察者,线程通过订阅 I/O 事件被动响应,这就是响应式模型。在 Socket 编程中,最适合提供这种中间数据结构的就是操作系统的内核,事实上 epoll 模型也是在操作系统的内核中提供了红黑树结构。
epoll 模型在操作系统内核中提供了一个中间数据结构,这个中间数据结构会提供事件监听注册,以及快速判断消息关联到哪个线程的能力(红黑树实现,文件描述符构成了一棵红黑树,而红黑树的节点上挂着文件描述符对应的线程、线程监听事件类型以及相应程序)。因此在高并发 I/O 下,可以考虑 epoll 模型,它的速度更快,开销更小。
中间观察者需要一个快速能插入(注册过程)、查询(通知过程)一个整数(Socket 的文件描述符)的数据结构。综合来看,能够解决这个问题的数据结构中,跳表和二叉搜索树都是不错的选择。
tips: 一文搞懂select、poll和epoll区别
四、异步非阻塞IO(AIO)
当用户线程调用了 read 系统调用,用户线程立刻就能去做其它的事,用户线程不阻塞。
内核(kernel)就开始了 IO 的第一个阶段:准备数据,当 kernel 一直等到数据准备好了,它就会将数据从 kernel 内核缓冲区,拷贝到用户缓冲区(用户内存)。
然后,kernel 会给用户线程发送一个信号(signal),或者回调用户线程注册的回调接口,告诉用户线程 read 操作完成了。
用户线程读取用户缓冲区的数据,完成后续的业务操作。
AIO 的特点:
- 在内核 kernel 的等待数据和复制数据的两个阶段,用户线程都不是 block 的。
- 用户线程需要接受 kernel 的 IO 操作完成的事件,或者说注册 IO 操作完成的回调函数到操作系统的内核,因此,异步 IO 有的时候也叫做信号驱动 IO。
AIO 的缺点:需要完成事件的注册与传递,这里边需要底层操作系统提供大量的支持,去做大量的工作。
目前来说, Windows 系统下通过 IOCP 实现了真正的异步 I/O,但是,就目前的业界形式而言,Windows 系统,很少作为百万级以上或者说高并发应用的服务器操作系统来使用。
而在 Linux 系统下,异步 IO 模型在2.6版本才引入,目前并不完善。所以,这也是在 Linux 下,实现高并发网络编程时都是以 IO 复用模型模式为主。(https://github.com/netty/netty/issues/2515)