工作多年後再來聊聊IO

你呀不牛發表於2021-08-29

IO模型

IO是Input/Output的縮寫。Linix網路程式設計中有五種IO模型:

  • blocking IO(阻塞IO)
  • nonblocking IO(非阻塞IO)
  • IO multiplexing(多路複用IO)
  • signal driven IO(訊號驅動IO)
  • asynchronous IO(非同步IO)

簡介

  • Java.io包基於流模型實現,提供File抽象、輸入輸出流等IO的功能。互動方式是同步、阻塞的方式,在讀取輸入流或者寫入輸出流時,在讀、寫動作完成之前,執行緒會一直阻塞。java.io包的好處是程式碼比較簡單、直觀,缺點則是IO效率和擴充套件性存在侷限性,容易成為應用效能的瓶頸。
  • Java.net下面提供的部分網路API,比如Socket、ServerSocket、HttpURLConnection 也時常被歸類到同步阻塞IO類庫,網路通訊同樣是IO行為
  • Java 1.4中引入了NIO框架(java.nio 包),提供了Channel、Selector、Buffer等新的抽象,可以構建多路複用IO程式,同時提供更接近作業系統底層的高效能資料操作方式。
  • Java7中,NIO有了進一步的改進,也就是NIO2,引入了非同步非阻塞IO方式,也被稱為AIO(Asynchronous IO),非同步IO操作基於事件和回撥機制。

首先了解下同步\非同步、阻塞\非阻塞的區別

同步與非同步

同步和非同步是針對的是使用者程式與核心的互動方式

  • 同步指的是使用者程式觸發IO操作並等待或者輪詢的去檢視IO操作是否就緒。例如:自己去銀行辦理業務,自己只能一直幹這件事,其他事情只能等這件是做完後再做
  • 非同步指的是使用者程式觸發IO操作以後便開始做其他的事情,而當IO操作已經完成的時候會得到IO完成的通知。例如:委託親屬去銀行辦理業務,然後自己可以去幹別的事。(使用非同步I/O時,Java將I/O讀寫委託給OS處理,需要將資料緩衝區地址和大小傳給OS)。

阻塞與非阻塞

阻塞和非阻塞是針對程式在訪問資料的時候,根據IO操作的就緒狀態來採取的不同方式。

  • 阻塞指的是當試圖對該檔案描述符進行讀寫時,如果當時沒有東西可讀,或暫時不可寫,程式就進入等待狀態,直到有東西可讀或可寫為止。去辦理業務時,人過多需要排隊,此時就在原地等待,一直等到自己為止。
  • 非阻塞指的是如果沒有東西可讀,或不可寫,讀寫函式馬上返回,而不會等待。在銀行裡辦業務時,領取一張小票,之後我們可以玩手機,或與別人聊聊天,當輪到我們時,銀行的喇叭會通知,這時候我們就可以去辦業務了。

注意,這裡辦業務的時候,還是需要我們也參與其中的,這和非同步是完全不同的,因此同步\非同步、阻塞\非阻塞,是完全不同的兩個概念,二者不要混淆

I/O模型分類

應用程式向作業系統發出IO請求:應用程式發出IO請求給作業系統核心,作業系統核心需要等待資料就緒,這裡的資料可能來自別的應用程式或者網路。一般來說,一個IO分為兩個階段:

  1. 等待資料:資料可能來自其他應用程式或者網路,如果沒有資料,應用程式就阻塞等待。
  2. 拷貝資料:將就緒的資料拷貝到應用程式工作區。

在Linux系統中,作業系統的IO操作是一個系統呼叫recvfrom(),即一個系統呼叫recvfrom包含兩步,等待資料就緒和拷貝資料。

同步阻塞IO

在此種方式下,使用者程式在發起一個IO操作以後,必須等待IO操作的完成,只有當IO操作完成之後,使用者程式才能執行。JAVA傳統的BIO屬於此種方式。(jdk1.4以前)

img

同步非阻塞IO

JAVA NIO(jdk1.4以後引入)

在此種方式下,使用者程式發起一個IO操作以後邊可返回做其它事情,但是使用者程式需要時不時的詢問IO操作是否就緒,這就要求使用者程式不停的去詢問,從而引入不必要的CPU資源浪費。JAVA的NIO就屬於同步非阻塞IO

img

多路複用IO

redis、nginx、netty;reactor模式

select,epoll;有時也稱這種IO方式為事件驅動IO。

