写在前面
随着互联网的发展,面对海量用户高并发业务,传统的阻塞I/O架构已经无能为力,改善阻塞问题是服务器高性能架构的关键优化点,本篇文章介绍非阻塞I/O编程的实现。
阻塞I/O与非阻塞I/O
阻塞和非阻塞的区别点在于,线程在发起接口调用(发出请求)后,等待操作完成期间,线程是否被挂起无法执行其他操作。
跟阻塞/非阻塞概念常常一起比较的,还有同步和异步的概念:同步和异步关注的是一个执行流程中每个方法是否必须依赖前一个方法完成后才可以继续执行,实现异步的手段一般是将前面方法一直接交给其他线程执行,不由主线程执行,也就不会阻塞主线程,所以后面的方法二不必等到方法一完成即可开始执行。
- 阻塞I/O
用户线程发起 I/O 操作后会被挂起,需要阻塞等待直到操作完成,阻塞期间线程不能处理别的任务,此时想同时处理其他I/O操作需基于另外的新线程。操作系统层面对线程的个数是有限制的,当线程数过多,会引起CPU频繁进行线程上下文切换造成CPU的消耗。
- 非阻塞I/O
I/O 操作都是调用之后立刻返回而不会阻塞当前用户线程,当操作处理完成之后,再触发用户线程继续执行后续操作。
对于单个请求,非阻塞I/O相对阻塞I/O,并不会缩短处理耗时,但从整个系统,非阻塞编程可以让相同数量的线程在相同时间内处理更多请求,提高整个系统的吞吐量。
非阻塞I/O编程
1 多路I/O复用
使用非阻塞I/O,当I/O操作处理完成,如何使用户线程知道,并触发执行后续操作?可以通过另外的线程主动监听I/O事件是否处理完成,而且不仅只监听一个I/O事件,而是多个,即I/O多路复用。
I/O 多路复用指的就是 select/poll/epoll 这一系列的API:支持单一线程同时阻塞等待监听多个文件描述符(I/O 事件),并在其中某个文件描述符可读写时由os唤醒阻塞等待的线程。 I/O 复用其实复用的不是 I/O 连接,而是复用线程,让线程能够监听多个连接(I/O 事件)。
I/O复用在不同的操作系统有不同的实现,这里以Linux最常用的epoll为例进行介绍
epoll 的 API 非常简洁,涉及到3 个系统调用:
epoll_create();
创建并返回一个 内核数据对象epoll实例。epoll_ctl();
添加/删除/修改file descriptor(socket连接)等待的 I/O 事件到 epoll 实例上。epoll_wait()
指定超时时间阻塞监听 epoll 实例上所有的 file descriptor 的 I/O 事件,接收一个用户空间上的一块内存地址,kernel 会在 I/O 事件就绪时候把文件描述符列表复制到这块内存地址上,然后 epoll_wait 解除阻塞并返回,最后用户空间上的程序就可以对相应的 fd 进行读写了
2 Java实现
为了更好理解,先看一段Java服务端的简化示例代码
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.socket().bind(new InetSocketAddress(port));
Selector selector = Selector.open();
//Channel注册到Selector中
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while(true) {
int n = selector.select();
if (n == 0) continue;
Iterator ite = this.selector.selectedKeys().iterator();
while(ite.hasNext()) {
SelectionKey key = (SelectionKey)ite.next();
if(key.isAcceptable()) {
SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
//将socket注册到selector上
clientChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(bufSize));
}
if (key.isReadable()) {
handleRead(key);
}
if (key.isWritable() && key.isValid()) {
handleWrite(key);
}
ite.remove();
}
}
jdk中Selector是对操作系统的I/O多路复用调用的一个封装,在Linux中默认基于epoll的实现。
SelectionKey是对I/O事件的封装,而SocketChannel 是对客户端socket连接的封装。
工作流程如下:
- 创建服务端Socket对象,并开始监听指定端口。
- 创建Selector对象,并将服务端Socket对象注册到它上面。
- 阻塞监听就绪的I/O事件,当监听到客户端Socket连接建立事件,将该连接注册到Selector上,监听该连接上的后续的I/O事件。
- 监听到客户端连接的I/O事件可读或可写,触发相应的事件处理。
3 Netty实现
Java nio对多路I/O复用做了基础的封装,没有实现I/O事件的多线程处理,Netty在Java nio的基础上做了进一步的封装,实现Reactor模型。
Reactor是的中文是反应堆,对应是“事件反应”,可以通俗理解为“来了一个事件触发相应的反应”,简单理解的话,就是I/O多路复用+线程池,Reactor 会根据事件类型来调用相应的代码进行处理。Reactor 模式也叫 Dispatcher 模式,即 I/O 多路复用统一监听事件,收到事件后分配(Dispatch)给某个线程。
Reactor模型较为常见的主从Reactor模型设计是:系统有2个线程池,主线程池和子线程池:
- 在主线程池中运行的的MainReacor内置Selector负责监听连接建立事件(accept),当连接建立之后,分发给子线程池。
- 在子线程池中运行的SubReactor负责监听连接数据就绪可读事件,然后进行业务处理和写回响应数据。
设计成2个线程池分开的好处在于,主线程池和子线程池的职责非常明确,主线程池只负责接收新连接,子线程池负责完成后续的业务处理,避免相互影响。
Netty的异步事件驱动模型本质上是Reacor模型,其中“事件”可以理解成I/O复用中监听的各种I/O事件,包括连接建立,连接上数据就绪可读,连接上数据已完成写入、连接关闭等。通过Selector对象,不断监听I/O事件,驱动触发相应的处理逻辑。包含的组件及其工作原理如下:
- Boss Group是Reactor模型中的主线程池,内置Selector对象和一个NioEventLoop对象。
- Worker Group是Reactor模型中的子线程池,内置Selector对象和多个NioEventLoop对象。
- NioEventLoop内部维护了一个处理线程,线程的执行逻辑是从当前线程池的Selector进行select,获取出就绪的I/O事件进行处理(processSelectedKeys)。NioEventLoop同时也维护了一个内部任务队列,最终执行runAllTasks 方法,处理被提交到任务队列中的任务。
- Boss Group中的NioEventLoop的processSelectedKeys处理连接就绪事件(acceptable),与客户端建立连接,并将连接注册到Worker Group内置的Selector中。
- Worker Group中的NioEventLoop的processSelectedKeys调用当前客户端连接(channel)的事件处理器(ChannelHanndler)处理具体业务逻辑。
4 Node.js实现
Node.js高性能服务端JavaScriptpt运行平台,底层通过Bindings调用C/C++的libuv库实现异步事件驱动。
Node.js单线程只是一个js主线程,本质上的异步操作还是由线程池完成的,Node.js将所有的阻塞操作都交给了libuv库内部线程池去实现,本身只负责不断地往返调度,并没有进行真正的I/O操作,从而实现异步非阻塞I/O。
基本执行原理如下:
- Node.js将异步任务放入事件队列中(Event Queue)。
- libuv主线程从事件队列不断循环取出事件,驱动所有的异步回调函数的执行,Event Loop总共6个阶段,每个阶段都有一个子事件队列,当所有阶段被顺序执行一次后,event loop 完成了一个 tick。
- Event Loop执行过程中,如果I/O操作有注册回调,都是提交到libuv的线程池的工作线程来执行,实现异步I/O,例如文件操作,网络连接读写。当操作完成后工作线程更新 file descriptor,libuv主线程通过多路I/O复用(例如epool)监听file descriptor,再层层回调,最终会调用到用户注册的回调函数。
5 Golang实现
前面介绍的几种非阻塞I/O的实现,为了避免I/O操作阻塞线程而采用异步写入的方式,然后再基于I/O多路复用监听到I/O操作完成,再触发后续操作。这样做虽然可以提高性能,但需要编写相关异步回调逻辑,相比同步顺序执行程序,异步回调逻辑并不友好,带来一定的代码复杂度,例如回调地狱问题,程序上下文变量/对象如何传递到异步回调程序。
有没有什么方案,既兼顾性能,实现线程非阻塞I/O,又程序友好,代码同步顺序执行而不是异步回调的方案?看似相互矛盾的需求,看看Golang的协程方案如何实现:
大部分编程语言的线程库(例如C++11的std::thread、Java的java.lang.Thread)都是对操作系统的线程(内核级线程)的一层封装,因此其管理和调度完全由OS调度器来做,这种方式实现简单,但在需要使用大量线程的场景下对OS的性能影响会很大。
Go的协程(goroutine)是一种用户态的轻量级线程,协程的调度完全由用户控制。协程的最大优势在于其"轻量级",可以轻松创建上百万个而不会导致系统资源衰竭,Go通过实现一个调度器,实现协程与内核线程的动态关联调度,OS内核的调度器实现内核线程到CPU的调度。
Go的I/O 多路复用netpoller模型,一个goroutine处理一个客户端连接来处理(goroutine-per-connection)实现非阻塞I/O基本原理如下:
- (1) goroutine处理客户端I/O事件,(例如建立连接、读写连接数据),Linux系统下对应epoll_ctl在内核空间注册待监听事件,goroutine调用相关netpoller的I/O事件API之后,进入休眠状态(gopark)
- (2) 当I/O事件就绪,可以通过runtime.netpoll(相当于epoll实例对象),获取休眠状态goroutine的并进行唤醒,runtime.netpoll触发场景有以下2个:
- Go的调度器Go scheduler调用;
- Go runtime 在程序启动的时候会创建一个独立的sysmon监控线程定时调用。
- (3) goroutine被唤醒之后,继续执行后续业务逻辑。
Java中的BIO为每一路连接单独分配线程来处理的性能并不高,而在Go中可以为每一路连接单独分配轻量级的goroutine进行高性能处理,在每个goroutine协程中,调用I/O操作API时,代码同步顺序执行,不用写I/O事件完成时的异步操作回调代码。
参考
《EPOLL_CTL_DISABLE and multithreaded applications》 https://lwn.net/Articles/520012/
《go-netpoll-io-multiplexing-reactor]》https://strikefreedom.top/go-netpoll-io-multiplexing-reactor
《Go netpoller 原生网络模型之源码全面揭秘》https://strikefreedom.top/go-netpoll-io-multiplexing-reactor
《libuv源码阅读》http://masutangu.com/2016/10/13/libuv-source-code/