同步,非同步,阻塞,非阻塞
在看kafka的生產者基於IO/">NIO構建網路通訊層NetworkClient的時候,發覺自己對網路通訊的相關知識(同步,非同步,阻塞,非阻塞, Reactor,Proactor,Linux的IO模型,IO的多路複用等知識)有點模糊,熟悉喃也好像不怎麼熟悉,瞭解喃也好像不怎麼了解。那麼就在這裡重新梳理一遍。
同步和非同步針對應用程式,關注的是程式之間的協作關係;阻塞和非阻塞關注的是程序/執行緒之間的執行狀態(正在執行或者是掛起)。
同步 非同步
同步:執行一個操作之後,等待結果後,然後才繼續執行後續的操作。
非同步:執行一個操作之後,可以去執行其他的操作,然後等待通知後,再回來執行剛剛那個操作後續的操作。
阻塞 非阻塞
阻塞:程序給CPU一個任務後,一直等待CPU處理完成,然後才執行後面的操作。
非阻塞:程序給CPU一個任務後,繼續處理後面的操作,間隔一段時間在來詢問之前的任務是否完成,完成了,就繼續執行後續的操作。這個過程叫輪詢。
阻塞和非阻塞是指程序/執行緒訪問的資料如果尚未就緒,程序/執行緒是否需要等待。
同步和非同步是指訪問資料的機制:同步一般指主動請求並等待I/O操作完畢的方式,當資料就緒後進程/執行緒在讀寫的時候必須阻塞(等待資料從核心緩衝區複製到使用者緩衝區)。非同步則指主動請求I/O資料後,繼續處理其他任務。隨後等待I/O操作完畢的通知(callback),這樣可以使程序/執行緒在讀寫時不阻塞。
網路IO的本質是socket的讀取,socket在Linux系統被抽象為流,IO可以理解為對流的操作。對於一次IO訪問,資料會先被拷貝到作業系統核心的緩衝區中,然後再從作業系統核心的緩衝區拷貝到應用程式的地址空間。
同步模型
阻塞IO
最常用的IO模型就是阻塞IO模型,在預設條件下,所有的socket操作都是阻塞的,以socket讀為例(底層是呼叫recvfrom方法):

阻塞IO
在使用者空間呼叫recvfrom,直到資料包全部達到並且從核心緩衝區複製到應用程序的緩衝區或者中間發生異常返回的這段期間程序/執行緒一直等待。程序/執行緒從呼叫recvfrom開始到recvfrom返回整段時間內都被阻塞----阻塞IO模型。
非阻塞IO
非阻塞的recvfrom呼叫,從應用到核心時,如果緩衝區沒有資料,程序不會被阻塞,核心會馬上返回一個EWOULDBLOCK錯誤。這樣程序可以做點其他的事情,然後程序再發起recvfrom呼叫,如果緩衝區還沒有資料,又會返回EWOULDBLOCK錯誤。就這樣迴圈往復的進行recvform系統呼叫,這種就叫做輪詢。輪詢檢查到核心的緩衝區有資料準備好了,再拷貝到應用的緩衝區,程序進行資料處理。 資料從核心緩衝區到應用的緩衝區這個時間段,程序屬於阻塞狀態。

非阻塞IO
多路複用IO
Linux提供了select,poll,epoll。應用系統呼叫阻塞於select呼叫,等待socket變為可讀,就返回這個可讀的條件。程序再呼叫recvfrom把資料從核心緩衝區複製到應用緩衝區(這個過程是阻塞的)