select/epoll的好處就在於單個process就可以同時處理多個網路連線的IO。它的基本原理就是select/epoll這個函式會不斷的輪詢所負責的所有socket,當某個socket有資料到達了,就通知使用者程式.

多路複用中,通過select函式,可以同時監聽多個IO請求的核心操作,只要有任意一個IO的核心操作就緒,都可以通知select函式返回,再進行系統呼叫recvfrom()完成IO操作。

這個過程應用程式就可以同時監聽多個IO請求,這比起基於多執行緒阻塞式IO要先進得多,因為伺服器只需要少數執行緒就可以進行大量的客戶端通訊。

img

訊號驅動式IO模型

在unix系統中,應用程式發起IO請求時,可以給IO請求註冊一個訊號函式,請求立即返回,作業系統底層則處於等待狀態(等待資料就緒),直到資料就緒,然後通過訊號通知主調程式,主調程式才去呼叫系統函式recvfrom()完成IO操作。

訊號驅動也是一種非阻塞式的IO模型,比起上面的非阻塞式IO模型,訊號驅動式IO模型不需要輪詢檢查底層IO資料是否就緒,而是被動接收訊號,然後再呼叫recvfrom執行IO操作。

比起多路複用IO模型來說,訊號驅動IO模型針對的是一個IO的完成過程, 而多路複用IO模型針對的是多個IO同時進行時候的場景。

img

非同步IO

在此種模式下,整個IO操作(包括等待資料就緒,複製資料到應用程式工作空間)全都交給作業系統完成。資料就緒後作業系統將資料拷貝進應用程式執行空間之後,作業系統再通知應用程式,這個過程中應用程式不需要阻塞

img

區別

如果你在燒水:

  • 同步阻塞:你將水放在爐子上,然後在那兒等著,還要一直觀察:水燒開了沒啊!
  • 同步非阻塞:你將水放在爐子上,就去看電視了了。每過一會,就到爐子邊觀察:水燒開了沒啊!
  • 多路複用:有人改進了燒水壺,水開了之後會自動發出哨聲,你只需要安心看電視等待哨響通知你水燒開了。
  • 非同步非阻塞:你安排其他人燒水,水燒開後放在特地場合,會打電話通知你,安心看電視等待就可以了。

img

阻塞、非阻塞、多路IO複用,都是同步IO,非同步必定是非阻塞的,所以不存在非同步阻塞和非同步非阻塞的說法。真正的非同步IO需要CPU的深度參與。換句話說,只有使用者執行緒在操作IO的時候根本不去考慮IO的執行,全部都交給CPU去完成,而只需要等待一個完成訊號的時候,才是真正的非同步IO。所以,fork子執行緒去輪詢、死迴圈或者使用select、poll、epoll,都不是非同步

比較經典的一個舉例

  • 阻塞I/O模型

    老李去火車站買票,排隊三天買到一張退票。 耗費:在車站吃喝拉撒睡 3天,其他事一件沒幹。

  • 非阻塞I/O模型

    老李去火車站買票,隔12小時去火車站問有沒有退票,三天後買到一張票。耗費:往返車站6次,路上6小時,其他時間做了好多事。

  • I/O複用模型

    1.select/poll 老李、老王、老劉…一行人去火車站買票,一起委託給黃牛(select黃牛最大隻能接1024個人的訂單/pool黃牛不限制),select/pool黃牛一直等待出票結果,待黃牛取到票後,不知道這張票是屬於誰的(需要根據票面逐一詢問),確認後通知相應人去火車站交錢領票。

    2.epoll 老李、老王、老劉…一行人(無人員個數限制)去火車站買票,一起委託給黃牛,黃牛買到後不需要確認就可以知道這張票的委託人是誰,然後通知其去火車站交錢領票。

    多路複用的意思是:黃牛在承接老李的訂單之後,同時也接了老王、老劉的購票訂單;大家使用同一個黃牛

  • 訊號驅動I/O模型

    老李去火車站買票,給售票員留下電話,有票後,售票員電話通知老李,然後老李去火車站交錢領票。 耗費:往返車站2次,路上2小時,免黃牛費100元,無需打電話,也不需要黃牛

  • 非同步I/O模型

    老李去火車站買票,給售票員留下電話,有票後,售票員快遞送票上門後電話通知其收貨。 耗費:往返車站1次,路上1小時,免黃牛費100元,無需打電話,也不需要黃牛

