徹底理解 IO 多路複用實現機制

徹底理解 IO 多路複用實現機制

本文作者:何建輝(公眾號:org_yijiaoqian)

點贊再看,養成習慣,微信搜一搜【一角錢技術】關注更多原創技術文章。本文 GitHub org_hejianhui/JavaStudy已收錄,有我的系列文章。

前言

為了加深對 I/O多路複用機制 的理解,以及瞭解到多路複用也有侷限性,本著打破砂鍋問到底的精神,前面我們講了BIO、NIO、AIO的基本概念以及一些常見問題,同時也回顧了Unix網絡編程中的五種IO模型。本篇重點學習理解IO多路複用的底層實現機制。

概念說明

IO 多路複用有三種實現,在介紹select、poll、epoll之前,首先介紹一下Linux操作系統中基礎的概念

  • 用戶空間和內核空間
  • 進程切換
  • 進程的阻塞
  • 文件描述符
  • 緩存 I/O

用戶空間 / 內核空間

現在操作系統都是採用虛擬存儲器,那麼對32位操作系統而言,它的尋址空間(虛擬存儲空間)為4G(2的32次方)。 操作系統的核心是內核,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。為了保證用戶進程不能直接操作內核(kernel),保證內核的安全,操作系統將虛擬空間劃分為兩部分,一部分為內核空間,一部分為用戶空間。

針對linux操作系統而言,將最高的1G字節(從虛擬地址0xC0000000到0xFFFFFFFF),供內核使用,稱為內核空間,而將較低的3G字節(從虛擬地址0x00000000到0xBFFFFFFF),供各個進程使用,稱為用戶空間。

進程切換

為了控制進程的執行,內核必須有能力掛起正在CPU上運行的進程,並恢復以前掛起的某個進程的執行。這種行為被稱為進程切換。因此可以說,任何進程都是在操作系統內核的支持下運行的,是與內核緊密相關的,並且進程切換是非常耗費資源的。

從一個進程的運行轉到另一個進程上運行,這個過程中經過下面這些變化:

  1. 保存處理機上下文,包括程序計數器和其他寄存器。
  2. 更新PCB信息。
  3. 把進程的PCB移入相應的隊列,如就緒、在某事件阻塞等隊列。
  4. 選擇另一個進程執行,並更新其PCB。
  5. 更新內存管理的數據結構。
  6. 恢復處理機上下文。

進程阻塞

正在執行的進程,由於期待的某些事件未發生,如請求系統資源失敗、等待某種操作的完成、新數據尚未到達或無新工作做等,則由系統自動執行阻塞原語(Block),使自己由運行狀態變為阻塞狀態。可見,進程的阻塞是進程自身的一種主動行為,也因此只有處於運行態的進程(獲得了CPU資源),才可能將其轉為阻塞狀態。當進程進入阻塞狀態,是不佔用CPU資源的。

文件描述符

文件描述符(File descriptor)是計算機科學中的一個術語,是一個用於表述指向文件的引用的抽象化概念。 文件描述符在形式上是一個非負整數。實際上,它是一個索引值,指向內核為每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符。在程序設計中,一些涉及底層的程序編寫往往會圍繞著文件描述符展開。但是文件描述符這一概念往往只適用於UNIX、Linux這樣的操作系統。

緩存I/O

緩存I/O又稱為標準I/O,大多數文件系統的默認I/O操作都是緩存I/O。在Linux的緩存I/O機制中,操作系統會將I/O的數據緩存在文件系統的頁緩存中,即數據會先被拷貝到操作系統內核的緩衝區中,然後才會從操作系統內核的緩衝區拷貝到應用程序的地址空間。

緩存 I/O 的缺點:

數據在傳輸過程中需要在應用程序地址空間和內核進行多次數據拷貝操作,這些數據拷貝操作所帶來的 CPU 以及內存開銷是非常大的。

