聊聊非阻塞I/O編程

寫在前面

隨著互聯(lián)網(wǎng)的發(fā)展,面對海量用戶高并發(fā)業(yè)務(wù),傳統(tǒng)的阻塞I/O架構(gòu)已經(jīng)無能為力,改善阻塞問題是服務(wù)器高性能架構(gòu)的關(guān)鍵優(yōu)化點,本篇文章介紹非阻塞I/O編程的實現(xiàn)。

阻塞I/O與非阻塞I/O

阻塞和非阻塞的區(qū)別點在于,線程在發(fā)起接口調(diào)用(發(fā)出請求)后,等待操作完成期間,線程是否被掛起無法執(zhí)行其他操作。

跟阻塞/非阻塞概念常常一起比較的,還有同步和異步的概念:同步和異步關(guān)注的是一個執(zhí)行流程中每個方法是否必須依賴前一個方法完成后才可以繼續(xù)執(zhí)行,實現(xiàn)異步的手段一般是將前面方法一直接交給其他線程執(zhí)行,不由主線程執(zhí)行,也就不會阻塞主線程,所以后面的方法二不必等到方法一完成即可開始執(zhí)行。

  • 阻塞I/O

用戶線程發(fā)起 I/O 操作后會被掛起,需要阻塞等待直到操作完成,阻塞期間線程不能處理別的任務(wù),此時想同時處理其他I/O操作需基于另外的新線程。操作系統(tǒng)層面對線程的個數(shù)是有限制的,當線程數(shù)過多,會引起CPU頻繁進行線程上下文切換造成CPU的消耗。

  • 非阻塞I/O
    I/O 操作都是調(diào)用之后立刻返回而不會阻塞當前用戶線程,當操作處理完成之后,再觸發(fā)用戶線程繼續(xù)執(zhí)行后續(xù)操作。

對于單個請求,非阻塞I/O相對阻塞I/O,并不會縮短處理耗時,但從整個系統(tǒng),非阻塞編程可以讓相同數(shù)量的線程在相同時間內(nèi)處理更多請求,提高整個系統(tǒng)的吞吐量。

非阻塞I/O編程

1 多路I/O復(fù)用

使用非阻塞I/O,當I/O操作處理完成,如何使用戶線程知道,并觸發(fā)執(zhí)行后續(xù)操作?可以通過另外的線程主動監(jiān)聽I/O事件是否處理完成,而且不僅只監(jiān)聽一個I/O事件,而是多個,即I/O多路復(fù)用。

I/O 多路復(fù)用指的就是 select/poll/epoll 這一系列的API:支持單一線程同時阻塞等待監(jiān)聽多個文件描述符(I/O 事件),并在其中某個文件描述符可讀寫時由os喚醒阻塞等待的線程。 I/O 復(fù)用其實復(fù)用的不是 I/O 連接,而是復(fù)用線程,讓線程能夠監(jiān)聽多個連接(I/O 事件)。

I/O復(fù)用在不同的操作系統(tǒng)有不同的實現(xiàn),這里以Linux最常用的epoll為例進行介紹
epoll 的 API 非常簡潔,涉及到3 個系統(tǒng)調(diào)用:

  • epoll_create();
    創(chuàng)建并返回一個 內(nèi)核數(shù)據(jù)對象epoll實例。

  • epoll_ctl();
    添加/刪除/修改file descriptor(socket連接)等待的 I/O 事件到 epoll 實例上。

  • epoll_wait()
    指定超時時間阻塞監(jiān)聽 epoll 實例上所有的 file descriptor 的 I/O 事件,接收一個用戶空間上的一塊內(nèi)存地址,kernel 會在 I/O 事件就緒時候把文件描述符列表復(fù)制到這塊內(nèi)存地址上,然后 epoll_wait 解除阻塞并返回,最后用戶空間上的程序就可以對相應(yīng)的 fd 進行讀寫了

2 Java實現(xiàn)

為了更好理解,先看一段Java服務(wù)端的簡化示例代碼

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是對操作系統(tǒng)的I/O多路復(fù)用調(diào)用的一個封裝,在Linux中默認基于epoll的實現(xiàn)。
SelectionKey是對I/O事件的封裝,而SocketChannel 是對客戶端socket連接的封裝。