再談IO多路複用

I/O多路複用就通過一種機制,可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程式進行相應的讀寫操作。但select,poll,epoll本質上都是同步I/O,因為他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而非同步I/O則無需自己負責進行讀寫,非同步I/O的實現會負責把資料從核心拷貝到使用者空間。

多路複用如下面所示:指的其實是在單個執行緒通過記錄跟蹤每一個Sock(I/O流)的狀態來同時管理多個I/O流
img

個人的一些理解:

如上圖做一個簡單的比喻:左邊有若干取水器,需要到右邊水龍頭進行取水操作,每個取水器和水龍頭是一一對應的關係,但是中間段是斷開的,需要將水管連線上(一個水管相當於一個IO執行緒),才可以進行取水操作了(注意水龍頭不是一直都有水流的,只有當取水器連線上才會觸發輸水操作)。下面依次對不同的IO模型進行講解:

1、傳統阻塞BIO:每個取水器和水龍頭之間都需要一個連線水管,水管連線上觸發取水操作,水龍頭才會輸水。這樣有幾個取水器就需要幾個水管,另外水管接上之後並不會馬上就能取到水,之間一直處於阻塞狀態,當取水器過多時沒有足夠的水管來進行連線

執行緒池模式:水池中存在10根水管,每當取水器有取水請求時,就去水池中拿一根水管使用,水管會根據取水器編號接到相應的水龍頭上。當取水器請求過多時,需要不停的進行水管切換。

2、多路複用IO:

select/poll:全部的取水器均複用一根水管,沒有多餘的水管可用,所有的取水器均接到這一根水管上。(區別是select模式僅支援1024個取水器的接入,而poll不限制取水器個數)。當水龍頭有水流過來時,水管會提前收到通知,但不知道是哪個水龍頭。則此時水管需要每個水龍頭都接上試一下,當發現其中一個水龍頭有水流時則將其運到與其相連的取水器中。

epoll部的取水器均複用一根水管,沒有多餘的水管可用,所有的取水器均接到這一根水管上。(與select/poll區別是:當水龍頭有水流過來時,水管就已經知道是哪根水龍頭在運水了,直接將水管接上相應的水龍頭即可)。

虛擬碼描述各IO區別

  • 非阻塞忙輪詢式

    while true
    {
      for i in fd[]
      {
          if i has data
          read until unavailable
      }
    }
    

    把所有流從頭到尾查詢一遍,就可以處理多個流了,但這樣做很不好,因為如果所有的流都沒有I/O事件,白白浪費CPU時間片

  • select:服務端一直在輪詢、監聽如果有客戶端連結上來就建立一個連線放到陣列A中,繼續輪詢這個陣列,如果在輪詢的過程中有客戶端發生IO事件就去處理;select只能監視1024個連線(一個程式只能建立1024個檔案);而且存線上程安全問題;

    while true
    {
      select(fds[]) //阻塞這裡,直到有一個流有I/O事件時,才往下執行,陣列的大小隻有1024
      for i in fds[]
      {
          if i has data
          read until unavailable
      }
    }
    

    它僅僅知道了,有I/O事件發生了,卻並不知道是哪那幾個流(可能有一個,多個,甚至全部),我們只能無差別輪詢所有流,找出能讀出資料,或者寫入資料的流,對他們進行操作。所以select具有O(n)的無差別輪詢複雜度,同時處理的流越多,無差別輪詢時間就越長

  • poll:在select做了許多修復,比如不限制監測的連線數;但是也有執行緒安全問題;

    poll本質上和select沒有區別,它將使用者傳入的陣列拷貝到核心空間,然後查詢每個fd對應的裝置狀態, 但是它沒有最大連線數的限制,原因是它是基於連結串列來儲存的.

  • epoll:也是監測IO事件,但是如果發生IO事件,它會告訴你是哪個連線發生了事件,就不用再輪詢訪問。而且它是執行緒安全的,但是隻有linux平臺支援;

    while true
    {
      active_fds[] = epoll_wait(epollfd)
      for i in active_fds[]
      {
          read or write till
      }
    }
    

    epoll可以理解為event poll,不同於忙輪詢和無差別輪詢,epoll會把哪個流發生了怎樣的I/O事件通知我們。所以我們說epoll實際上是事件驅動(每個事件關聯上fd)的,此時我們對這些流的操作都是有意義的。(複雜度降低到了O(1))

相關文章