什麼是IO多路複用?

  • IO 多路複用是一種同步IO模型,實現一個線程可以監視多個文件句柄;
  • 一旦某個文件句柄就緒,就能夠通知應用程序進行相應的讀寫操作;
  • 沒有文件句柄就緒就會阻塞應用程序,交出CPU。

多路是指網絡連接,複用指的是同一個線程

為什麼有IO多路複用機制?

沒有IO多路複用機制時,有BIO、NIO兩種實現方式,但它們都有一些問題

同步阻塞(BIO)

  • 服務端採用單線程,當

    accept

    一個請求後,在

    recv

    send

    調用阻塞時,將無法

    accept

    其他請求(必須等上一個請求處理

    recv

    send

    完 )(無法處理併發)
<code> 

while

 (

true

) {       client_fd = accept(listen_fd);     fds.append(client_fd);     

for

 (fd in fds) {               

if

 (recv(fd)) {                   }     } }/<code>
  • 服務端採用多線程,當 accept 一個請求後,開啟線程進行 recv,可以完成併發處理,但隨著請求數增加需要增加系統線程,大量的線程佔用很大的內存空間,並且線程切換會帶來很大的開銷,10000個線程真正發生讀寫實際的線程數不會超過20%,每次accept都開一個線程也是一種資源浪費。
<code> 
while( 

true

) {      client_fd = accept(listen_fd)      

new

 Thread 

func

()

 {          

if

 (recv(fd)) {            }   }   } /<code>

同步非阻塞(NIO)

  • 服務器端當

    accept

    一個請求後,加入

    fds

    集合,每次輪詢一遍

    fds

    集合

    recv

    (非阻塞)數據,沒有數據則立即返回錯誤,每次輪詢所有 fd (包括沒有發生讀寫實際的 fd)會很浪費 CPU。
<code> 
while(

true

) {      client_fd = accept(listen_fd)   

if

 (client_fd != null) {          fds.

append

(client_fd)   } 

else

 {        }     

for

 (fd in fds) {          setNonblocking(client_fd)          

if

 (

len

 = recv(fd) && 

len

 > 

0

) {                   } 

else

 {        無讀寫數據     }   }   } /<code>

IO多路複用

服務器端採用單線程通過 select/poll/epoll 等系統調用獲取 fd 列表,遍歷有事件的 fd 進行 accept/recv/send ,使其能支持更多的併發連接請求。

<code> 
while(

true

) {         

for

 (fd in 

select

(fds)) {     

if

 (fd == listen_fd) {         client_fd = accept(listen_fd)         fds.

append

(client_fd)     } elseif (

len

 = recv(fd) && 

len

 != 

-1

) {             }   }   } /<code>

IO多路複用的三種實現

  • select
  • poll
  • epoll

select

它僅僅知道了,有I/O事件發生了,卻並不知道是哪那幾個流(可能有一個,多個,甚至全部),我們只能無差別輪詢所有流,找出能讀出數據,或者寫入數據的流,對他們進行操作。所以select具有O(n)的無差別輪詢複雜度,同時處理的流越多,無差別輪詢時間就越長。

select調用過程

徹底理解 IO 多路複用實現機制

(1)使用copy_from_user從用戶空間拷貝fd_set到內核空間

(2)註冊回調函數__pollwait

(3)遍歷所有fd,調用其對應的poll方法(對於socket,這個poll方法是sock_poll,sock_poll根據情況會調用到tcp_poll,udp_poll或者datagram_poll)

(4)以tcp_poll為例,其核心實現就是__pollwait,也就是上面註冊的回調函數。

(5)__pollwait的主要工作就是把current(當前進程)掛到設備的等待隊列中,不同的設備有不同的等待隊列,對於tcp_poll來說,其等待隊列是sk->sk_sleep(注意把進程掛到等待隊列中並不代表進程已經睡眠了)。在設備收到一條消息(網絡設備)或填寫完文件數據(磁盤設備)後,會喚醒設備等待隊列上睡眠的進程,這時current便被喚醒了。

