【异步网络编程】【Reactor模型】【多路复用】BIO、NIO和AIO
前言:
用户程序进行IO的读写,依赖于底层的IO读写,基本上会用到底层的read&write两大系统调用。在不同的操作系统中,IO读写的系统调用的名称可能不完全一样,但是基本功能是一样的。
这里涉及一个基础的知识:read系统调用,并不是直接从物理设备把数据读取到内存中;write系统调用,也不是直接把数据写入到物理设备。上层应用无论是调用操作系统的read,还是调用操作系统的write,都会涉及缓冲区。
具体来说,调用操作系统的read,是把数据从内核缓冲区复制到进程缓冲区;而write系统调用,是把数据从进程缓冲区复制到内核缓冲区。 也就是说,上层程序的IO操作,实际上不是物理设备级别的读写,而是缓存的复制。read&write两大系统调用,都不负责数据在内核缓冲区和物理设备(如磁盘)之间的交换,这项底层的读写交换,是由操作系统内核来完成的。
在用户程序中,无论是Socket的IO、还是文件IO操作,都属于上层应用的开发,它们的输入和输出的处理,在编程的流程上,都是一致的。
服务器端编程,经常需要构造高性能的网络应用,需要选用高性能的IO模型,这也是通关大厂面试必备的知识
常见的IO模型有四种:
同步和异步
同步是一个比较重要的概念,是针对一个事务的管控职责而言的,如果当前事务的推动管控职责一直在A方,那么称之为A方一直在同步,如果A把推动职责,当前焦点交给B,而A在等B转移过来的时候才负责对事务的职责,那么这个时候对A而言,这个事务是异步的,在计算机中这个掌控者就是线程;
同步阻塞IO(BIO)
首先,解释一下这里的阻塞与非阻塞:阻塞IO,指的是需要内核IO操作彻底完成后,才返回到用户空间执行用户的操作。阻塞指的是用户空间程序的执行状态。传统的IO模型都是同步阻塞IO。在Java中,默认创建的socket都是阻塞的。
体现在一个线程调用IO的时候,会挂起等待,然后Thread会进入blocked状态;这样线程资源就会被闲置,造成资源浪费,通常一个系统线程数是有限的,而且,Thread进入内核态也是很大的性能开销。而阻塞方式,意味着BIO必然是一个同步IO。
BIO还有一个显著的特点是面向流式Stream编程,特点是实现简单,但也意味着拓展性差。
其次,解释一下同步与异步:同步IO,是一种用户空间与内核空间的IO发起方式。同步IO是指用户空间的线程是主动发起IO请求的一方,内核空间是被动接受方。异步IO则在后期反过来,是指在IO返回时,系统内核是主动发起IO返回的一方,用户空间的线程是被动接受方。
同步非阻塞IO(用户态层面的非阻塞)
非阻塞IO,指的是用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用户空间执行用户的操作,即处于非阻塞的状态,与此同时内核会立即返回给用户一个状态值。
简单来说:阻塞是指用户空间(调用线程)一直在等待,而不能干别的事情;非阻塞是指用户空间(调用线程)拿到内核返回的状态值就返回自己的空间,IO操作可以干就干,不可以干,就去干别的事情。非阻塞IO要求socket被设置为NONBLOCK。
同步意味着不会产生会调,需要线程自身去同步IO是否完成,而非阻塞就是线程会立刻返回。
相对于BIO面向流式抽象思想编程,NIO是面向管道编程的,例如在Java中必谈的三个封装类Buffer、Channel、Sellector,就是管道编程的体现,Java1.4后提供的非阻塞 IO 的核心在于使用一个 Selector 来管理多个通道,可以是 SocketChannel,也可以是 ServerSocketChannel,将各个通道注册到 Selector 上,指定监听的事件。
之后可以只用一个线程来轮询这个 Selector,看看上面是否有通道是准备好的,当通道准备好可读或可写,然后才去开始真正的读写,这样速度就很快了。我们就完全没有必要给每个通道都起一个线程。如下面代码所示:
13
14 public class PlainNioServer {
15
16 public void serve(int port) throws IOException {
17
18 ServerSocketChannel serverChannel = ServerSocketChannel.open();
19 serverChannel.configureBlocking(false);
20 ServerSocket ssocket = serverChannel.socket();
21 InetSocketAddress address = new InetSocketAddress(port);
22 ssocket.bind(address);
23 Selector selector = Selector.open();
24 serverChannel.register(selector, SelectionKey.OP_ACCEPT);
25 final ByteBuffer msg = ByteBuffer.wrap("Hi!\r\n".getBytes());
26
27 for (; ; ) {
28 try {
29 selector.select();
30 } catch (IOException ex) {
31 ex.printStackTrace();
32 // handle exception
33 break;
34 }
35 Set<SelectionKey> readyKeys = selector.selectedKeys();
36 Iterator<SelectionKey> iterator = readyKeys.iterator();
37 while (iterator.hasNext()) {
38 SelectionKey key = iterator.next();
39 iterator.remove();
40 try {
41 if (key.isAcceptable()) {
42 ServerSocketChannel server = (ServerSocketChannel) key.channel();
43 SocketChannel client = server.accept();
44 client.configureBlocking(false);
45 client.register(selector,
46 SelectionKey.OP_WRITE | SelectionKey.OP_READ,
47 msg.duplicate());
48 System.out.println("Accepted connection from " + client);
49 }
50 if (key.isWritable()) {
51 SocketChannel client = (SocketChannel) key.channel();
52 ByteBuffer buffer = (ByteBuffer) key.attachment();
53 while (buffer.hasRemaining()) {
54 if (client.write(buffer) == 0) {
55 break;
56 }
57 }
58 client.close();
59 }
60 } catch (IOException ex) {
61 key.cancel();
62 try {
63 key.channel().close();
64 } catch (IOException cex) {
65 // ignore on close
66 }
67 }
68 }
69 }
70 }
71 }
对于并发数量大但处理的任务又十分快速的时候用处十分显著,代替了之前的利用多线程解决业务问题的方案,就是利用单线程以及底层epoll或者poll原理完成了单线程处理多任务的方案,理论上至少我们想到了减少线程切换的开支,而由内核去改变IO状态。
Selector:我们看看selector是怎么样的,每一次open获取,其实都是一个新的selector实例。所以针对这个实例注册什么,就会select到什么,这也说明了,serverSocket和普通socket可以用不同的selector,这个意义也是很重要的,例如我们在Reactor模型中可以做两个reactor,他们分别自己做自己的事件分派。
/** * Opens a selector. * * <p> The new selector is created by invoking the {@link * java.nio.channels.spi.SelectorProvider#openSelector openSelector} method * of the system-wide default {@link * java.nio.channels.spi.SelectorProvider} object. </p> * * @return A new selector * * @throws IOException * If an I/O error occurs */ public static Selector open() throws IOException { return SelectorProvider.provider().openSelector(); }
SelectionKey.OP_CONNECT
该事件只有客户端调用connect的时候才有;
SelectionKey.OP_ACCEPT
该事件只有ServerSocker才有,当服务端收到客户端的一个连接请求时,‘SelectionKey.OP_ACCEPT’将会触发;
SelectionKey.OP_READ
当有可读数据准备被读取时,‘SelectionKey.OP_READ’将会触发;
SelectionKey.OP_WRITE:
OP_WRITE事件的就绪条件并不是发生在调用channel的write方法之后,而是在当底层缓冲区有空闲空间的情况下。
情况一:你需要写的时候再注册,因为写缓冲区在绝大部分时候都是有空闲空间的,所以如果你注册了写事件,这会使得写事件一直处于就就绪,选择处理现场就会一直占用着CPU资源。所以,只有当你确实有数据要写时再注册写操作,并在写完以后马上取消注册。
情况二:满了无法再写的时候注册,SocketChannel会在写数据时,若发现当buffer还有数据,但写缓冲区已经满的情况下,socketChannel.write(buffer)会返回已经写出去的字节数,此时为0。那么这个时候我们就需要注册OP_WRITE事件,这样当写缓冲区又有空闲空间的时候就会触发OP_WRITE事件,这样我们就可以继续将没写完的数据继续写出了。而且在写完后,一定要记得将OP_WRITE事件注销。
Selector底层:
NIO 中 Selector 是对底层操作系统实现的一个抽象,管理通道状态其实都是底层系统实现的,在不同系统下的实现会不同,是自动选择的,可能的实现方式如下:
select:上世纪 80 年代的事情了,它支持注册 FD_SETSIZE(1024) 个 socket,在那个年代肯定是够用的。
poll:1997 年,出现了 poll 作为 select 的替代者,最大的区别就是,poll 不再限制 socket 数量。
select 和 poll 都有一个共同的问题,那就是它们都只会告诉你有几个通道准备好了,但是不会告诉你具体是哪几个通道。所以,一旦知道有通道准备好以后,自己还是需要进行一次扫描,显然这个不太好,通道少的时候还行,一旦通道的数量是几十万个以上的时候,扫描一次的时间都很可观了,时间复杂度 O(n)。所以,后来才催生了以下实现。
epoll:2002 年随 Linux 内核 2.5.44 发布,epoll 能直接返回具体的准备好的通道,时间复杂度 O(1)。那么这个epoll是怎么的原理呢?这就涉及操作系统的中断了,在内核的最底层是中断,类似系统回调的机制。网卡设备对应一个中断号, 当网卡收到网络端的消息的时候会向CPU发起中断请求, 然后CPU处理该请求. 通过驱动程序 进而操作系统得到通知, 系统然后通知epoll, epoll改变阻塞状态。
除了 Linux 中的 epoll,2000 年 FreeBSD 出现了 Kqueue,还有就是,Solaris 中有 /dev/poll。
前面说了那么多实现,但是没有出现 Windows,Windows 平台的非阻塞 IO 使用 select,在 Windows 中 IOCP 提供的异步 IO 是比较强大的。
异步IO(阻塞非阻塞)
异步IO指在IO的返回过程中,用户空间的代码变成被动,内核空间线程成了它的主动调用者。类似于Java中比较典型的回调模式,刚开始用户空间的线程向内核空间注册了各种IO事件的回调函数,由内核去主动调用。
异步这个词,我想对于绝大多数开发者来说都很熟悉,很多场景下我们都会使用异步。对于我而言比较有意义的事情就是发现我所在公司自己做的底层框架Lwmf,自己做了一个声称为AIO的实现,只不过是封装了一层罢。
通常,我们会有一个线程池用于执行异步任务,提交任务的线程将任务提交到线程就可以立马返回,不必等到任务真正完成。如果想要知道任务的执行结果,通常是通过传递一个回调函数的方式,任务结束后另一方去调用这个函数。
同样的原理,Java 中的异步 IO 也是一样的,都是由一个线程池来负责执行任务,然后使用回调或自己去查询结果,所以这里涉及了两个实现方式,在Java中就是注册回调函数和使用异步任务返回的Feature实例。
干货在这里:对象是过程的抽象,而线程是调度的抽象;所以,设计异步IO的时候,需要把线程控制的牢牢的,才能更稳健的设计。
IO多路复用(用户态同步非阻塞的一种实现)
如何避免同步非阻塞IO模型中轮询等待的问题呢?这就是IO多路复用模型。在IO多路复用模型中,引入了一种新的系统调用,查询IO的就绪状态。在Linux系统中,对应的系统调用为select/epoll系统调用。
通过该系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的IO系统调用。
目前支持IO多路复用的系统调用,有select、epoll等等。select系统调用,几乎在所有的操作系统上都有支持,具有良好的跨平台特性。
epoll是在Linux 2.6内核中提出的,是select系统调用的Linux增强版本。 在IO多路复用模型中通过select/epoll系统调用,单个应用程序的线程,可以不断地轮询成百上千的socket连接,当某个或者某些socket网络连接有IO就绪的状态,就返回对应的可以执行的读写操作。 举个例子来说明IO多路复用模型的流程。发起一个多路复用IO的read读操作的系统调用,流程如下:
(1)选择器注册。在这种模式中,首先,将需要read操作的目标socket网络连接,提前注册到select/epoll选择器中,Java中对应的选择器类是Selector类。然后,才可以开启整个IO多路复用模型的轮询流程。
(2)就绪状态的轮询。通过选择器的查询方法,查询注册过的所有socket连接的就绪状态。通过查询的系统调用,内核会返回一个就绪的socket列表。当任何一个注册过的socket中的数据准备好了,内核缓冲区有数据(就绪)了,内核就将该socket加入到就绪的列表中。当用户进程调用了select查询方法,那么整个线程会被阻塞掉。
(3)用户线程获得了就绪状态的列表后,根据其中的socket连接,发起read系统调用,用户线程阻塞。内核开始复制数据,将数据从内核缓冲区复制到用户缓冲区。
(4)复制完成后,内核返回结果,用户线程才会解除阻塞的状态,用户线程读取到了数据,继续执行。IO多路复用模型的流程。如图所示:
IO多路复用模型的特点:IO多路复用模型的IO涉及两种系统调用,另一种是select/epoll(就绪查询),一种是IO操作。
IO多路复用模型建立在操作系统的基础设施之上,即操作系统的内核必须能够提供多路分离的系统调用select/epoll。
和NIO模型相似,多路复用IO也需要轮询。负责select/epoll状态查询调用的线程,需要不断地进行select/epoll轮询,查找出达到IO操作就绪的socket连接。 IO多路复用模型与同步非阻塞IO模型是有密切关系的。对于注册在选择器上的每一个可以查询的socket连接,一般都设置成为同步非阻塞模型。仅是这一点,对于用户程序而言是无感知的。
IO多路复用模型的优点:与一个线程维护一个连接的阻塞IO模式相比,使用select/epoll的最大优势在于,一个选择器查询线程可以同时处理成千上万个连接。系统不必创建大量的线程,也不必维护这些线程,从而大大减小了系统的开销。
Java语言的NIO技术,使用的就是IO多路复用模型。在Linux系统上,使用的是epoll系统调用。IO多路复用模型的缺点:本质上,select/epoll系统调用是阻塞式的,属于同步IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个读写过程是阻塞的。如何彻底地解除线程的阻塞,就必须使用异步IO模型。
Reactor模式
针对NIO网络编程,结合事件驱动的理念,产生了一种Reactor模式,基本可以把上面的多路复用体现出来。他的基本组件有:reactor组件、accepted组件、handler组件;reactor组件用作监听事件、分派事件,accepted组件用作建立连接,handler组件用作处理业务逻辑,基本事件:连接事件、读事件、写事件;该模式的实现可以有多种方式,如下所示:
类型:
- 单Reactor单线程:建立连接(Acceptor),监听accept、read、write事件(Reactor),处理事件(Handler)都用一个线程,很少使用,无法利用多核。
- 单Reactor多线程:建立连接(Acceptor),监听accept、read、write事件(Reactor)在一个线程;处理事件(Handler)都用多个线程,这种情况基本是业务操作非常耗时的。
- 多Reactor多线程:建立连接(Acceptor)用一个mainReactor;监听accept、read、write事件(Reactor)在多个线程下的subReactor;处理事件(Handler)都用线程池,这种模式是selector(记得是多例的)分职责使用的一种实践,而且希望各个环节都没有瓶颈的存在。
应用:针对不同的需求,下面的应用就是不同的Reactor类型。
REDIS:在6.0之后,把IO的读写做成了多线程,其他还是单线程。
Nginx:多进程模型,master进程不处理IO,每个work进程单独一个单线程Reactor
Netty:默认,多reactor多线程模型,一个主reactor建立连接,从reactor复杂IO读写
Kakfa:也是多reactor,但是因为业务handler和磁盘交互,所以单独用的worker线程池处理
RocketMQ:主从多Reactor线程模型,分别有Boss线程、IO线程池、Handler线程池、业务线程池