多路複用IO
前面的非阻塞IO需要使用者應用不停的去輪詢,並且一個程序/執行緒只能關注一個socket。多路複用IO不需要使用者應用去不停的輪詢,而是讓Linux下的select,poll,epoll去幫忙輪詢多個任務的完成情況。select呼叫是核心級別,select輪詢相對非阻塞IO的輪詢的區別在於---select輪詢可以等待多個socket,能實現同時對多個IO埠進行監聽。當其中任何一個socket的資料準好了,就能返回進行可讀,然後程序再進行recvform系統呼叫,將資料由核心拷貝到使用者程序,當然這個過程是阻塞的。呼叫select後,會阻塞程序,於阻塞IO不同(等待所有資料都到達),select不是等到socket資料全部到達再處理, 而是有了一部分資料(網路上的資料是分組到達的,如何知道有一部分資料到達了呢?監視的事情交給了核心,核心負責資料到達的處理)就會通知使用者應用讀準備好了,然後程序呼叫recvform來處理。
1,對多個socket進行監聽,只要任何一個socket資料準備好就返回可讀。
2,不等一個socket資料全部到達再處理,而是一部分socket的資料到達了就通知使用者程序。這就是我們經常在程式碼裡面寫迴圈來處理資料:
while ((networkReceive = channel.read()) != null){ // 處理讀取到的一部分的socket資料 }
訊號驅動IO
首先開啟套接字的訊號驅動式IO功能,並且通過sigaction系統呼叫安裝一個訊號處理函式,該函式呼叫將立即返回,當前程序沒有被阻塞,繼續工作;當資料報準備好的時候,核心則為該程序產生SIGIO的訊號,隨既可以在訊號處理函式中呼叫recvfrom讀取資料報,並且通知主迴圈;資料已經準備好等待處理,通知主迴圈讀取資料報;(一個待讀取的通知和待處理的通知);

訊號驅動IO
非同步IO
告知核心啟動讀取事件,並讓核心在整個操作完成後(包括將資料從核心緩衝區複製到應用程式緩衝區)通知使用者程序。

非同步IO
檔案描述符(fd)
檔案描述符(file descriptor,簡稱fd),是一個非負整數。當程序開啟一個現有檔案或者建立一個新檔案時,核心向程序返回一個檔案描述符。在Linux系統中,核心將所有的外部裝置都當做一個檔案來進行操作,而對於一個檔案的讀寫操作會呼叫核心提供的系統命令,返回一個fd。對於一個socket的讀寫也會有相應的描述符---socketfd(socket描述符),指向核心中的一個結構體(檔案路徑,資料區域等屬性)。
Linux的IO多路複用模型
基本思想:先構造一張有關檔案描述符的表,然後呼叫一個函式,這個函式要到這些檔案描述符中一個已經準備好進行IO操作後才返回。在返回時,此函式告訴程序哪一個檔案描述符已經準備好可以進行IO操作。
IO多路複用通過把多個IO阻塞複用到同一個select的阻塞上,從而使得系統在單執行緒的情況下,可以同時處理多個 client 請求,與傳統的多執行緒相比,IO多路複用的最大優勢是系統開銷小,系統不需要建立新的額外的程序或執行緒,也不需要維護這些程序和執行緒的執行,節省了系統資源。
目前支援I/O多路複用的系統有select,pselect,poll,epoll,I/O多路複用就是通過一種機制,一個程序可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程式進行相應的讀寫操作。但select,pselect,poll,epoll本質上都是同步I/O,因為他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而非同步I/O則無需自己負責進行讀寫,非同步I/O的實現會負責把資料從核心拷貝到使用者空間。使用者程序之間從使用者緩衝區中讀取資料。 真正的IO阻塞是資料從核心緩衝區複製到使用者緩衝區中,這正是上面recvfrom方法做的事情。所以從訊息機制來看都是同步IO。
select
select函式的引數會告訴核心:
1,關心的檔案描述符有哪些。
2,對於每一個檔案描述符關心的條件是什麼:是否可以讀一個檔案描述符,是否可以寫一個檔案描述符,檔案描述符的異常情況。
3,程序希望等待多長的時間(在沒有檔案描述符準備的時候,好久返回):可以永遠等待,等待一個固定的時間,完全不等待。
在select函式返回時,核心會告訴我們:
1,已經準備好的檔案描述符的數量。
2,哪一個檔案描述符已準備好讀,寫或者異常條件。
select函式監視的檔案描述符分3類,分別是writefds、readfds、和exceptfds。這三個指標是指向描述符集的。這三個描述符集代表了我們關心的可讀,可寫或處於異常條件的各個描述符。每個描述符集存放在一個fd_set資料型別中,fd_set用了一個32位向量來表示fd,Linux中FD_SETSIZE設定預設是1024。呼叫後select函式會阻塞,直到有描述符就緒(有資料可讀、可寫),或者超時(timeout指定等待時間)函式返回。當select函式返回後,可以通過遍歷fd_set,來找到就緒的描述符。
select的缺點:
1,在於單個程序能夠監視的檔案描述符的數量存在最大限制。由FD_SETSIZE設定 , 在Linux上預設為1024。
2,當socket比較多的時候,每次的select都要遍歷FD_SETSIZE個socket。不是遍歷的socket是否是活躍的,都要遍歷一遍。浪費很多CPU時間。
3,需要維護一個用來存放大量fd的資料結構(操作socket,其實就是對檔案描述符的操作),這樣在從核心空間複製到使用者空間開銷很大。
poll
poll函式類似select,與select不同的是,poll不是為每個條件構造一個描述符集(writefds、readfds、和exceptfds),而是構造一個fd結構陣列,陣列中每一個元素指定一個描述符編號和關心的事件。查詢每個fd對應的裝置狀態。如果裝置就緒則在設定陣列中描述符編號對應的描述符發生了什麼事件(revent),如果遍歷完所有 fd 後沒有發現就緒裝置,則掛起當前程序。直到裝置就緒或者主動超時,被喚醒後它又要再次遍歷fd。這個過程經歷了多次無謂的遍歷。
poll沒有FD_SETSIZE限制。因為採用陣列方式儲存要檢查的fd。
poll的缺點:
1,fd結構陣列(資料量大)被整體複製於使用者態和核心地址空間之間,而不管這樣的複製是不是有意義。
2,如果報告了fd後,沒有被處理,那麼下次poll時會再次報告該fd。
select和poll最後都需要遍歷檔案描述符來獲取已經就緒的socket。因為我們只是知道有事件發生,但是並不知道是那幾個流,所以只能無差別的輪詢一遍所有的流。其實在同時連線大量客戶端在一時刻可能只有很少的處於就緒狀態。但是也要全部遍歷,這樣隨著監視的描述符數量的增長,其效率也會線性下降。
epoll
在核心裡,一切都是檔案。epoll會向核心註冊一個檔案系統,用於儲存被監控的socket描述符。epoll在被核心初始化的時(作業系統啟動時),會開闢屬於epoll自己的核心快取記憶體區。
epoll的相關操作
int epoll_create:建立一個epoll物件,epollfd = epoll_create()。返回一個檔案描述符,這個檔案描述符指向核心中的事件表,這個事件表存放的是使用者關心的檔案描述符。相當於使用一個檔案描述符來管理使用者的所有的檔案描述符。呼叫epoll_create方法會向epoll向核心註冊的檔案系統裡面建立一個file節點。並且在屬於epoll的快取記憶體區中建立一個紅黑樹用於儲存epoll_ctl傳來的socket描述符。最後還會建立一個就緒的連結串列,用於儲存準備就緒的事件。(檔案系統和快取區直接採用mmap來新增對映關係)
int epoll_ctl:操作epoll_create建立的epoll,可將新建的socket描述符加入到epoll讓其監控,或者移除正在被監控的某個socket描述符。返回值:若成功,返回0,若出錯返回-1。epoll_ctl把socket描述符新增到紅黑樹上,還會給核心的中斷處理程式註冊一個回撥函式,這樣核心會在此socket描述符中斷到了後,就把它放到就緒的連結串列中。(中斷:網絡卡上收到了資料,從CPU傳送一箇中斷請求,CPU會從當前正在執行的動作中抽出身來獲取資料,然後通過驅動程式通知作業系統,作業系統通知epoll在核心的中斷處理程式上註冊的回撥函式把處理此socket描述符-->放到就緒連結串列中。)
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout):等待所監聽檔案描述符上有事件發生。返回值:若成功,返回就緒的檔案描述符個數,若出錯,返回-1,時間超時返回0。epoll_wait直接監控就緒連結串列裡面有沒有資料即可,有資料就返回,沒有資料就sleep,如果過了timeout不管有無資料都直接返回,就不用去掃描所有的socket描述符(fd)。如果我們即使監控數百萬的fd。那麼在就緒連結串列裡面也會只有很少的fd就緒,這樣epoll_wait從核心的緩衝區複製在使用者的緩衝區中是很少的資料,這也就是epoll_wait高效所在之一。
struct epoll_event *events:
struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
疑問:epoll_wait()方法的返回值的是fd就緒的個數,那麼應用系統又是怎麼知道具體是哪些fd就緒的?epoll不會去遍歷整個fd。
解答:通過檢視原始碼發現在epoll_wait方法中,會從通過就緒連結串列拿出就緒的事件,然後向用戶空間傳送data資訊。if(__put_user(revents,&events[eventcnt.].events) || __put_user(epi->event.data,&events[eventcnt].data)):event.data就是epoll_data。epoll_data裡面包含fd,fd就是可讀或者可寫的檔案描述符。應用程式就知道哪個fd可讀可寫了。
epoll的執行過程:
1,執行epoll_create建立紅黑樹和就緒連結串列。2,執行epoll_ctl時,向紅黑樹增加socket描述符,並向核心註冊回撥函式,當中斷事件發生後通過回撥函式想就緒連結串列中寫入資料。3,當事件(可讀可寫)發生epoll_wait被觸發,直接向用戶快取區返回準備就緒的fd。
關鍵:1,紅黑樹高速的查詢、插入、刪除。2,就緒連結串列只儲存準備就緒的描述符,減少從核心緩衝區到使用者緩衝區的複製大小。
epoll對檔案描述符有兩種模式:LT(level trigger)和ET(edge trigger)LT是預設模式。
LT模式:採用LT模式的檔案描述符,當epoll_wait檢測到其上有事件發生並將此事件通知應用程式後,應用程式可以 不立即 處理此事件,當下一次呼叫epoll_wait時,epoll_wait還會將此事件通告應用程式。
ET模式:當呼叫epoll_ctl,向引數event註冊EPOLLET事件時,epoll將以ET模式來操作該檔案描述符,ET模式是epoll的高效工作模式。對於採用ET模式的檔案描述符,當epoll_wait檢測到其上有事件發生並將此通知應用程式後,應用程式 必須立即 處理該事件,因為後續的epoll_wait呼叫將不在嚮應用程式通知這一事件。ET模式降低了同一epoll事件被觸發的次數,效率比LT模式高。
總結:在select/poll中,程序呼叫select方法,將所監控的描述符從使用者緩衝區複製到核心緩衝區中,將數以萬計的socket描述符從使用者緩衝區複製到核心緩衝區裡面,這是非常低效的。核心對所有監視的檔案描述符進行全部掃描。epoll事先通過epoll_ctl()把檔案描述符增加到紅黑樹上,一旦某個檔案描述符就緒時,核心會呼叫epoll_ctl在核心上註冊的回撥函式來將此描述符新增到就緒連結串列中,當程序呼叫epoll_wait()時就將就緒連結串列裡的fd從核心緩衝區複製到使用者的緩衝區,這次的copy是很少量的。
Java的IO模型可以參考: ofollow,noindex">談一談 Java IO 模型 | Matt's Blog 裡面說的比較好
參考資料:
阻塞和非阻塞,同步和非同步 總結 - banananana - 部落格園
關於同步、非同步與阻塞、非阻塞的理解 - Anker's Blog - 部落格園
Unix 網路 IO 模型及 Linux 的 IO 多路複用模型 | Matt's Blog