(6)poll方法返回時會返回一個描述讀寫操作是否就緒的mask掩碼,根據這個mask掩碼給fd_set賦值。

(7)如果遍歷完所有的fd,還沒有返回一個可讀寫的mask掩碼,則會調用schedule_timeout是調用select的進程(也就是current)進入睡眠。當設備驅動發生自身資源可讀寫後,會喚醒其等待隊列上睡眠的進程。如果超過一定的超時時間(schedule_timeout指定),還是沒人喚醒,則調用select的進程會重新被喚醒獲得CPU,進而重新遍歷fd,判斷有沒有就緒的fd。

(8)把fd_set從內核空間拷貝到用戶空間。

select函數接口

<code> 
 

 
 
 

 

typedef

 

struct

 {

    

unsigned

 

long

 fds_bits[__FDSET_LONGS]; } fd_set;

int

 

select

(     

int

 max_fd,      fd_set *readset,      fd_set *writeset,      fd_set *exceptset,      struct timeval *timeout )

                              

FD_ZERO

(

int

 fd, fd_set* fds)

   

FD_SET

(

int

 fd, fd_set* fds)

    

FD_ISSET

(

int

 fd, fd_set* fds)

  

FD_CLR

(

int

 fd, fd_set* fds)

    

/<code>

select使用示例

<code>

int

 

main

()

 

{      fd_set read_fs, write_fs;   

struct

 

timeval

 

timeout

;

  

int

 max = 

0

;        FD_ZERO(&read_fs);   FD_ZERO(&write_fs);   

int

 nfds = 

0

;    

while

 (

1

) {               nfds = select(max + 

1

, &read_fd, &write_fd, 

NULL

, &timeout);          

for

 (

int

 i = 

0

; i <= max && nfds; ++i) {       

if

 (i == listenfd) {          --nfds;                    FD_SET(i, &read_fd);       }       

if

 (FD_ISSET(i, &read_fd)) {         --nfds;                }       

if

 (FD_ISSET(i, &write_fd)) {          --nfds;                }     }   } /<code>

select缺點

select本質上是通過設置或者檢查存放fd標誌位的數據結構來進行下一步處理。這樣所帶來的缺點是:

  • 單個進程所打開的FD是有限制的,通過

    FD_SETSIZE

    設置,默認1024 ;
  • 每次調用 select,都需要把 fd 集合從用戶態拷貝到內核態,這個開銷在 fd 很多時會很大;

需要維護一個用來存放大量fd的數據結構,這樣會使得用戶空間和內核空間在傳遞該結構時複製開銷大

  • 對 socket 掃描時是線性掃描,採用輪詢的方法,效率較低(高併發)

當套接字比較多的時候,每次select()都要通過遍歷FD_SETSIZE個Socket來完成調度,不管哪個Socket是活躍的,都遍歷一遍。這會浪費很多CPU時間。如果能給套接字註冊某個回調函數,當他們活躍時,自動完成相關操作,那就避免了輪詢,這正是epoll與kqueue做的。

poll

poll本質上和select沒有區別,它將用戶傳入的數組拷貝到內核空間,然後查詢每個fd對應的設備狀態, 但是它沒有最大連接數的限制,原因是它是基於鏈表來存儲的.

poll函數接口

<code> 
 

struct

 

pollfd

 {

    

int

 fd;                              short events;                        short revents;                   };

int

 

poll

(struct pollfd fds[], 

nfds_t

 nfds, 

int

 timeout)

; /<code>

poll使用示例

<code> 
 

int

 

main

()

 

{      

int

 nfds = 

0

;   pollfd fds[MAX_POLLFD_LEN];   

memset

(fds, 

0

sizeof

(fds));   fds[

0

].fd = listenfd;   fds[

0

].events = POLLRDNORM;   

int

 max  = 

0

;     

int

 timeout = 

0

;   

int

 current_size = max;   

while

 (

1

) {               nfds = poll(fds, max+

1

, timeout);     

if

 (fds[

0

].revents & POLLRDNORM) {                  connfd = accept(listenfd);              }          

for

 (

int

 i = 

1

; i if (fds[i].revents & POLLRDNORM) {           sockfd = fds[i].fd          

if

 ((n = read(sockfd, buf, MAXLINE)) <= 

0

) {                          

if

 (n == 

0

) {                 close(sockfd);                 fds[i].fd = 

-1

;             }          } 

else

 {                        }          

if

 (--nfds <= 

0

) {             

break

;                 }          }     }   } /<code>

poll缺點

它沒有最大連接數的限制,原因是它是基於鏈表來存儲的,但是同樣有缺點:

  • 每次調用 poll ,都需要把 fd 集合從用戶態拷貝到內核態,這個開銷在 fd 很多時會很大;
  • 對 socket 掃描是線性掃描,採用輪詢的方法,效率較低(高併發時)

epoll

epoll可以理解為event poll

,不同於忙輪詢和無差別輪詢,epoll會把哪個流發生了怎樣的I/O事件通知我們。所以我們說epoll實際上是**事件驅動(每個事件關聯上fd)**的,此時我們對這些流的操作都是有意義的。(複雜度降低到了O(1))

epoll函數接口

當某一進程調用epoll_create方法時,Linux內核會創建一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。eventpoll結構體如下所示:

<code> 

 
 
 
 

struct

 

eventpoll

 {

         

struct

 

rb_root

  

rbr

;

         

struct

 

list_head

 

rdlist

;

};

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)

; /<code>

每一個epoll對象都有一個獨立的eventpoll結構體,用於存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,如此,重複添加的事件就可以通過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是lgn,其中n為紅黑樹元素個數)。

