Linux IO模型


簡述

IO操作不外乎讀和寫,但是不同場景對讀寫有不同的需求,例如網絡中同時監控多個文件句柄,例如關鍵數據希望一路刷到存儲設備而不是扔到cache就返回。

怎么讀,怎么寫,等不等結果返回,是否等獲取到數據才發返回,組成了不同的IO模型,分別適用於不同的場景。

根據同步與異步,阻塞與非阻塞,可以把IO模型如下分類:

同步/異步阻塞/非阻塞怎么理解呢?

同步與異步,就是IO發起人是否等待數據操作結束。發起一次IO請求后,發起IO的進程死等操作結束才繼續往下跑,就是同步。發起一次IO請求后,不等結束直接往下跑,就是異步。

阻塞與非阻塞,就是沒有數據時是否等到有數據才返回。如果沒有數據,就讓IO進程干等,直到有數據再返回,就是阻塞。如果看到沒有數據,直接就返回了,就是非阻塞。

什么情況下會沒有數據呢?一個文件就這么大,除非讀到文件末尾了,不然怎么會說沒有數據呢?實際上,阻塞與非阻塞並不指磁盤上常規的文件讀寫,而是指socket或者pipe之類的特殊文件。這些特殊文件並沒有明確意義上的大小,就是說,理論上他們的數據是無限大的,只要有人往里面寫數據,你就能無限讀出數據。這種特殊文件有數據的前提,就是有人往里面”灌“數據。如果你讀的太快,別人寫得太慢,就會出現池子里面沒有數據的情況。這情況並不表示文件讀到末端了,只表示暫時還沒有數據。這時候你等呢?還是不等呢?這就是阻塞與非阻塞。

如果是同步/異步是IO發起人是否主動想等待,阻塞/非阻塞就是沒有數據時讀是否被動等待。

本文並不會嚴格按上圖依次介紹6種IO模型,相信網上有一大堆的資料。本文嘗試從常規讀寫、多路復用的2個常用場景介紹IO模型。

上圖中的信號IO,需要內核在某個時機向用戶空間傳遞SIGIO信號,且用戶空間在捕抓到此信號后進行IO處理。這做法並不常見,本文不會展開介紹。且由於是需要被動通知后才會執行IO操作,因此被我歸類為異步IO。

上圖中的事件驅動依賴於某些特定的庫。其原理類似於為某些事件注冊鈎子函數,由庫函數實現事件監控。在事件觸發時調用鈎子函數解決。這些函數庫屏蔽了平台之間的差異,例如Linux中通過epoll()來監控多個fd。不同庫有不同的使用方法,本文也不會展開介紹。

常規read()和write()

讀操作

/* 同步阻塞 */
fd = open("./test.txt", O_RDONLY); // 常規文件
ret = read(fd, buf, len);

/* 同步非阻塞 */
int fds[2];
ret = pipe2(fds, O_NONBLOCK); // 無名管道
ret = read(fds[0], buf, len);

/* 同步非阻塞 */
fd = open("./fifo", O_RDONLY | O_NONBLOCK); // 有名管道
ret = read(fd, buf, len);

在大多數場景下,我們系統調用read()正確返回時就表示已經讀到數據了,此次的IO操作已經結束了。毫無疑問,大多數情況下的讀操作,都是同步的。

是否要阻塞,取決於open()時是否有O_NONBLOCK參數。

總的來說,我們系統調用read(),除非指定O_NONBLOCK,否則都是同步且阻塞的。

寫操作

/* 異步 */
fd = open("./test.txt", O_WRONLY);
ret = write(fd, buf, len);

/* 同步 */
fd = open("./test.txt", O_WRONLY | O_SYNC);
ret = write(fd, buf, len);

阻塞與非阻塞主要針對讀數據,寫數據主要區分同步還是異步

由於Linux的IO棧中,有專門為IO准備的Cache層。在正常情況下,寫操作只是把數據直接扔到了Cache就返回了,此時數據並沒回刷到磁盤。要不等到系統回刷線程主動回刷,要不應用主動調用fsync(),否則數據一直都在Cache層,此時掉電數據就丟了。

寫操作是同步還是異步,主要看open()時,是否帶O_SYNC參數。帶O_SYNC就是同步寫,否則就是異步寫。

本文開頭就提到,同步/異步是IO發起人是否主動想等待IO結束,這里的寫IO結束,指的是數據完全寫入到磁盤,而非write()返回。
在沒有O_SYNC情況下,數據只是寫到了Cache,需要內核線程定期回刷,所以此時的write是並沒有結束的,因此是異步的。相反,如果有O_SYNC,write()操作會一直等到數據完全寫入到磁盤后再返回,所以是同步的。

從寫性能角度來說,異步寫會優於同步寫。由內核IO調度算法,對寫請求進行合並與排序,再一次性寫入,效率絕對高於東一塊西一塊的隨機寫。因此,除非是擔心掉電丟失的關鍵數據,否則建議使用異步寫

多路復用

多路復用常用於網絡開發,例如每個客戶端由一個socket與服務器進行遠程通信,此時這個服務程序需要同時監控多個socket,為了避免資源損耗和提高響應速率,就會使用多路復用。

多路復用是怎么一回事呢?我們假設一下有100個socket,在某一時間可能只有個別socket是有數據的,即客戶端向服務端發送的請求數據。此時服務程序怎么監控這100個socket,找出有數據的socket,並做出響應?有一種做法,就是非阻塞讀每個socket,沒數據直接返回讀下一個,有數據(請求)就響應,以此實現輪詢。還有一種做法,創建100個進程/線程,每個線程,進程對應一個socket。

對少量的文件還行,如果文件數量一多,數百個,上千個socket逐一輪詢,或者創建上千個線程,這效率得多低啊。可不可以批量等待,當哪怕有一個socket有數據時,內核直接告訴應用那個socket來數據了?可以!這就是內核支持的多路復用的系統調用select()epoll()

/* select 函數原型 */
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

/* epoll 函數原型 */
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

他們原理大抵相似:

  1. 創建一個文件句柄集合,把要監視的文件句柄按順序整合到一起
  2. 在有數據時置位對應的標識后返回
  3. 應用通過檢查標識就可以知道是哪個socket有數據了,此時讀socket即可直接獲取數據

具體的使用方法不在這里詳細介紹,網上有總多資料,可以參考《UNIX環境高級編程》。

本文順便記錄下select()epoll()的優缺點對比:

select()

  1. 每次調用select,都需要把fd集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大
  2. 每次調用select,都需要在內核遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大
  3. select支持的文件描述符數量太小了,默認是1024

epoll()

  1. epoll不僅僅一個函數,而是切分為3個函數,使得監控新的fd時,不需要拷貝所有的fd集合,只需要拷貝新的fd到內核即可。
  2. epoll采取回調的形式,當某個fd就緒了,就會調用回調,而在回調中,把就緒的fd加入就緒鏈表
  3. epoll沒有數量,它所支持的FD上限是最大可以打開文件的數目,這個數字一般遠大於2048

可以發現,epoll()是對select()存在的問題進行針對性的解決。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM