聊聊非阻塞I/O编程

写在前面

随着互联网的发展,面对海量用户高并发业务,传统的阻塞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/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容