而所有添加到epoll中的事件都會與設備(網卡)驅動程序建立回調關係,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它會將發生的事件添加到rdlist雙鏈表中。

在epoll中,對於每一個事件,都會建立一個epitem結構體,如下所示:

<code>

struct

 

epitem

{

    

struct

 

rb_node

  

rbn

;

    

struct

 

list_head

    

rdllink

;

    

struct

 

epoll_filefd

  

ffd

;

       

struct

 

eventpoll

 *

ep

;

         

struct

 

epoll_event

 

event

;

  } /<code>

當調用epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不為空,則把發生的事件複製到用戶態,同時將事件數量返回給用戶。

徹底理解 IO 多路複用實現機制

從上面的講解可知:通過紅黑樹和雙鏈表數據結構,並結合回調機制,造就了epoll的高效。 講解完了Epoll的機理,我們便能很容易掌握epoll的用法了。一句話描述就是:三步曲。

  • 第一步:epoll_create()系統調用。此調用返回一個句柄,之後所有的使用都依靠這個句柄來標識。
  • 第二步:epoll_ctl()系統調用。通過此調用向epoll對象中添加、刪除、修改感興趣的事件,返回0標識成功,返回-1表示失敗。
  • 第三部:epoll_wait()系統調用。通過此調用收集收集在epoll監控中已經發生的事件。

epoll使用示例

<code>

int

 

main

(

int

 argc, 

char

* argv[])

{              epfd=epoll_create(

256

);          epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);       

while

(

1

) {              nfds = epoll_wait(epfd,events,

20

,

0

);       

for

(i=

0

;iif(events[i].data.fd==listenfd) {                              connfd = accept(listenfd);                              epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);           } 

else

 

if

 (events[i].events&EPOLLIN) {                              read(sockfd, BUF, MAXLINE);                              epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);           } 

else

 

if

(events[i].events&EPOLLOUT) {                              write(sockfd, BUF, n);                              epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);           }       }     }     

return

 

0

; } /<code>