工作流程如下:

  • 創(chuàng)建服務(wù)端Socket對象,并開始監(jiān)聽指定端口。
  • 創(chuàng)建Selector對象,并將服務(wù)端Socket對象注冊到它上面。
  • 阻塞監(jiān)聽就緒的I/O事件,當監(jiān)聽到客戶端Socket連接建立事件,將該連接注冊到Selector上,監(jiān)聽該連接上的后續(xù)的I/O事件。
  • 監(jiān)聽到客戶端連接的I/O事件可讀或可寫,觸發(fā)相應(yīng)的事件處理。

3 Netty實現(xiàn)

Java nio對多路I/O復(fù)用做了基礎(chǔ)的封裝,沒有實現(xiàn)I/O事件的多線程處理,Netty在Java nio的基礎(chǔ)上做了進一步的封裝,實現(xiàn)Reactor模型。

Reactor是的中文是反應(yīng)堆,對應(yīng)是“事件反應(yīng)”,可以通俗理解為“來了一個事件觸發(fā)相應(yīng)的反應(yīng)”,簡單理解的話,就是I/O多路復(fù)用+線程池,Reactor 會根據(jù)事件類型來調(diào)用相應(yīng)的代碼進行處理。Reactor 模式也叫 Dispatcher 模式,即 I/O 多路復(fù)用統(tǒng)一監(jiān)聽事件,收到事件后分配(Dispatch)給某個線程。

Reactor模型較為常見的主從Reactor模型設(shè)計是:系統(tǒng)有2個線程池,主線程池和子線程池:

  • 在主線程池中運行的的MainReacor內(nèi)置Selector負責監(jiān)聽連接建立事件(accept),當連接建立之后,分發(fā)給子線程池。
  • 在子線程池中運行的SubReactor負責監(jiān)聽連接數(shù)據(jù)就緒可讀事件,然后進行業(yè)務(wù)處理和寫回響應(yīng)數(shù)據(jù)。

設(shè)計成2個線程池分開的好處在于,主線程池和子線程池的職責非常明確,主線程池只負責接收新連接,子線程池負責完成后續(xù)的業(yè)務(wù)處理,避免相互影響。

Netty的異步事件驅(qū)動模型本質(zhì)上是Reacor模型,其中“事件”可以理解成I/O復(fù)用中監(jiān)聽的各種I/O事件,包括連接建立,連接上數(shù)據(jù)就緒可讀,連接上數(shù)據(jù)已完成寫入、連接關(guān)閉等。通過Selector對象,不斷監(jiān)聽I/O事件,驅(qū)動觸發(fā)相應(yīng)的處理邏輯。包含的組件及其工作原理如下:

  • Boss Group是Reactor模型中的主線程池,內(nèi)置Selector對象和一個NioEventLoop對象。
  • Worker Group是Reactor模型中的子線程池,內(nèi)置Selector對象和多個NioEventLoop對象。
  • NioEventLoop內(nèi)部維護了一個處理線程,線程的執(zhí)行邏輯是從當前線程池的Selector進行select,獲取出就緒的I/O事件進行處理(processSelectedKeys)。NioEventLoop同時也維護了一個內(nèi)部任務(wù)隊列,最終執(zhí)行runAllTasks 方法,處理被提交到任務(wù)隊列中的任務(wù)。
  • Boss Group中的NioEventLoop的processSelectedKeys處理連接就緒事件(acceptable),與客戶端建立連接,并將連接注冊到Worker Group內(nèi)置的Selector中。
  • Worker Group中的NioEventLoop的processSelectedKeys調(diào)用當前客戶端連接(channel)的事件處理器(ChannelHanndler)處理具體業(yè)務(wù)邏輯。

4 Node.js實現(xiàn)

Node.js高性能服務(wù)端JavaScriptpt運行平臺,底層通過Bindings調(diào)用C/C++的libuv庫實現(xiàn)異步事件驅(qū)動。

Node.js單線程只是一個js主線程,本質(zhì)上的異步操作還是由線程池完成的,Node.js將所有的阻塞操作都交給了libuv庫內(nèi)部線程池去實現(xiàn),本身只負責不斷地往返調(diào)度,并沒有進行真正的I/O操作,從而實現(xiàn)異步非阻塞I/O。

基本執(zhí)行原理如下:

  • Node.js將異步任務(wù)放入事件隊列中(Event Queue)。
  • libuv主線程從事件隊列不斷循環(huán)取出事件,驅(qū)動所有的異步回調(diào)函數(shù)的執(zhí)行,Event Loop總共6個階段,每個階段都有一個子事件隊列,當所有階段被順序執(zhí)行一次后,event loop 完成了一個 tick。
  • Event Loop執(zhí)行過程中,如果I/O操作有注冊回調(diào),都是提交到libuv的線程池的工作線程來執(zhí)行,實現(xiàn)異步I/O,例如文件操作,網(wǎng)絡(luò)連接讀寫。當操作完成后工作線程更新 file descriptor,libuv主線程通過多路I/O復(fù)用(例如epool)監(jiān)聽file descriptor,再層層回調(diào),最終會調(diào)用到用戶注冊的回調(diào)函數(shù)。

5 Golang實現(xiàn)

前面介紹的幾種非阻塞I/O的實現(xiàn),為了避免I/O操作阻塞線程而采用異步寫入的方式,然后再基于I/O多路復(fù)用監(jiān)聽到I/O操作完成,再觸發(fā)后續(xù)操作。這樣做雖然可以提高性能,但需要編寫相關(guān)異步回調(diào)邏輯,相比同步順序執(zhí)行程序,異步回調(diào)邏輯并不友好,帶來一定的代碼復(fù)雜度,例如回調(diào)地獄問題,程序上下文變量/對象如何傳遞到異步回調(diào)程序。

有沒有什么方案,既兼顧性能,實現(xiàn)線程非阻塞I/O,又程序友好,代碼同步順序執(zhí)行而不是異步回調(diào)的方案?看似相互矛盾的需求,看看Golang的協(xié)程方案如何實現(xiàn):


大部分編程語言的線程庫(例如C++11的std::thread、Java的java.lang.Thread)都是對操作系統(tǒng)的線程(內(nèi)核級線程)的一層封裝,因此其管理和調(diào)度完全由OS調(diào)度器來做,這種方式實現(xiàn)簡單,但在需要使用大量線程的場景下對OS的性能影響會很大。

Go的協(xié)程(goroutine)是一種用戶態(tài)的輕量級線程,協(xié)程的調(diào)度完全由用戶控制。協(xié)程的最大優(yōu)勢在于其"輕量級",可以輕松創(chuàng)建上百萬個而不會導(dǎo)致系統(tǒng)資源衰竭,Go通過實現(xiàn)一個調(diào)度器,實現(xiàn)協(xié)程與內(nèi)核線程的動態(tài)關(guān)聯(lián)調(diào)度,OS內(nèi)核的調(diào)度器實現(xiàn)內(nèi)核線程到CPU的調(diào)度。

Go的I/O 多路復(fù)用netpoller模型,一個goroutine處理一個客戶端連接來處理(goroutine-per-connection)實現(xiàn)非阻塞I/O基本原理如下:

  • (1) goroutine處理客戶端I/O事件,(例如建立連接、讀寫連接數(shù)據(jù)),Linux系統(tǒng)下對應(yīng)epoll_ctl在內(nèi)核空間注冊待監(jiān)聽事件,goroutine調(diào)用相關(guān)netpoller的I/O事件API之后,進入休眠狀態(tài)(gopark)
  • (2) 當I/O事件就緒,可以通過runtime.netpoll(相當于epoll實例對象),獲取休眠狀態(tài)goroutine的并進行喚醒,runtime.netpoll觸發(fā)場景有以下2個:
    • Go的調(diào)度器Go scheduler調(diào)用;
    • Go runtime 在程序啟動的時候會創(chuàng)建一個獨立的sysmon監(jiān)控線程定時調(diào)用。
  • (3) goroutine被喚醒之后,繼續(xù)執(zhí)行后續(xù)業(yè)務(wù)邏輯。

Java中的BIO為每一路連接單獨分配線程來處理的性能并不高,而在Go中可以為每一路連接單獨分配輕量級的goroutine進行高性能處理,在每個goroutine協(xié)程中,調(diào)用I/O操作API時,代碼同步順序執(zhí)行,不用寫I/O事件完成時的異步操作回調(dià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 原生網(wǎng)絡(luò)模型之源碼全面揭秘》https://strikefreedom.top/go-netpoll-io-multiplexing-reactor
《libuv源碼閱讀》http://masutangu.com/2016/10/13/libuv-source-code/

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容