阻塞IO
傳統的阻塞IO
listenfd = socket(); // 打開一個網絡通信端口
bind(listenfd); // 綁定
listen(listenfd); // 監聽
while(1) {
connfd = accept(listenfd); // 阻塞建立連接
int n = read(connfd, buf); // 阻塞讀數據
doSomeThing(buf); // 利用讀到的數據做些什么
close(connfd); // 關閉連接,循環等待下一個連接
}
服務端的線程阻塞在了兩個地方,一個是 accept 函數,一個是 read 函數。
Read函數的細節,阻塞兩次,第一次是等待文件描述符就緒(網卡->內核緩沖區),第二階段是讀取數據(內核緩沖區->用戶緩沖區)。
整體流程
多線程阻塞IO
每次都創建一個新的進程或線程,去調用 read 函數,並做業務處理。
while(1) {
connfd = accept(listenfd); // 阻塞建立連接,建立一個連接后會阻塞等待下一個連接,第二階段的讀取阻塞交給子線程處理。
pthread_create(doWork); // 創建一個新的線程
}
void doWork() {
int n = read(connfd, buf); // 阻塞讀數據
doSomeThing(buf); // 利用讀到的數據做些什么
close(connfd); // 關閉連接,循環等待下一個連接
}
這樣,當給一個客戶端建立好連接后,就可以立刻等待新的客戶端連接,而不用阻塞在原客戶端的read請求上。
但是,這並不叫非阻塞IO,僅僅用了多線程的手段使得主線程沒有卡在read函數上不往下走。操作系統為我們提供的read函數仍然是阻塞的。
真正的非阻塞IO,不是能通過用戶層的小把戲,而是要懇請操作系統為我們提供一個非阻塞的read函數。
這個非阻塞IO的read函數的效果是如果沒有數據到達時(到達網卡並拷貝到了內核緩沖區),立刻返回一個錯誤值(-1),而不是阻塞地等待。
非阻塞IO
操作系統提供了非阻塞IO功能,只需要在調用read前,將文件描述符設置為非阻塞即可。
fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);
這樣,就需要用戶線程循環調用read,直到返回值不為-1,再開始處理業務。
非阻塞的read只有一個阻塞階段,在數據到達前,即數據還未到達網卡或者到達網卡但還沒有拷貝到內核緩沖區之前,這個階段是非阻塞的。
當數據已到達內核緩沖區,此時調用read函數仍然是阻塞的,需要等待數據從內核緩沖區拷貝到用戶緩沖區,才能返回。
IO多路復用
阻塞IO,為每個客戶端創建一個線程,服務器端的線程資源很容易被耗光。
非阻塞IO,每accept一個客戶端連接后,將這個文件描述符(connfd)放到一個數組里。
fdlist.add(connfd);
然后起一個新的線程去不斷遍歷這個數組,調用每一個元素的非阻塞read方法。
while(1) {
for(fd <-- fdlist) {
if(read(fd) != -1) {
doSomeThing();
}
}
}
這樣就成功用一個線程處理了多個客戶端連接。
有點多路復用的意思,但這和我們用多線程去將阻塞IO改造成看起來是非阻塞IO一樣,這種遍歷方式也只是我們用戶自己想出的小把戲,每次遍歷遇到read返回-1時仍然是一次浪費資源的系統調用。
所以,還是得懇請操作系統,提供一個函數,將一批文件描述符通過一次系統調用傳給內核,由內核層去遍歷,才能真正解決這個問題。
select
select是操作系統提供的系統調用函數,通過它,我們可以把一個文件描述符的數組發給操作系統,讓操作系統去遍歷,確定哪個文件描述符可以讀寫,然后告訴我們去處理:
select系統調用的函數定義:
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
// nfds:監控的文件描述符集里最大文件描述符加1
// readfds:監控有讀數據到達文件描述符集合,傳入傳出參數
// writefds:監控寫數據到達文件描述符集合,傳入傳出參數
// exceptfds:監控異常發生達文件描述符集合, 傳入傳出參數
// timeout:定時阻塞監控時間,3種情況
// 1.NULL,永遠等下去
// 2.設置timeval,等待固定時間
// 3.設置timeval里時間均為0,檢查描述字后立即返回,輪詢
服務端代碼
首先一個線程不斷接受客戶端連接,並把socket文件描述符放到一個 list 里。
while(1) {
connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
fdlist.add(connfd);
}
然后,另一個線程不再自己遍歷,而是調用select,將這批文件描述符list交給操作系統去遍歷。
while(1) {
// 把一堆文件描述符 list 傳給 select 函數
// 有已就緒的文件描述符就返回,nready 表示有多少個就緒的
nready = select(list);
...
}
當select函數返回后,用戶依然需要遍歷剛剛提交給操作系統的 list。
只不過,操作系統會將准備就緒的文件描述符做上標識,用戶層將不會再有無意義的系統調用開銷。
while(1) {
nready = select(list); //阻塞,當有文件描述符就緒時才會返回已就緒數量
// 用戶層依然要遍歷,只不過少了很多無效的系統調用
for(fd <-- fdlist) {
if(fd != -1) {
// 只讀已就緒的文件描述符
read(fd, buf);
// 總共只有 nready 個已就緒描述符,不用過多遍歷
if(--nready == 0) break;
}
}
}
select細節
- select 調用需要傳入 fd 數組,需要拷貝一份到內核,高並發場景下這樣的拷貝消耗的資源是驚人的。(可優化為不復制)
- select 在內核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態,是個同步過程,只不過無系統調用切換上下文的開銷。(內核層可優化為異步事件通知)
- select 僅僅返回可讀文件描述符的個數,具體哪個可讀還是要用戶自己遍歷。(可優化為只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)
select流程
這種方式,既做到了一個線程處理多個客戶端連接(文件描述符),又減少了系統調用的開銷(多個文件描述符只有一次select的系統調用 +n次就緒狀態的文件描述符的read系統調用)。
poll
poll 也是操作系統提供的系統調用函數。
int poll(struct pollfd *fds, nfds_tnfds, int timeout);
struct pollfd {
intfd; /*文件描述符*/
shortevents; /*監控的事件*/
shortrevents; /*監控事件中滿足條件返回的事件*/
};
它和select的主要區別就是,去掉了select只能監聽 1024 個文件描述符的限制。
epoll
epoll是最終的大boss,它解決了select和poll的一些問題。
select的三個細節:
- select 調用需要傳入 fd 數組,需要拷貝一份到內核,高並發場景下這樣的拷貝消耗的資源是驚人的。(可優化為不復制)
- select 在內核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態,是個同步過程,只不過無系統調用切換上下文的開銷。(內核層可優化為異步事件通知)
- select 僅僅返回可讀文件描述符的個數,具體哪個可讀還是要用戶自己遍歷。(可優化為只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)。
epoll改進select三個細節
- 內核中保存一份文件描述符集合,無需用戶每次都重新傳入,只需告訴內核修改的部分即可。
- 內核不再通過輪詢的方式找到就緒的文件描述符,而是通過異步IO事件喚醒。
- 內核僅會將有IO事件的文件描述符返回給用戶,用戶也無需遍歷整個文件描述符集合。
具體,操作系統提供了這三個函數。
第一步,創建一個 epoll 句柄
int epoll_create(int size);
第二步,向內核添加、修改或刪除要監控的文件描述符。
int epoll_ctl(
int epfd, int op, int fd, struct epoll_event *event);
第三步,類似發起了select()調用
int epoll_wait(
int epfd, struct epoll_event *events, int max events, int timeout);
epoll流程
總結
一切的開始,都起源於這個 read 函數是操作系統提供的,而且是阻塞的,我們叫它 阻塞 IO。
為了破這個局,程序員在用戶態通過多線程來防止主線程卡死。
后來操作系統發現這個需求比較大,於是在操作系統層面提供了非阻塞的 read 函數,這樣程序員就可以在一個線程內完成多個文件描述符的讀取,這就是 非阻塞 IO。
但多個文件描述符的讀取就需要遍歷,當高並發場景越來越多時,用戶態遍歷的文件描述符也越來越多,相當於在 while 循環里進行了越來越多的系統調用。
后來操作系統又發現這個場景需求量較大,於是又在操作系統層面提供了這樣的遍歷文件描述符的機制,這就是 IO 多路復用。
多路復用有三個函數,最開始是 select,然后又發明了 poll 解決了 select 文件描述符的限制,然后又發明了 epoll 解決 select 的三個不足。
多路復用產生的效果,完全可以由用戶態去遍歷文件描述符並調用其非阻塞的 read 函數實現。而多路復用快的原因在於,操作系統提供了這樣的系統調用,使得原來的 while 循環里多次系統調用,變成了一次系統調用 + 內核層遍歷這些文件描述符。
就好比我們平時寫業務代碼,把原來 while 循環里調 http 接口進行批量添加,改成了讓對方提供一個批量添加的 http 接口,然后我們一次 rpc 請求就完成了批量添加。
參考:
https://mp.weixin.qq.com/s/YdIdoZ_yusVWza1PU7lWaw
http://www.pulpcode.cn/2017/02/01/user-buffer-and-kernel-buffer/
https://mp.weixin.qq.com/s?__biz=MjM5Njg5NDgwNA==&mid=2247484905&idx=1&sn=a74ed5d7551c4fb80a8abe057405ea5e&chksm=a6e304d291948dc4fd7fe32498daaae715adb5f84ec761c31faf7a6310f4b595f95186647f12&scene=21#wechat_redirect