epoll的優點

  • 沒有最大併發連接的限制,能打開的FD的上限遠大於1024(1G的內存上能監聽約10萬個端口);
  • 效率提升,不是輪詢的方式,不會隨著FD數目的增加效率下降。只有活躍可用的FD才會調用callback函數;即Epoll最大的優點就在於它只管你“活躍”的連接,而跟連接總數無關,因此在實際的網絡環境中,Epoll的效率就會遠遠高於select和poll;
  • 內存拷貝,利用mmap()文件映射內存加速與內核空間的消息傳遞;即epoll使用mmap減少複製開銷。

epoll缺點

  • epoll只能工作在 linux 下

epoll LT 與 ET 模式的區別

epoll 有 EPOLLLT 和 EPOLLET 兩種觸發模式,LT 是默認的模式,ET 是 “高速” 模式。

  • LT 模式下,只要這個 fd 還有數據可讀,每次 epoll_wait 都會返回它的事件,提醒用戶程序去操作;
  • ET 模式下,它只會提示一次,直到下次再有數據流入之前都不會再提示了,無論 fd 中是否還有數據可讀。所以在 ET 模式下,read 一個 fd 的時候一定要把它的 buffer 讀完,或者遇到 EAGIN 錯誤。

epoll使用“事件”的就緒通知方式,通過epoll_ctl註冊fd,一旦該fd就緒,內核就會採用類似callback的回調機制來激活該fd,epoll_wait便可以收到通知。

select/poll/epoll之間的區別

select,poll,epoll都是IO多路複用的機制。I/O多路複用就通過一種機制,可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作。但select,poll,epoll本質上都是同步I/O,因為他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的

,而異步I/O則無需自己負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間。

epoll跟select都能提供多路I/O複用的解決方案。在現在的Linux內核裡有都能夠支持,其中epoll是Linux所特有,而select則應該是POSIX所規定,一般操作系統均有實現


徹底理解 IO 多路複用實現機制


epoll是Linux目前大規模網絡併發程序開發的首選模型。在絕大多數情況下性能遠超select和poll。目前流行的高性能web服務器Nginx正式依賴於epoll提供的高效網絡套接字輪詢服務。但是,在併發連接不高的情況下,多線程+阻塞I/O方式可能性能更好。

支持一個進程所能打開的最大連接數

  • select:單個進程所能打開的最大連接數有FD_SETSIZE宏定義,其大小是32個整數的大小(在32位的機器上,大小就是32_32,同理64位機器上FD_SETSIZE為32_64),當然我們可以對進行修改,然後重新編譯內核,但是性能可能會受到影響,這需要進一步的測試。
  • poll:poll本質上和select沒有區別,但是它沒有最大連接數的限制,原因是它是基於鏈表來存儲的。
  • epoll:雖然連接數有上限,但是很大,1G內存的機器上可以打開10萬左右的連接,2G內存的機器可以打開20萬左右的連接。

FD劇增後帶來的IO效率問題

  • select:因為每次調用時都會對連接進行線性遍歷,所以隨著FD的增加會造成遍歷速度慢的“線性下降性能問題”。
  • poll:同上
  • epoll:因為epoll內核中實現是根據每個fd上的callback函數來實現的,只有活躍的socket才會主動調用callback,所以在活躍socket較少的情況下,使用epoll沒有前面兩者的線性下降的性能問題,但是所有socket都很活躍的情況下,可能會有性能問題。

消息傳遞方式

  • select:內核需要將消息傳遞到用戶空間,都需要內核拷貝動作
  • poll:同上
  • epoll:epoll通過內核和用戶空間共享一塊內存來實現的。

總結

