作者 |?京東云開發(fā)者-京東零售?石朝陽
在說 IO 多路復用模型之前,我們先來大致了解下 Linux 文件系統(tǒng)。在 Linux 系統(tǒng)中,不論是你的鼠標,鍵盤,還是打印機,甚至于連接到本機的 socket client 端,都是以文件描述符的形式存在于系統(tǒng)中,諸如此類,等等等等,所以可以這么說,一切皆文件。來看一下系統(tǒng)定義的文件描述符說明:
?? 從上面的列表可以看到,文件描述符 0,1,2 都已經(jīng)被系統(tǒng)占用了,當系統(tǒng)啟動的時候,這三個描述符就存在了。其中 0 代表標準輸入,1 代表標準輸出,2 代表錯誤輸出。當我們創(chuàng)建新的文件描述符的時候,就會在 2 的基礎(chǔ)上進行遞增。可以這么說,文件描述符是為了管理被打開的文件而創(chuàng)建的系統(tǒng)索引,他代表了文件的身份 ID。對標 windows 的話,你可以認為和句柄類似,這樣就更容易理解一些。 由于網(wǎng)上對 linux 文件這塊的原理描述的文章已經(jīng)非常多了,所以這里我不再做過多的贅述,感興趣的同學可以從 Wikipedia 翻閱一下。由于這塊內(nèi)容比較復雜,不屬于本文普及的內(nèi)容,建議讀者另行自研,這里我非常推薦馬士兵老師將 linux 文件系統(tǒng)這塊,講解的真的非常好。
select 模型
此模型是 IO 多路復用的最早期使用的模型之一,距今已經(jīng)幾十年了,但是現(xiàn)在依舊有不少應用還在采用此種方式,可見其長生不老。首先來看下其具體的定義(來源于 man 二類文檔):
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);
這里解釋下其具體參數(shù):
參數(shù)一:nfds,也即 maxfd,最大的文件描述符遞增一。這里之所以傳最大描述符,為的就是在遍歷 fd_set 的時候,限定遍歷范圍。
參數(shù)二:readfds,可讀文件描述符集合。
參數(shù)三:writefds,可寫文件描述符集合。
參數(shù)四:errorfds,異常文件描述符集合。
參數(shù)五:timeout,超時時間。在這段時間內(nèi)沒有檢測到描述符被觸發(fā),則返回。
下面的宏處理,可以對 fd_set 集合(準確的說是 bitmap,一個描述符有變更,則會在描述符對應的索引處置 1)進行操作:
FD_CLR (inr fd,fd_set* set) 用來清除描述詞組 set 中相關(guān) fd 的位,即 bitmap 結(jié)構(gòu)中索引值為 fd 的值置為 0。
FD_ISSET (int fd,fd_set *set) 用來測試描述詞組 set 中相關(guān) fd 的位是否為真,即 bitmap 結(jié)構(gòu)中某一位是否為 1。
FD_SET(int fd,fd_set*set) 用來設(shè)置描述詞組 set 中相關(guān) fd 的位,即將 bitmap 結(jié)構(gòu)中某一位設(shè)置為 1,索引值為 fd。
FD_ZERO(fd_set *set) 用來清除描述詞組 set 的全部位,即將 bitmap 結(jié)構(gòu)全部清零。
首先來看一段服務端采用了 select 模型的示例代碼:
//創(chuàng)建server端套接字,獲取文件描述符 int listenfd = socket(PF_INET,SOCK_STREAM,0); if(listenfd < 0) return -1; //綁定服務器 bind(listenfd,(struct sockaddr*)&address,sizeof(address)); //監(jiān)聽服務器 listen(listenfd,5); struct sockaddr_in client; socklen_t addr_len = sizeof(client); //接收客戶端連接 int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len); //讀緩沖區(qū) char buff[1024]; //讀文件操作符 fd_set read_fds; while(1) { memset(buff,0,sizeof(buff)); //注意:每次調(diào)用select之前都要重新設(shè)置文件描述符connfd,因為文件描述符表會在內(nèi)核中被修改 FD_ZERO(&read_fds); FD_SET(connfd,&read_fds); //注意:select會將用戶態(tài)中的文件描述符表放到內(nèi)核中進行修改,內(nèi)核修改完畢后再返回給用戶態(tài),開銷較大 ret = select(connfd+1,&read_fds,NULL,NULL,NULL); if(ret < 0) { printf("Fail to select! "); return -1; } //檢測文件描述符表中相關(guān)請求是否可讀 if(FD_ISSET(connfd, &read_fds)) { ret = recv(connfd,buff,sizeof(buff)-1,0); printf("receive %d bytes from client: %s ",ret,buff); } }
上面的代碼我加了比較詳細的注釋了,大家應該很容易看明白,說白了大概流程其實如下:
首先,創(chuàng)建 socket 套接字,創(chuàng)建完畢后,會獲取到此套接字的文件描述符。
然后,bind 到指定的地址進行監(jiān)聽 listen。這樣,服務端就在特定的端口啟動起來并進行監(jiān)聽了。
之后,利用開啟 accept 方法來監(jiān)聽客戶端的連接請求。一旦有客戶端連接,則將獲取到當前客戶端連接的 connection 文件描述符。
雙方建立連接之后,就可以進行數(shù)據(jù)互傳了。需要注意的是,在循環(huán)開始的時候,務必每次都要重新設(shè)置當前 connection 的文件描述符,是因為文件描描述符表在內(nèi)核中被修改過,如果不重置,將會導致異常的情況。
重新設(shè)置文件描述符后,就可以利用 select 函數(shù)從文件描述符表中,來輪詢哪些文件描述符就緒了。此時系統(tǒng)會將用戶態(tài)的文件描述符表發(fā)送到內(nèi)核態(tài)進行調(diào)整,即將準備就緒的文件描述符進行置位,然后再發(fā)送給用戶態(tài)的應用中來。
用戶通過 FD_ISSET 方法來輪詢文件描述符,如果數(shù)據(jù)可讀,則讀取數(shù)據(jù)即可。
舉個例子,假設(shè)此時連接上來了 3 個客戶端,connection 的文件描述符分別為 4,8,12,那么其 read_fds 文件描述符表(bitmap 結(jié)構(gòu))的大致結(jié)構(gòu)為 00010001000100000....0,由于 read_fds 文件描述符的長度為 1024 位,所以最多允許 1024 個連接。
而在 select 的時候,涉及到用戶態(tài)和內(nèi)核態(tài)的轉(zhuǎn)換,所以整體轉(zhuǎn)換方式如下:
所以,綜合起來,select 整體還是比較高效和穩(wěn)定的,但是呈現(xiàn)出來的問題也不少,這些問題進一步限制了其性能發(fā)揮:
文件描述符表為 bitmap 結(jié)構(gòu),且有長度為 1024 的限制。
fdset 無法做到重用,每次循環(huán)必須重新創(chuàng)建。
頻繁的用戶態(tài)和內(nèi)核態(tài)拷貝,性能開銷較大。
需要對文件描述符表進行遍歷,O (n) 的輪詢時間復雜度。
poll 模型
考慮到 select 模型的幾個限制,后來進行了改進,這也就是 poll 模型,既然是 select 模型的改進版,那么肯定有其亮眼的地方,一起來看看吧。當然,這次我們依舊是先翻閱 linux man 二類文檔,因為這是官方的文檔,對其有著最為精準的定義。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其實,從運行機制上說來,poll 所做的功能和 select 是基本上一樣的,都是等待并檢測一組文件描述符就緒,然后在進行后續(xù)的 IO 處理工作。只不過不同的是,select 中,采用的是 bitmap 結(jié)構(gòu),長度限定在 1024 位的文件描述符表,而 poll 模型則采用的是 pollfd 結(jié)構(gòu)的數(shù)組 fds,也正是由于 poll 模型采用了數(shù)組結(jié)構(gòu),則不會有 1024 長度限制,使其能夠承受更高的并發(fā)。
pollfd 結(jié)構(gòu)內(nèi)容如下:
struct pollfd { int fd; /* 文件描述符 */ short events; /* 關(guān)心的事件 */ short revents; /* 實際返回的事件 */ };
從上面的結(jié)構(gòu)可以看出,fd 很明顯就是指文件描述符,也就是當客戶端連接上來后,fd 會將生成的文件描述符保存到這里;而 events 則是指用戶想關(guān)注的事件;revents 則是指實際返回的事件,是由系統(tǒng)內(nèi)核填充并返回,如果當前的 fd 文件描述符有狀態(tài)變化,則 revents 的值就會有相應的變化。
events 事件列表如下:
revents 事件列表如下:
從列表中可以看出,revents 是包含 events 的。接下來結(jié)合示例來看一下:
//創(chuàng)建server端套接字,獲取文件描述符 int listenfd = socket(PF_INET,SOCK_STREAM,0); if(listenfd < 0) return -1; //綁定服務器 bind(listenfd,(struct sockaddr*)&address,sizeof(address)); //監(jiān)聽服務器 listen(listenfd,5); struct pollfd pollfds[1]; socklen_t addr_len = sizeof(client); //接收客戶端連接 int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len); //放入fd數(shù)組 pollfds[0].fd = connfd; pollfds[0].events = POLLIN; //讀緩沖區(qū) char buff[1024]; //讀文件操作符 fd_set read_fds; while(1) { memset(buff,0,sizeof(buff)); /** ** SELECT模型專用 ** 注意:每次調(diào)用select之前都要重新設(shè)置文件描述符connfd,因為文件描述符表會在內(nèi)核中被修改 ** FD_ZERO(&read_fds); ** FD_SET(connfd,&read_fds); ** 注意:select會將用戶態(tài)中的文件描述符表放到內(nèi)核中進行修改,內(nèi)核修改完畢后再返回給用戶態(tài),開銷較大 ** ret = select(connfd+1,&read_fds,NULL,NULL,NULL); **/ ret = poll(pollfds, 1, 1000); if(ret < 0) { printf("Fail to poll! "); return -1; } /** ** SELECT模型專用 ** 檢測文件描述符表中相關(guān)請求是否可讀 ** if(FD_ISSET(connfd, &read_fds)) ** { ** ret = recv(connfd,buff,sizeof(buff)-1,0); ** printf("receive %d bytes from client: %s ",ret,buff); ** } **/ //檢測文件描述符數(shù)組中相關(guān)請求 if(pollfds[0].revents & POLLIN){ pollfds[0].revents = 0; ret = recv(connfd,buff,sizeof(buff)-1,0); printf("receive %d bytes from client: %s ",ret,buff); } }
由于源碼中,我做了比較詳細的注釋,同時將和 select 模型不一樣的地方都列了出來,這里就不再詳細解釋了。總體說來,poll 模型比 select 模型要好用一些,去掉了一些限制,但是仍然避免不了如下的問題:
用戶態(tài)和內(nèi)核態(tài)仍需要頻繁切換,因為 revents 的賦值是在內(nèi)核態(tài)進行的,然后再推送到用戶態(tài),和 select 類似,整體開銷較大。
仍需要遍歷數(shù)組,時間復雜度為 O(N)。
epoll 模型
如果說 select 模型和 poll 模型是早期的產(chǎn)物,在性能上有諸多不盡人意之處,那么自 linux 2.6 之后新增的 epoll 模型,則徹底解決了性能問題,一舉使得單機承受百萬并發(fā)的課題變得極為容易。現(xiàn)在可以這么說,只需要一些簡單的設(shè)置更改,然后配合上 epoll 的性能,實現(xiàn)單機百萬并發(fā)輕而易舉。同時,由于 epoll 整體的優(yōu)化,使得之前的幾個比較耗費性能的問題不再成為羈絆,所以也成為了 linux 平臺上進行網(wǎng)絡(luò)通訊的首選模型。
講解之前,還是 linux man 文檔鎮(zhèn)樓:linux man epoll 4 類文檔 linux man epoll 7 類文檔,倆文檔結(jié)合著讀,會對 epoll 有個大概的了解。和之前提到的 select 和 poll 不同的是,此二者皆屬于系統(tǒng)調(diào)用函數(shù),但是 epoll 則不然,他是存在于內(nèi)核中的數(shù)據(jù)結(jié)構(gòu),可以通過 epoll_create,epoll_ctl 及 epoll_wait 三個函數(shù)結(jié)合來對此數(shù)據(jù)結(jié)構(gòu)進行操控。
說道 epoll_create 函數(shù),其作用是在內(nèi)核中創(chuàng)建一個 epoll 數(shù)據(jù)結(jié)構(gòu)實例,然后將返回此實例在系統(tǒng)中的文件描述符。此 epoll 數(shù)據(jù)結(jié)構(gòu)的組成其實是一個鏈表結(jié)構(gòu),我們稱之為 interest list,里面會注冊連接上來的 client 的文件描述符。
其簡化工作機制如下:
說道 epoll_ctl 函數(shù),其作用則是對 epoll 實例進行增刪改查操作。有些類似我們常用的 CRUD 操作。這個函數(shù)操作的對象其實就是 epoll 數(shù)據(jù)結(jié)構(gòu),當有新的 client 連接上來的時候,他會將此 client 注冊到 epoll 中的 interest list 中,此操作通過附加 EPOLL_CTL_ADD 標記來實現(xiàn);當已有的 client 掉線或者主動下線的時候,他會將下線的 client 從 epoll 的 interest list 中移除,此操作通過附加 EPOLL_CTL_DEL 標記來實現(xiàn);當有 client 的文件描述符有變更的時候,他會將 events 中的對應的文件描述符進行更新,此操作通過附加 EPOLL_CTL_MOD 來實現(xiàn);當 interest list 中有 client 已經(jīng)準備好了,可以進行 IO 操作的時候,他會將這些 clients 拿出來,然后放到一個新的 ready list 里面。
其簡化工作機制如下:
說道 epoll_wait 函數(shù),其作用就是掃描 ready list,處理準備就緒的 client IO,其返回結(jié)果即為準備好進行 IO 的 client 的個數(shù)。通過遍歷這些準備好的 client,就可以輕松進行 IO 處理了。
上面這三個函數(shù)是 epoll 操作的基本函數(shù),但是,想要徹底理解 epoll,則需要先了解這三塊內(nèi)容,即:inode,鏈表,紅黑樹。
在 linux 內(nèi)核中,針對當前打開的文件,有一個 open file table,里面記錄的是所有打開的文件描述符信息;同時也有一個 inode table,里面則記錄的是底層的文件描述符信息。這里假如文件描述符 B fork 了文件描述符 A,雖然在 open file table 中,我們看新增了一個文件描述符 B,但是實際上,在 inode table 中,A 和 B 的底層是一模一樣的。這里,將 inode table 中的內(nèi)容理解為 windows 中的文件屬性,會更加貼切和易懂。這樣存儲的好處就是,無論上層文件描述符怎么變化,由于 epoll 監(jiān)控的數(shù)據(jù)永遠是 inode table 的底層數(shù)據(jù),那么我就可以一直能夠監(jiān)控到文件的各種變化信息,這也是 epoll 高效的基礎(chǔ)。更多詳細信息,請參閱這兩篇文章:Nonblocking IO & The method to epoll's madness.
簡化流程如下:
數(shù)據(jù)存儲這塊解決了,那么針對連接上來的客戶端 socket,該用什么數(shù)據(jù)結(jié)構(gòu)保存進來呢?這里用到了紅黑樹,由于客戶端 socket 會有頻繁的新增和刪除操作,而紅黑樹這塊時間復雜度僅僅為 O (logN),還是挺高效的。有人會問為啥不用哈希表呢?當大量的連接頻繁的進行接入或者斷開的時候,擴容或者其他行為將會產(chǎn)生不少的 rehash 操作,而且還要考慮哈希沖突的情況。雖然查詢速度的確可以達到 o (1),但是 rehash 或者哈希沖突是不可控的,所以基于這些考量,我認為紅黑樹占優(yōu)一些。
客戶端 socket 怎么管理這塊解決了,接下來,當有 socket 有數(shù)據(jù)需要進行讀寫事件處理的時候,系統(tǒng)會將已經(jīng)就緒的 socket 添加到雙向鏈表中,然后通過 epoll_wait 方法檢測的時候,其實檢查的就是這個雙向鏈表,由于鏈表中都是就緒的數(shù)據(jù),所以避免了針對整個客戶端 socket 列表進行遍歷的情況,使得整體效率大大提升。整體的操作流程為:
首先,利用 epoll_create 在內(nèi)核中創(chuàng)建一個 epoll 對象。其實這個 epoll 對象,就是一個可以存儲客戶端連接的數(shù)據(jù)結(jié)構(gòu)。
然后,客戶端 socket 連接上來,會通過 epoll_ctl 操作將結(jié)果添加到 epoll 對象的紅黑樹數(shù)據(jù)結(jié)構(gòu)中。
然后,一旦有 socket 有事件發(fā)生,則會通過回調(diào)函數(shù)將其添加到 ready list 雙向鏈表中。
最后,epoll_wait 會遍歷鏈表來處理已經(jīng)準備好的 socket,然后通過預先設(shè)置的水平觸發(fā)或者邊緣觸發(fā)來進行數(shù)據(jù)的感知操作。
從上面的細節(jié)可以看出,由于 epoll 內(nèi)部監(jiān)控的是底層的文件描述符信息,可以將變更的描述符直接加入到 ready list,無需用戶將所有的描述符再進行傳入。同時由于 epoll_wait 掃描的是已經(jīng)就緒的文件描述符,避免了很多無效的遍歷查詢,使得 epoll 的整體性能大大提升,可以說現(xiàn)在只要談論 linux 平臺的 IO 多路復用,epoll 已經(jīng)成為了不二之選。
水平觸發(fā)和邊緣觸發(fā)
上面說到了 epoll,主要講解了 client 端怎么連進來,但是并未詳細的講解 epoll_wait 怎么被喚醒的,這里我將來詳細的講解一下。
水平觸發(fā),意即 Level Trigger,邊緣觸發(fā),意即 Edge Trigger,如果單從字面意思上理解,則不太容易,但是如果將硬件設(shè)計中的水平沿,上升沿,下降沿的概念引進來,則理解起來就容易多了。比如我們可以這樣認為:
如果將上圖中的方塊看做是 buffer 的話,那么理解起來則就更加容易了,比如針對水平觸發(fā),buffer 只要是一直有數(shù)據(jù),則一直通知;而邊緣觸發(fā),則 buffer 容量發(fā)生變化的時候,才會通知。雖然可以這樣簡單的理解,但是實際上,其細節(jié)處理部分,比圖示中展現(xiàn)的更加精細,這里來詳細的說一下。
邊緣觸發(fā)
針對讀操作,也就是當前 fd 處于 EPOLLIN 模式下,即可讀。此時意味著有新的數(shù)據(jù)到來,接收緩沖區(qū)可讀,以下 buffer 都指接收緩沖區(qū):
buffer 由空變?yōu)榉强眨饧从袛?shù)據(jù)進來的時候,此過程會觸發(fā)通知。
buffer 原本有些數(shù)據(jù),這時候又有新數(shù)據(jù)進來的時候,數(shù)據(jù)變多,此過程會觸發(fā)通知。
buffer 中有數(shù)據(jù),此時用戶對操作的 fd 注冊 EPOLL_CTL_MOD 事件的時候,會觸發(fā)通知。
針對寫操作,也就是當前 fd 處于 EPOLLOUT 模式下,即可寫。此時意味著緩沖區(qū)可以寫了,以下 buffer 都指發(fā)送緩沖區(qū):
buffer 滿了,這時候發(fā)送出去一些數(shù)據(jù),數(shù)據(jù)變少,此過程會觸發(fā)通知。
buffer 原本有些數(shù)據(jù),這時候又發(fā)送出去一些數(shù)據(jù),數(shù)據(jù)變少,此過程會觸發(fā)通知。
這里就是 ET 這種模式觸發(fā)的幾種情形,可以看出,基本上都是圍繞著接收緩沖區(qū)或者發(fā)送緩沖區(qū)的狀態(tài)變化來進行的。
晦澀難懂?不存在的,舉個栗子:
在服務端,我們開啟邊緣觸發(fā)模式,然后將 buffer size 設(shè)為 10 個字節(jié),來看看具體的表現(xiàn)形式。
服務端開啟,客戶端連接,發(fā)送單字符 A 到服務端,輸出結(jié)果如下:
-->ET Mode: it was triggered once
get 1 bytes of content: A
-->wait to read!
可以看到,由于 buffer 從空到非空,邊緣觸發(fā)通知產(chǎn)生,之后在 epoll_wait 處阻塞,繼續(xù)等待后續(xù)事件。
這里我們變一下,輸入 ABCDEFGHIJKLMNOPQ,可以看到,客戶端發(fā)送的字符長度超過了服務端 buffer size,那么輸出結(jié)果將是怎么樣的呢?
-->ET Mode: it was triggered once
get 9 bytes of content: ABCDEFGHI
get 8 bytes of content: JKLMNOPQ
-->wait to read!
可以看到,這次發(fā)送,由于發(fā)送的長度大于 buffer size,所以內(nèi)容被折成兩段進行接收,由于用了邊緣觸發(fā)方式,buffer 的情況是從空到非空,所以只會產(chǎn)生一次通知。
水平觸發(fā)
水平觸發(fā)則簡單多了,他包含了邊緣觸發(fā)的所有場景,簡而言之如下:
當接收緩沖區(qū)不為空的時候,有數(shù)據(jù)可讀,則讀事件會一直觸發(fā)。
當發(fā)送緩沖區(qū)未滿的時候,可以繼續(xù)寫入數(shù)據(jù),則寫事件一直會觸發(fā)。
同樣的,為了使表達更清晰,我們也來舉個栗子,按照上述入輸入方式來進行。
服務端開啟,客戶端連接并發(fā)送單字符 A,可以看到服務端輸出情況如下:
-->LT Mode: it was triggered once!
get 1 bytes of content: A
這個輸出結(jié)果,毋庸置疑,由于 buffer 中有數(shù)據(jù),所以水平模式觸發(fā),輸出了結(jié)果。
服務端開啟,客戶端連接并發(fā)送 ABCDEFGHIJKLMNOPQ,可以看到服務端輸出情況如下:
-->LT Mode: it was triggered once!
get 9 bytes of content: ABCDEFGHI
-->LT Mode: it was triggered once!
get 8 bytes of content: JKLMNOPQ
從結(jié)果中,可以看出,由于 buffer 中數(shù)據(jù)讀取完畢后,還有未讀完的數(shù)據(jù),所以水平模式會一直觸發(fā),這也是為啥這里水平模式被觸發(fā)了兩次的原因。
有了這兩個栗子的比對,不知道聰明的你,get 到二者的區(qū)別了嗎?
在實際開發(fā)過程中,實際上 LT 更易用一些,畢竟系統(tǒng)幫助我們做了大部分校驗通知工作,之前提到的 SELECT 和 POLL,默認采用的也都是這個。但是需要注意的是,當有成千上萬個客戶端連接上來開始進行數(shù)據(jù)發(fā)送,由于 LT 的特性,內(nèi)核會頻繁的處理通知操作,導致其相對于 ET 來說,比較的耗費系統(tǒng)資源,所以,隨著客戶端的增多,其性能也就越差。
而邊緣觸發(fā),由于監(jiān)控的是 FD 的狀態(tài)變化,所以整體的系統(tǒng)通知并沒有那么頻繁,高并發(fā)下整體的性能表現(xiàn)也要好很多。但是由于此模式下,用戶需要積極的處理好每一筆數(shù)據(jù),帶來的維護代價也是相當大的,稍微不注意就有可能出錯。所以使用起來須要非常小心才行。
至于二者如何抉擇,諸位就仁者見仁智者見智吧。
行文到這里,關(guān)于 epoll 的講解基本上完畢了,大家從中是不是學到了很多干貨呢?由于從 netty 研究到 linux epoll 底層,其難度非常大,可以用曲高和寡來形容,所以在這塊探索的文章是比較少的,很多東西需要自己照著 man 文檔和源碼一點一點的琢磨(linux 源碼詳見 eventpoll.c 等)。這里我來糾正一下搜索引擎上,說 epoll 高性能是因為利用 mmap 技術(shù)實現(xiàn)了用戶態(tài)和內(nèi)核態(tài)的內(nèi)存共享,所以性能好,我前期被這個觀點誤導了好久,后來下來了 linux 源碼,翻了一下,并沒有在 epoll 中翻到 mmap 的技術(shù)點,所以這個觀點是錯誤的。這些錯誤觀點的文章,國內(nèi)不少,國外也不少,希望大家能審慎抉擇,避免被錯誤帶偏。
所以,epoll 高性能的根本就是,其高效的文件描述符處理方式加上頗具特性邊的緣觸發(fā)處理模式,以極少的內(nèi)核態(tài)和用戶態(tài)的切換,實現(xiàn)了真正意義上的高并發(fā)。
編輯:黃飛
?
評論