計算機概念——io 複用

敖毛毛發表於2024-11-25

前言

首先什麼是io複用呢?

現在web框架沒有不用到io複用的,這點是肯定的,不然併發真的很差。

那麼io複用,複用的是什麼呢?複用的真的不是io管道啥的,也不是io連線啥的,複用的是io執行緒。

這其實是作業系統的設計上的io併發不足的地方,然後自己給慢慢填了。

正文

聽一段歷史:

當時作業系統設計的時候呢? 按道理說是要作業系統去管理io這塊,也就是對我們遮蔽硬體。

然後我們就呼叫系統的介面,讓作業系統去幫我們讀取資料啥的,這似乎是非常的nice的時候。

作業系統就設計了兩種讀取方案:

第一種呢,比如自己呼叫io系統介面,然後執行緒陷入等待,當有資料的時候呢,作業系統喚醒我們的執行緒

第二種,就是自己呼叫系統介面,然後作業系統告訴我們沒有,這個時候我們可以選擇做其他事情,或者等會再來問。

因為對io是抽象的,這個時候呢,如果是磁碟檔案系統,這還是相當nice的,因為我們又不關心是網路io還是檔案io,讀取就完事了。

磁碟檔案我們一般是對某個檔案連續讀對吧,這樣這兩種方式工作都是很好的。

可能有些人一直認為讀磁碟檔案就是立即返回的,其實可以呼叫其他方式,當磁碟有新資料的時候再返回,是可以的,在作業系統系統會補全這些機制的說明,這裡不擴充套件。

但是網路io有個什麼樣的場景呢?那就是連線數可能特別多,而且是每個間斷著讀取。

加入我們採用第一種方式,那麼每一個連線,就需要一個執行緒去監控,這樣就會很多執行緒,這樣的確會出現執行緒大爆炸,而且執行緒排程是需要開銷的。

那麼我們採用第二種方式,第二種方式好像每個都需要一個執行緒,其實不需要哈。

我們可以排隊嘛,比如把socket放入到一個佇列中,然後不斷地輪訓,這樣其實就實現了io複用了,這個時候就有人問了,io複用這麼簡單嗎?

是的,這就是io複用了。呀呀呀,這就實現了,是的,這就實現了io執行緒複用,就是一個或者多個執行緒實現io的資料接收嘛。

但是效能不太行,不太行的地方在於兩點,就是加入有5000個連線,只有2個有資訊,那麼得去輪訓一遍,這效率真的很感人。

還要就是不斷地呼叫作業系統的陷入這個開銷也是很大的。

那麼這個時候作業系統自己就得改進了,明明就是作業系統自己知道有沒有訊息,為啥不主動告訴我呢?

那麼作業系統自己做了一些改進。

select 函式.

#define __FD_SETSIZE 1024

typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;

struct timeval {
time_t      tv_sec;         /* seconds */
suseconds_t tv_usec;        /* microseconds */
};

//函式宣告
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

透過select函式可以完成多個IO事件的監聽。

函式引數:

readfds:核心檢測該集合中的IO是否可讀。如果想讓核心幫忙檢測某個IO是否可讀,需要手動把檔案描述符加入該集合。
writefds:核心檢測該集合中的IO是否可寫。同readfds,需要手動加入
exceptfds:核心檢測該集合中的IO是否異常。同readfds,需要手動加入
nfds:以上三個集合中最大的檔案描述符數值 + 1,例如集合是{0,1,4},那麼 maxfd 就是 5
timeout:使用者執行緒呼叫select的超時時長。 
設定成NULL,表示如果沒有 I/O 事件發生,則 select 一直等待下去。
設定為非0的值,這個表示等待固定的一段時間後從 select 阻塞呼叫中返回。
設定成 0,表示根本不等待,檢測完畢立即返回。
函式返回值:

大於0:成功,返回集合中已就緒的IO總個數
等於-1:呼叫失敗
等於0:沒有就緒的IO
// 將檔案描述符fd從set集合中刪除 
void FD_CLR(int fd, fd_set *set); 

// 判斷檔案描述符fd是否在set集合中 
int  FD_ISSET(int fd, fd_set *set); 

// 將檔案描述符fd新增到set集合中 
void FD_SET(int fd, fd_set *set); 

// 將set集合中, 所有檔案描述符對應的標誌位設定為0
void FD_ZERO(fd_set *set); 

select 缺點:

1.fd_set長度限制:由於fd_set本質是一個陣列,同時作業系統限制了其長度,導致其只能接受檔案描述符數值在1024以內的。
2. select函式的返回值是int,導致每次返回後,使用者得手動檢測集合中哪些值被改為1了(被改為1的表示產生了IO就緒事件)
3. 每次呼叫 select,都需要把 fd 集合從使用者態複製到核心態,當fd很多時,開銷很大。
4. 每次核心都是線性掃描整個 fd_set,判斷是否有IO就緒事件,導致隨著監控的描述符 fd 數量增長,其效能會線性下降

看到select缺點這麼多,看著就不怎麼好用。

這個其實就是批次檢測,然後加了一個timeout,最後還得自己去輪訓一遍,還是有點坑。

這時候可能我們都會想,自己設計都不會這麼坑。其實這樣設計也是當時的一個常規設計,因為既要保全核心的穩定,又要維護陷入函式的簡單,後面就能看到資料結構之美了。

然後就到了poll了:

和 select 相比,它使用了不同的方式儲存檔案描述符,也解決檔案描述符的個數限制。

struct pollfd {
    int fd; /* file descriptor */
    short events; /* events to look for */
    short revents; /* events returned */
};

int poll(struct pollfd *fds, unsigned long nfds, int timeout);    

函式引數:

fds:struct pollfd型別的陣列, 儲存了待檢測的檔案描述符,struct pollfd有三個成員:
fd:委託核心檢測的檔案描述符
events:委託核心檢測的fd事件(輸入、輸出、錯誤),每一個事件有多個取值
revents:這是一個傳出引數,資料由核心寫入,儲存核心檢測之後的結果
nfds:描述的是陣列 fds 的大小
timeout: 指定poll函式的阻塞時長
-1:一直阻塞,直到檢測的集合中有就緒的IO事件,然後解除阻塞函式返回
0:不阻塞,不管檢測集合中有沒有已就緒的IO事件,函式馬上返回
大於0:表示 poll 呼叫方等待指定的毫秒數後返回
函式返回值:

-1:失敗
大於0:表示檢測的集合中已就緒的檔案描述符的總個數
在 select 裡面,檔案描述符的個數已經隨著 fd_set 的實現而固定,沒有辦法對此進行配置;而在 poll 函式里,我們可以自由控制 pollfd 結構的陣列大小,從而突破select中面臨的檔案描述符個數的限制。

這個pollfd就設計的人性化多了哈,有個fd然後裡面是事件,看起來還是不錯的,很物件導向。

poll 的實現和 select 非常相似,只是poll 使用 pollfd 結構,而 select 使用fd_set 結構,poll 解決了檔案描述符數量限制的問題,但是同樣需要從使用者態複製所有的 fd 到核心態,

也需要線性遍歷所有的 fd 集合,所以它和 select 並沒有本質上的區別。

所以呢,有人如果系統用poll和epoll比,這兩個思想就不一樣,下文可見,poll只是在select上進行輕微的改進,和作業系統的溝通真的很感人,但是這個結構化,還是很舒服的,尤其是寫了很多物件導向程式碼後

epoll 是 Linux kernel 2.6 之後引入的新 I/O 事件驅動技術,它解決了select、poll在效能上的缺點,是目前IO多路複用的主流解決方案。

epoll 實現:

int epoll_create(int size);  
int epoll_create1(int flags);

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);

struct epoll_event {
    __uint32_t events;
    epoll_data_t data;
};

union epoll_data {
 void     *ptr;
 int       fd;
 uint32_t  u32;
 uint64_t  u64;
};
typedef union epoll_data  epoll_data_t;

1.epoll_creare、epoll_create1:這兩個函式的作用是一樣的,都是建立一個epoll例項。
2. epoll_ctl:在建立完 epoll 例項之後,可以透過呼叫 epoll_ctl 往 epoll 例項增加或刪除需要監控的IO事件。

epoll_ctl:在建立完 epoll 例項之後,可以透過呼叫 epoll_ctl 往 epoll 例項增加或刪除需要監控的IO事件。
epfd:呼叫 epoll_create 建立的 epoll 獲得的返回值,可以簡單理解成是 epoll 例項的唯一標識。
op:表示是增加還是刪除一個監控事件,它有三個選項可供選擇:
EPOLL_CTL_ADD: 向 epoll 例項註冊檔案描述符對應的事件;
EPOLL_CTL_DEL:向 epoll 例項刪除檔案描述符對應的事件;
EPOLL_CTL_MOD: 修改檔案描述符對應的事件。
fd:需要註冊的事件的檔案描述符。
epoll_event:表示需要註冊的事件型別,並且可以在這個結構體裡設定使用者需要的資料。
events:表示需要註冊的事件型別,可選值在下文的 Linux 常見網路IO事件定義中列出
data:可以存放使用者自定義的資料。

人性化來了,可以自己刪除和新增了,不用一次性給了。
3.epoll_wait:呼叫者程序呼叫該函式等待I/O 事件就緒。

epfd: epoll 例項的唯一標識。
epoll_event:相關事件
maxevents:一個大於 0 的整數,表示 epoll_wait 可以返回的最大事件值。
timeout: 
-1:一直阻塞,直到檢測的集合中有就緒的IO事件,然後解除阻塞函式返回
0:不阻塞,不管檢測集合中有沒有已就緒的IO事件,函式馬上返回
大於0:表示 epoll 呼叫方等待指定的毫秒數後返回

在核心中eventpoll結構如下:

struct eventpoll {
   /* Wait queue used by sys_epoll_wait() */
 wait_queue_head_t wq;

 /* List of ready file descriptors */
 struct list_head rdllist;

    /* RB tree root used to store monitored fd structs */
 struct rb_root_cached rbr;
}

wq:當使用者程序執行了epoll_wait導致了阻塞後,使用者程序就會儲存到這,等待後續資料準備完成後喚醒。
rdllist:當某個檔案描述符就緒了後,就會從rbr移動到這。其本質是一個連結串列。
rbr:使用者呼叫epoll_ctl增加的檔案描述都儲存在這,其本質是一顆紅黑樹。

先不介紹紅黑樹,到了紅黑樹介紹的時候再畫圖,不然這個過程可能比較難理解為啥epoll這麼高效,除非利用了紅黑樹,那麼看下這個改變的地方。

  1. 我們多個程序可以監控利用epoll_ctl進行監控,其實一般就一個,根據業務也可以多個
  2. 當觸發事件的時候呢,就觸發的事件再rbr中移除,然後加入到了rdllist中(之所以要移除就說明觸發了,就不需要再繼續監控該事件了唄)
  3. 當rdllist裡面有事件後,那麼就會獲取到wq等待的程序,進行通知,也就是說epoll_wait就已經返回了相關的epoll_event,不需要再輪訓一遍了

相當人性化哈,這是我們理解了我們作為使用者和作業系統直接的溝通橋樑塑造好了,所以效率也就高了。

那麼問題來了,為啥epoll的效率這麼高呢,除了解決和作業系統的溝通問題,還要什麼經過最佳化呢,後續關於紅黑樹的介紹,黑紅樹之所以再這裡能發揮作用就是因為其頻繁的加入和刪除,以及遍歷。

那麼請問epoll是阻塞io,還是非阻塞io呢?

那肯定是阻塞io嘛,有個timeout當事件到了就返回了,但是也屬於阻塞。

然後呢,我們如果用c語言的時候,發現網路io和磁碟io其實是用不同的庫,但是他們也的確可以用的底層函式read讀取,都抽象成檔案了嘛,之所以用不同的庫是因為這兩個方向針對的場景的確不同,庫嘛,肯定是幫忙封裝好了的,用好庫比啥都重要。

後續作業系統系列慢慢整理,紅黑樹也得整理下,以上個人簡單整理和理解,如有錯誤忘請指正。

相關文章