select,poll實現需要自己不斷輪詢所有fd集合,直到設備就緒,期間可能要睡眠和喚醒多次交替。而epoll其實也需要調用epoll_wait不斷輪詢就緒鏈表,期間也可能多次睡眠和喚醒交替,但是它是設備就緒時,調用回調函數,把就緒fd放入就緒鏈表中,並喚醒在epoll_wait中進入睡眠的進程。雖然都要睡眠和交替,但是select和poll在“醒著”的時候要遍歷整個fd集合,而epoll在“醒著”的時候只要判斷一下就緒鏈表是否為空就行了,這節省了大量的CPU時間。這就是回調機制帶來的性能提升。

select,poll每次調用都要把fd集合從用戶態往內核態拷貝一次,並且要把current往設備等待隊列中掛一次,而epoll只要一次拷貝,而且把current往等待隊列上掛也只掛一次(在epoll_wait的開始,注意這裡的等待隊列並不是設備等待隊列,只是一個epoll內部定義的等待隊列)。這也能節省不少的開銷。

高頻面試題

什麼是IO多路複用?

看完上面的文章,相信你可以回答出來了。

nginx/redis 所使用的IO模型是什麼?

Nginx的IO模型

Nginx 支持多種併發模型,併發模型的具體實現根據系統平臺而有所不同。

在支持多種併發模型的平臺上,nginx 自動選擇最高效的模型。但我們也可以使用 use 指令在配置文件中顯式地定義某個併發模型。

NGINX中支持的併發模型:

1、select

IO多路複用、標準併發模型。在編譯 nginx 時,如果所使用的系統平臺沒有更高效的併發模型,select 模塊將被自動編譯。configure 腳本的選項:–with-select_module 和 --without-select_module 可被用來強制性地開啟或禁止 select 模塊的編譯

2、poll

IO多路複用、標準併發模型。與 select 類似,在編譯 nginx 時,如果所使用的系統平臺沒有更高效的併發模型,poll 模塊將被自動編譯。configure 腳本的選項:–with-poll_module 和 --without-poll_module 可用於強制性地開啟或禁止 poll 模塊的編譯

3、epoll

IO多路複用、高效併發模型,可在 Linux 2.6+ 及以上內核可以使用

4、kqueue

IO多路複用、高效併發模型,可在 FreeBSD 4.1+, OpenBSD 2.9+, NetBSD 2.0, and Mac OS X 平臺中使用

5、/dev/poll

高效併發模型,可在 Solaris 7 11/99+, HP/UX 11.22+ (eventport), IRIX 6.5.15+, and Tru64 UNIX 5.1A+ 平臺使用

6、eventport

高效併發模型,可用於 Solaris 10 平臺,PS:由於一些已知的問題,建議 使用/dev/poll替代。

Redis IO多路複用技術

redis 是一個單線程卻性能非常好的內存數據庫, 主要用來作為緩存系統。 redis 採用網絡IO多路複用技術來保證在多連接的時候, 系統的高吞吐量。

為什麼 Redis 中要使用 I/O 多路複用這種技術呢?

首先,Redis 是跑在單線程中的,所有的操作都是按照順序線性執行的,但是由於讀寫操作等待用戶輸入或輸出都是阻塞的,所以 I/O 操作在一般情況下往往不能直接返回,這會導致某一文件的 I/O 阻塞導致整個進程無法對其它客戶提供服務,而 I/O 多路複用 就是為了解決這個問題而出現的。

redis的io模型主要是基於epoll實現的,不過它也提供了 select和kqueue的實現,默認採用epoll。

select、poll、epoll之間的區別

看完上面的文章,相信你可以回答出來了。

epoll 水平觸發(LT)與 邊緣觸發(ET)的區別?

EPOLL事件有兩種模型:

  • Edge Triggered (ET) 邊緣觸發只有數據到來,才觸發,不管緩存區中是否還有數據。
  • Level Triggered (LT) 水平觸發只要有數據都會觸發。

看完上面的文章,相信你可以回答出來了。

文章持續更新,可以微信搜一搜「 一角錢技術 」第一時間閱讀, 本文 GitHub org_hejianhui/JavaStudy已經收錄,歡迎 Star。


分享到:


相關文章: