徹底理解 IO 多路復(fù)用實(shí)現(xiàn)機(jī)制
前言
- BIO 、NIO 、AIO 總結(jié)
- Unix網(wǎng)絡(luò)編程中的五種IO模型
為了加深對(duì) I/O多路復(fù)用機(jī)制 的理解,以及了解到多路復(fù)用也有局限性,本著打破砂鍋問到底的精神,前面我們講了BIO、NIO、AIO的基本概念以及一些常見問題,同時(shí)也回顧了Unix網(wǎng)絡(luò)編程中的五種IO模型。本篇重點(diǎn)學(xué)習(xí)理解IO多路復(fù)用的底層實(shí)現(xiàn)機(jī)制。
概念說明
IO 多路復(fù)用有三種實(shí)現(xiàn),在介紹select、poll、epoll之前,首先介紹一下Linux操作系統(tǒng)中基礎(chǔ)的概念:
- 用戶空間和內(nèi)核空間
- 進(jìn)程切換
- 進(jìn)程的阻塞
- 文件描述符
- 緩存 I/O
用戶空間 / 內(nèi)核空間
現(xiàn)在操作系統(tǒng)都是采用虛擬存儲(chǔ)器,那么對(duì)32位操作系統(tǒng)而言,它的尋址空間(虛擬存儲(chǔ)空間)為4G(2的32次方)。 操作系統(tǒng)的核心是內(nèi)核,獨(dú)立于普通的應(yīng)用程序,可以訪問受保護(hù)的內(nèi)存空間,也有訪問底層硬件設(shè)備的所有權(quán)限。為了保證用戶進(jìn)程不能直接操作內(nèi)核(kernel),保證內(nèi)核的安全,操作系統(tǒng)將虛擬空間劃分為兩部分,一部分為內(nèi)核空間,一部分為用戶空間。
針對(duì)linux操作系統(tǒng)而言,將最高的1G字節(jié)(從虛擬地址0xC0000000到0xFFFFFFFF),供內(nèi)核使用,稱為內(nèi)核空間,而將較低的3G字節(jié)(從虛擬地址0x00000000到0xBFFFFFFF),供各個(gè)進(jìn)程使用,稱為用戶空間。
進(jìn)程切換
為了控制進(jìn)程的執(zhí)行,內(nèi)核必須有能力掛起正在CPU上運(yùn)行的進(jìn)程,并恢復(fù)以前掛起的某個(gè)進(jìn)程的執(zhí)行。這種行為被稱為進(jìn)程切換。因此可以說,任何進(jìn)程都是在操作系統(tǒng)內(nèi)核的支持下運(yùn)行的,是與內(nèi)核緊密相關(guān)的,并且進(jìn)程切換是非常耗費(fèi)資源的。
從一個(gè)進(jìn)程的運(yùn)行轉(zhuǎn)到另一個(gè)進(jìn)程上運(yùn)行,這個(gè)過程中經(jīng)過下面這些變化:
- 保存處理機(jī)上下文,包括程序計(jì)數(shù)器和其他寄存器。
- 更新PCB信息。
- 把進(jìn)程的PCB移入相應(yīng)的隊(duì)列,如就緒、在某事件阻塞等隊(duì)列。
- 選擇另一個(gè)進(jìn)程執(zhí)行,并更新其PCB。
- 更新內(nèi)存管理的數(shù)據(jù)結(jié)構(gòu)。
- 恢復(fù)處理機(jī)上下文。
進(jìn)程阻塞
正在執(zhí)行的進(jìn)程,由于期待的某些事件未發(fā)生,如請(qǐng)求系統(tǒng)資源失敗、等待某種操作的完成、新數(shù)據(jù)尚未到達(dá)或無新工作做等,則由系統(tǒng)自動(dòng)執(zhí)行阻塞原語(Block),使自己由運(yùn)行狀態(tài)變?yōu)樽枞麪顟B(tài)??梢?,進(jìn)程的阻塞是進(jìn)程自身的一種主動(dòng)行為,也因此只有處于運(yùn)行態(tài)的進(jìn)程(獲得了CPU資源),才可能將其轉(zhuǎn)為阻塞狀態(tài)。當(dāng)進(jìn)程進(jìn)入阻塞狀態(tài),是不占用CPU資源的。
文件描述符
文件描述符(File descriptor)是計(jì)算機(jī)科學(xué)中的一個(gè)術(shù)語,是一個(gè)用于表述指向文件的引用的抽象化概念。 文件描述符在形式上是一個(gè)非負(fù)整數(shù)。實(shí)際上,它是一個(gè)索引值,指向內(nèi)核為每一個(gè)進(jìn)程所維護(hù)的該進(jìn)程打開文件的記錄表。當(dāng)程序打開一個(gè)現(xiàn)有文件或者創(chuàng)建一個(gè)新文件時(shí),內(nèi)核向進(jìn)程返回一個(gè)文件描述符。在程序設(shè)計(jì)中,一些涉及底層的程序編寫往往會(huì)圍繞著文件描述符展開。但是文件描述符這一概念往往只適用于UNIX、Linux這樣的操作系統(tǒng)。
緩存I/O
緩存I/O又稱為標(biāo)準(zhǔn)I/O,大多數(shù)文件系統(tǒng)的默認(rèn)I/O操作都是緩存I/O。在Linux的緩存I/O機(jī)制中,操作系統(tǒng)會(huì)將I/O的數(shù)據(jù)緩存在文件系統(tǒng)的頁緩存中,即數(shù)據(jù)會(huì)先被拷貝到操作系統(tǒng)內(nèi)核的緩沖區(qū)中,然后才會(huì)從操作系統(tǒng)內(nèi)核的緩沖區(qū)拷貝到應(yīng)用程序的地址空間。
緩存 I/O 的缺點(diǎn):
數(shù)據(jù)在傳輸過程中需要在應(yīng)用程序地址空間和內(nèi)核進(jìn)行多次數(shù)據(jù)拷貝操作,這些數(shù)據(jù)拷貝操作所帶來的 CPU 以及內(nèi)存開銷是非常大的。
什么是IO多路復(fù)用?
- IO 多路復(fù)用是一種同步IO模型,實(shí)現(xiàn)一個(gè)線程可以監(jiān)視多個(gè)文件句柄;
- 一旦某個(gè)文件句柄就緒,就能夠通知應(yīng)用程序進(jìn)行相應(yīng)的讀寫操作;
- 沒有文件句柄就緒就會(huì)阻塞應(yīng)用程序,交出CPU。
多路是指網(wǎng)絡(luò)連接,復(fù)用指的是同一個(gè)線程
為什么有IO多路復(fù)用機(jī)制?
沒有IO多路復(fù)用機(jī)制時(shí),有BIO、NIO兩種實(shí)現(xiàn)方式,但它們都有一些問題
同步阻塞(BIO)
服務(wù)端采用單線程,當(dāng) accept 一個(gè)請(qǐng)求后,在 recv 或 send 調(diào)用阻塞時(shí),將無法 accept 其他請(qǐng)求(必須等上一個(gè)請(qǐng)求處理 recv 或 send 完 )(無法處理并發(fā))
- // 偽代碼描述
- while (true) {
- // accept阻塞
- client_fd = accept(listen_fd);
- fds.append(client_fd);
- for (fd in fds) {
- // recv阻塞(會(huì)影響上面的accept)
- if (recv(fd)) {
- // logic
- }
- }
- }
- 服務(wù)端采用多線程,當(dāng) accept 一個(gè)請(qǐng)求后,開啟線程進(jìn)行 recv,可以完成并發(fā)處理,但隨著請(qǐng)求數(shù)增加需要增加系統(tǒng)線程,大量的線程占用很大的內(nèi)存空間,并且線程切換會(huì)帶來很大的開銷,10000個(gè)線程真正發(fā)生讀寫實(shí)際的線程數(shù)不會(huì)超過20%,每次accept都開一個(gè)線程也是一種資源浪費(fèi)。
- // 偽代碼描述
- while(true) {
- // accept阻塞
- client_fd = accept(listen_fd)
- // 開啟線程read數(shù)據(jù)(fd增多導(dǎo)致線程數(shù)增多)
- new Thread func() {
- // recv阻塞(多線程不影響上面的accept)
- if (recv(fd)) {
- // logic
- }
- }
- }
同步非阻塞(NIO)
- 服務(wù)器端當(dāng) accept 一個(gè)請(qǐng)求后,加入 fds 集合,每次輪詢一遍 fds 集合 recv (非阻塞)數(shù)據(jù),沒有數(shù)據(jù)則立即返回錯(cuò)誤,每次輪詢所有 fd (包括沒有發(fā)生讀寫實(shí)際的 fd)會(huì)很浪費(fèi) CPU。
- // 偽代碼描述
- while(true) {
- // accept非阻塞(cpu一直忙輪詢)
- client_fd = accept(listen_fd)
- if (client_fd != null) {
- // 有人連接
- fds.append(client_fd)
- } else {
- // 無人連接
- }
- for (fd in fds) {
- // recv非阻塞
- setNonblocking(client_fd)
- // recv 為非阻塞命令
- if (len = recv(fd) && len > 0) {
- // 有讀寫數(shù)據(jù)
- // logic
- } else {
- 無讀寫數(shù)據(jù)
- }
- }
- }
IO多路復(fù)用
服務(wù)器端采用單線程通過 select/poll/epoll 等系統(tǒng)調(diào)用獲取 fd 列表,遍歷有事件的 fd 進(jìn)行 accept/recv/send ,使其能支持更多的并發(fā)連接請(qǐng)求。
- // 偽代碼描述
- while(true) {
- // 通過內(nèi)核獲取有讀寫事件發(fā)生的fd,只要有一個(gè)則返回,無則阻塞
- // 整個(gè)過程只在調(diào)用select、poll、epoll這些調(diào)用的時(shí)候才會(huì)阻塞,accept/recv是不會(huì)阻塞
- for (fd in select(fds)) {
- if (fd == listen_fd) {
- client_fd = accept(listen_fd)
- fds.append(client_fd)
- } elseif (len = recv(fd) && len != -1) {
- // logic
- }
- }
- }
IO多路復(fù)用的三種實(shí)現(xiàn)
- select
- poll
- epoll
select
它僅僅知道了,有I/O事件發(fā)生了,卻并不知道是哪那幾個(gè)流(可能有一個(gè),多個(gè),甚至全部),我們只能無差別輪詢所有流,找出能讀出數(shù)據(jù),或者寫入數(shù)據(jù)的流,對(duì)他們進(jìn)行操作。所以select具有O(n)的無差別輪詢復(fù)雜度,同時(shí)處理的流越多,無差別輪詢時(shí)間就越長(zhǎng)。
select調(diào)用過程
(1)使用copy_from_user從用戶空間拷貝fd_set到內(nèi)核空間
(2)注冊(cè)回調(diào)函數(shù)__pollwait
(3)遍歷所有fd,調(diào)用其對(duì)應(yīng)的poll方法(對(duì)于socket,這個(gè)poll方法是sock_poll,sock_poll根據(jù)情況會(huì)調(diào)用到tcp_poll,udp_poll或者datagram_poll)
(4)以tcp_poll為例,其核心實(shí)現(xiàn)就是__pollwait,也就是上面注冊(cè)的回調(diào)函數(shù)。
(5)__pollwait的主要工作就是把current(當(dāng)前進(jìn)程)掛到設(shè)備的等待隊(duì)列中,不同的設(shè)備有不同的等待隊(duì)列,對(duì)于tcp_poll來說,其等待隊(duì)列是sk->sk_sleep(注意把進(jìn)程掛到等待隊(duì)列中并不代表進(jìn)程已經(jīng)睡眠了)。在設(shè)備收到一條消息(網(wǎng)絡(luò)設(shè)備)或填寫完文件數(shù)據(jù)(磁盤設(shè)備)后,會(huì)喚醒設(shè)備等待隊(duì)列上睡眠的進(jìn)程,這時(shí)current便被喚醒了。
(6)poll方法返回時(shí)會(huì)返回一個(gè)描述讀寫操作是否就緒的mask掩碼,根據(jù)這個(gè)mask掩碼給fd_set賦值。
(7)如果遍歷完所有的fd,還沒有返回一個(gè)可讀寫的mask掩碼,則會(huì)調(diào)用schedule_timeout是調(diào)用select的進(jìn)程(也就是current)進(jìn)入睡眠。當(dāng)設(shè)備驅(qū)動(dòng)發(fā)生自身資源可讀寫后,會(huì)喚醒其等待隊(duì)列上睡眠的進(jìn)程。如果超過一定的超時(shí)時(shí)間(schedule_timeout指定),還是沒人喚醒,則調(diào)用select的進(jìn)程會(huì)重新被喚醒獲得CPU,進(jìn)而重新遍歷fd,判斷有沒有就緒的fd。
(8)把fd_set從內(nèi)核空間拷貝到用戶空間。
select函數(shù)接口
- #include <sys/select.h>
- #include <sys/time.h>
- #define FD_SETSIZE 1024
- #define NFDBITS (8 * sizeof(unsigned long))
- #define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
- // 數(shù)據(jù)結(jié)構(gòu) (bitmap)
- typedef struct {
- unsigned long fds_bits[__FDSET_LONGS];
- } fd_set;
- // API
- int select(
- int max_fd,
- fd_set *readset,
- fd_set *writeset,
- fd_set *exceptset,
- struct timeval *timeout
- ) // 返回值就緒描述符的數(shù)目
- 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) // 將給定的描述符從文件中刪除
select使用示例
- int main() {
- /*
- * 這里進(jìn)行一些初始化的設(shè)置,
- * 包括socket建立,地址的設(shè)置等,
- */
- fd_set read_fs, write_fs;
- struct timeval timeout;
- int max = 0; // 用于記錄最大的fd,在輪詢中時(shí)刻更新即可
- // 初始化比特位
- FD_ZERO(&read_fs);
- FD_ZERO(&write_fs);
- int nfds = 0; // 記錄就緒的事件,可以減少遍歷的次數(shù)
- while (1) {
- // 阻塞獲取
- // 每次需要把fd從用戶態(tài)拷貝到內(nèi)核態(tài)
- nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout);
- // 每次需要遍歷所有fd,判斷有無讀寫事件發(fā)生
- for (int i = 0; i <= max && nfds; ++i) {
- if (i == listenfd) {
- --nfds;
- // 這里處理accept事件
- FD_SET(i, &read_fd);//將客戶端socket加入到集合中
- }
- if (FD_ISSET(i, &read_fd)) {
- --nfds;
- // 這里處理read事件
- }
- if (FD_ISSET(i, &write_fd)) {
- --nfds;
- // 這里處理write事件
- }
- }
- }
select缺點(diǎn)
select本質(zhì)上是通過設(shè)置或者檢查存放fd標(biāo)志位的數(shù)據(jù)結(jié)構(gòu)來進(jìn)行下一步處理。這樣所帶來的缺點(diǎn)是:
- 單個(gè)進(jìn)程所打開的FD是有限制的,通過 FD_SETSIZE 設(shè)置,默認(rèn)1024 ;
- 每次調(diào)用 select,都需要把 fd 集合從用戶態(tài)拷貝到內(nèi)核態(tài),這個(gè)開銷在 fd 很多時(shí)會(huì)很大;
需要維護(hù)一個(gè)用來存放大量fd的數(shù)據(jù)結(jié)構(gòu),這樣會(huì)使得用戶空間和內(nèi)核空間在傳遞該結(jié)構(gòu)時(shí)復(fù)制開銷大
- 對(duì) socket 掃描時(shí)是線性掃描,采用輪詢的方法,效率較低(高并發(fā))
當(dāng)套接字比較多的時(shí)候,每次select()都要通過遍歷FD_SETSIZE個(gè)Socket來完成調(diào)度,不管哪個(gè)Socket是活躍的,都遍歷一遍。這會(huì)浪費(fèi)很多CPU時(shí)間。如果能給套接字注冊(cè)某個(gè)回調(diào)函數(shù),當(dāng) 他們活躍時(shí),自動(dòng)完成相關(guān)操作,那就避免了輪詢,這正是epoll與kqueue做的。
poll
poll本質(zhì)上和select沒有區(qū)別,它將用戶傳入的數(shù)組拷貝到內(nèi)核空間,然后查詢每個(gè)fd對(duì)應(yīng)的設(shè)備狀態(tài), 但是它沒有最大連接數(shù)的限制,原因是它是基于鏈表來存儲(chǔ)的.
poll函數(shù)接口
- #include <poll.h>
- // 數(shù)據(jù)結(jié)構(gòu)
- struct pollfd {
- int fd; // 需要監(jiān)視的文件描述符
- short events; // 需要內(nèi)核監(jiān)視的事件
- short revents; // 實(shí)際發(fā)生的事件
- };
- // API
- int poll(struct pollfd fds[], nfds_t nfds, int timeout);
poll使用示例
- // 先宏定義長(zhǎng)度
- #define MAX_POLLFD_LEN 4096
- int main() {
- /*
- * 在這里進(jìn)行一些初始化的操作,
- * 比如初始化數(shù)據(jù)和socket等。
- */
- int nfds = 0;
- pollfd fds[MAX_POLLFD_LEN];
- memset(fds, 0, sizeof(fds));
- fds[0].fd = listenfd;
- fds[0].events = POLLRDNORM;
- int max = 0; // 隊(duì)列的實(shí)際長(zhǎng)度,是一個(gè)隨時(shí)更新的,也可以自定義其他的
- int timeout = 0;
- int current_size = max;
- while (1) {
- // 阻塞獲取
- // 每次需要把fd從用戶態(tài)拷貝到內(nèi)核態(tài)
- nfds = poll(fds, max+1, timeout);
- if (fds[0].revents & POLLRDNORM) {
- // 這里處理accept事件
- connfd = accept(listenfd);
- //將新的描述符添加到讀描述符集合中
- }
- // 每次需要遍歷所有fd,判斷有無讀寫事件發(fā)生
- for (int i = 1; i < max; ++i) {
- if (fds[i].revents & POLLRDNORM) {
- sockfd = fds[i].fd
- if ((n = read(sockfd, buf, MAXLINE)) <= 0) {
- // 這里處理read事件
- if (n == 0) {
- close(sockfd);
- fds[i].fd = -1;
- }
- } else {
- // 這里處理write事件
- }
- if (--nfds <= 0) {
- break;
- }
- }
- }
- }
poll缺點(diǎn)
它沒有最大連接數(shù)的限制,原因是它是基于鏈表來存儲(chǔ)的,但是同樣有缺點(diǎn):
- 每次調(diào)用 poll ,都需要把 fd 集合從用戶態(tài)拷貝到內(nèi)核態(tài),這個(gè)開銷在 fd 很多時(shí)會(huì)很大;
- 對(duì) socket 掃描是線性掃描,采用輪詢的方法,效率較低(高并發(fā)時(shí))
epoll
epoll可以理解為event poll,不同于忙輪詢和無差別輪詢,epoll會(huì)把哪個(gè)流發(fā)生了怎樣的I/O事件通知我們。所以我們說epoll實(shí)際上是**事件驅(qū)動(dòng)(每個(gè)事件關(guān)聯(lián)上fd)**的,此時(shí)我們對(duì)這些流的操作都是有意義的。(復(fù)雜度降低到了O(1))
epoll函數(shù)接口
當(dāng)某一進(jìn)程調(diào)用epoll_create方法時(shí),Linux內(nèi)核會(huì)創(chuàng)建一個(gè)eventpoll結(jié)構(gòu)體,這個(gè)結(jié)構(gòu)體中有兩個(gè)成員與epoll的使用方式密切相關(guān)。eventpoll結(jié)構(gòu)體如下所示:
- #include <sys/epoll.h>
- // 數(shù)據(jù)結(jié)構(gòu)
- // 每一個(gè)epoll對(duì)象都有一個(gè)獨(dú)立的eventpoll結(jié)構(gòu)體
- // 用于存放通過epoll_ctl方法向epoll對(duì)象中添加進(jìn)來的事件
- // epoll_wait檢查是否有事件發(fā)生時(shí),只需要檢查eventpoll對(duì)象中的rdlist雙鏈表中是否有epitem元素即可
- struct eventpoll {
- /*紅黑樹的根節(jié)點(diǎn),這顆樹中存儲(chǔ)著所有添加到epoll中的需要監(jiān)控的事件*/
- struct rb_root rbr;
- /*雙鏈表中則存放著將要通過epoll_wait返回給用戶的滿足條件的事件*/
- struct list_head rdlist;
- };
- // API
- int epoll_create(int size); // 內(nèi)核中間加一個(gè) ep 對(duì)象,把所有需要監(jiān)聽的 socket 都放到 ep 對(duì)象中
- int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 負(fù)責(zé)把 socket 增加、刪除到內(nèi)核紅黑樹
- int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 負(fù)責(zé)檢測(cè)可讀隊(duì)列,沒有可讀 socket 則阻塞進(jìn)程
每一個(gè)epoll對(duì)象都有一個(gè)獨(dú)立的eventpoll結(jié)構(gòu)體,用于存放通過epoll_ctl方法向epoll對(duì)象中添加進(jìn)來的事件。這些事件都會(huì)掛載在紅黑樹中,如此,重復(fù)添加的事件就可以通過紅黑樹而高效的識(shí)別出來(紅黑樹的插入時(shí)間效率是lgn,其中n為紅黑樹元素個(gè)數(shù))。
而所有添加到epoll中的事件都會(huì)與設(shè)備(網(wǎng)卡)驅(qū)動(dòng)程序建立回調(diào)關(guān)系,也就是說,當(dāng)相應(yīng)的事件發(fā)生時(shí)會(huì)調(diào)用這個(gè)回調(diào)方法。這個(gè)回調(diào)方法在內(nèi)核中叫ep_poll_callback,它會(huì)將發(fā)生的事件添加到rdlist雙鏈表中。
在epoll中,對(duì)于每一個(gè)事件,都會(huì)建立一個(gè)epitem結(jié)構(gòu)體,如下所示:
- struct epitem{
- struct rb_node rbn;//紅黑樹節(jié)點(diǎn)
- struct list_head rdllink;//雙向鏈表節(jié)點(diǎn)
- struct epoll_filefd ffd; //事件句柄信息
- struct eventpoll *ep; //指向其所屬的eventpoll對(duì)象
- struct epoll_event event; //期待發(fā)生的事件類型
- }
當(dāng)調(diào)用epoll_wait檢查是否有事件發(fā)生時(shí),只需要檢查eventpoll對(duì)象中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不為空,則把發(fā)生的事件復(fù)制到用戶態(tài),同時(shí)將事件數(shù)量返回給用戶。
從上面的講解可知:通過紅黑樹和雙鏈表數(shù)據(jù)結(jié)構(gòu),并結(jié)合回調(diào)機(jī)制,造就了epoll的高效。 講解完了Epoll的機(jī)理,我們便能很容易掌握epoll的用法了。一句話描述就是:三步曲。
- 第一步:epoll_create()系統(tǒng)調(diào)用。此調(diào)用返回一個(gè)句柄,之后所有的使用都依靠這個(gè)句柄來標(biāo)識(shí)。
- 第二步:epoll_ctl()系統(tǒng)調(diào)用。通過此調(diào)用向epoll對(duì)象中添加、刪除、修改感興趣的事件,返回0標(biāo)識(shí)成功,返回-1表示失敗。
- 第三部:epoll_wait()系統(tǒng)調(diào)用。通過此調(diào)用收集收集在epoll監(jiān)控中已經(jīng)發(fā)生的事件。
epoll使用示例
- int main(int argc, char* argv[])
- {
- /*
- * 在這里進(jìn)行一些初始化的操作,
- * 比如初始化數(shù)據(jù)和socket等。
- */
- // 內(nèi)核中創(chuàng)建ep對(duì)象
- epfd=epoll_create(256);
- // 需要監(jiān)聽的socket放到ep中
- epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
- while(1) {
- // 阻塞獲取
- nfds = epoll_wait(epfd,events,20,0);
- for(i=0;i<nfds;++i) {
- if(events[i].data.fd==listenfd) {
- // 這里處理accept事件
- connfd = accept(listenfd);
- // 接收新連接寫到內(nèi)核對(duì)象中
- epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
- } else if (events[i].events&EPOLLIN) {
- // 這里處理read事件
- read(sockfd, BUF, MAXLINE);
- //讀完后準(zhǔn)備寫
- epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
- } else if(events[i].events&EPOLLOUT) {
- // 這里處理write事件
- write(sockfd, BUF, n);
- //寫完后準(zhǔn)備讀
- epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
- }
- }
- }
- return 0;
- }
epoll的優(yōu)點(diǎn)
- 沒有最大并發(fā)連接的限制,能打開的FD的上限遠(yuǎn)大于1024(1G的內(nèi)存上能監(jiān)聽約10萬個(gè)端口);
- 效率提升,不是輪詢的方式,不會(huì)隨著FD數(shù)目的增加效率下降。只有活躍可用的FD才會(huì)調(diào)用callback函數(shù);即Epoll最大的優(yōu)點(diǎn)就在于它只管你“活躍”的連接,而跟連接總數(shù)無關(guān),因此在實(shí)際的網(wǎng)絡(luò)環(huán)境中,Epoll的效率就會(huì)遠(yuǎn)遠(yuǎn)高于select和poll;
- 內(nèi)存拷貝,利用mmap()文件映射內(nèi)存加速與內(nèi)核空間的消息傳遞;即epoll使用mmap減少復(fù)制開銷。
epoll缺點(diǎn)
- epoll只能工作在 linux 下
epoll LT 與 ET 模式的區(qū)別
epoll 有 EPOLLLT 和 EPOLLET 兩種觸發(fā)模式,LT 是默認(rèn)的模式,ET 是 “高速” 模式。
- LT 模式下,只要這個(gè) fd 還有數(shù)據(jù)可讀,每次 epoll_wait 都會(huì)返回它的事件,提醒用戶程序去操作;
- ET 模式下,它只會(huì)提示一次,直到下次再有數(shù)據(jù)流入之前都不會(huì)再提示了,無論 fd 中是否還有數(shù)據(jù)可讀。所以在 ET 模式下,read 一個(gè) fd 的時(shí)候一定要把它的 buffer 讀完,或者遇到 EAGIN 錯(cuò)誤。
epoll使用“事件”的就緒通知方式,通過epoll_ctl注冊(cè)fd,一旦該fd就緒,內(nèi)核就會(huì)采用類似callback的回調(diào)機(jī)制來激活該fd,epoll_wait便可以收到通知。
select/poll/epoll之間的區(qū)別
select,poll,epoll都是IO多路復(fù)用的機(jī)制。I/O多路復(fù)用就通過一種機(jī)制,可以監(jiān)視多個(gè)描述符,一旦某個(gè)描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進(jìn)行相應(yīng)的讀寫操作。但select,poll,epoll本質(zhì)上都是同步I/O,因?yàn)樗麄兌夹枰谧x寫事件就緒后自己負(fù)責(zé)進(jìn)行讀寫,也就是說這個(gè)讀寫過程是阻塞的,而異步I/O則無需自己負(fù)責(zé)進(jìn)行讀寫,異步I/O的實(shí)現(xiàn)會(huì)負(fù)責(zé)把數(shù)據(jù)從內(nèi)核拷貝到用戶空間。
epoll跟select都能提供多路I/O復(fù)用的解決方案。在現(xiàn)在的Linux內(nèi)核里有都能夠支持,其中epoll是Linux所特有,而select則應(yīng)該是POSIX所規(guī)定,一般操作系統(tǒng)均有實(shí)現(xiàn)
epoll是Linux目前大規(guī)模網(wǎng)絡(luò)并發(fā)程序開發(fā)的首選模型。在絕大多數(shù)情況下性能遠(yuǎn)超select和poll。目前流行的高性能web服務(wù)器Nginx正式依賴于epoll提供的高效網(wǎng)絡(luò)套接字輪詢服務(wù)。但是,在并發(fā)連接不高的情況下,多線程+阻塞I/O方式可能性能更好。
支持一個(gè)進(jìn)程所能打開的最大連接數(shù)
- select:?jiǎn)蝹€(gè)進(jìn)程所能打開的最大連接數(shù)有FD_SETSIZE宏定義,其大小是32個(gè)整數(shù)的大?。ㄔ?2位的機(jī)器上,大小就是32_32,同理64位機(jī)器上FD_SETSIZE為32_64),當(dāng)然我們可以對(duì)進(jìn)行修改,然后重新編譯內(nèi)核,但是性能可能會(huì)受到影響,這需要進(jìn)一步的測(cè)試。
- poll:poll本質(zhì)上和select沒有區(qū)別,但是它沒有最大連接數(shù)的限制,原因是它是基于鏈表來存儲(chǔ)的。
- epoll:雖然連接數(shù)有上限,但是很大,1G內(nèi)存的機(jī)器上可以打開10萬左右的連接,2G內(nèi)存的機(jī)器可以打開20萬左右的連接。
FD劇增后帶來的IO效率問題
- select:因?yàn)槊看握{(diào)用時(shí)都會(huì)對(duì)連接進(jìn)行線性遍歷,所以隨著FD的增加會(huì)造成遍歷速度慢的“線性下降性能問題”。
- poll:同上
- epoll:因?yàn)閑poll內(nèi)核中實(shí)現(xiàn)是根據(jù)每個(gè)fd上的callback函數(shù)來實(shí)現(xiàn)的,只有活躍的socket才會(huì)主動(dòng)調(diào)用callback,所以在活躍socket較少的情況下,使用epoll沒有前面兩者的線性下降的性能問題,但是所有socket都很活躍的情況下,可能會(huì)有性能問題。
消息傳遞方式
- select:內(nèi)核需要將消息傳遞到用戶空間,都需要內(nèi)核拷貝動(dòng)作
- poll:同上
- epoll:epoll通過內(nèi)核和用戶空間共享一塊內(nèi)存來實(shí)現(xiàn)的。
總結(jié)
select,poll實(shí)現(xiàn)需要自己不斷輪詢所有fd集合,直到設(shè)備就緒,期間可能要睡眠和喚醒多次交替。而epoll其實(shí)也需要調(diào)用epoll_wait不斷輪詢就緒鏈表,期間也可能多次睡眠和喚醒交替,但是它是設(shè)備就緒時(shí),調(diào)用回調(diào)函數(shù),把就緒fd放入就緒鏈表中,并喚醒在epoll_wait中進(jìn)入睡眠的進(jìn)程。雖然都要睡眠和交替,但是select和poll在“醒著”的時(shí)候要遍歷整個(gè)fd集合,而epoll在“醒著”的時(shí)候只要判斷一下就緒鏈表是否為空就行了,這節(jié)省了大量的CPU時(shí)間。這就是回調(diào)機(jī)制帶來的性能提升。
select,poll每次調(diào)用都要把fd集合從用戶態(tài)往內(nèi)核態(tài)拷貝一次,并且要把current往設(shè)備等待隊(duì)列中掛一次,而epoll只要一次拷貝,而且把current往等待隊(duì)列上掛也只掛一次(在epoll_wait的開始,注意這里的等待隊(duì)列并不是設(shè)備等待隊(duì)列,只是一個(gè)epoll內(nèi)部定義的等待隊(duì)列)。這也能節(jié)省不少的開銷。
高頻面試題
什么是IO多路復(fù)用?
看完上面的文章,相信你可以回答出來了。
nginx/redis 所使用的IO模型是什么?
Nginx的IO模型
Nginx 支持多種并發(fā)模型,并發(fā)模型的具體實(shí)現(xiàn)根據(jù)系統(tǒng)平臺(tái)而有所不同。
在支持多種并發(fā)模型的平臺(tái)上,nginx 自動(dòng)選擇最高效的模型。但我們也可以使用 use 指令在配置文件中顯式地定義某個(gè)并發(fā)模型。
NGINX中支持的并發(fā)模型:
1、select
IO多路復(fù)用、標(biāo)準(zhǔn)并發(fā)模型。在編譯 nginx 時(shí),如果所使用的系統(tǒng)平臺(tái)沒有更高效的并發(fā)模型,select 模塊將被自動(dòng)編譯。configure 腳本的選項(xiàng):–with-select_module 和 --without-select_module 可被用來強(qiáng)制性地開啟或禁止 select 模塊的編譯
2、poll
IO多路復(fù)用、標(biāo)準(zhǔn)并發(fā)模型。與 select 類似,在編譯 nginx 時(shí),如果所使用的系統(tǒng)平臺(tái)沒有更高效的并發(fā)模型,poll 模塊將被自動(dòng)編譯。configure 腳本的選項(xiàng):–with-poll_module 和 --without-poll_module 可用于強(qiáng)制性地開啟或禁止 poll 模塊的編譯
3、epoll
IO多路復(fù)用、高效并發(fā)模型,可在 Linux 2.6+ 及以上內(nèi)核可以使用
4、kqueue
IO多路復(fù)用、高效并發(fā)模型,可在 FreeBSD 4.1+, OpenBSD 2.9+, NetBSD 2.0, and Mac OS X 平臺(tái)中使用
5、/dev/poll
高效并發(fā)模型,可在 Solaris 7 11/99+, HP/UX 11.22+ (eventport), IRIX 6.5.15+, and Tru64 UNIX 5.1A+ 平臺(tái)使用
6、eventport
高效并發(fā)模型,可用于 Solaris 10 平臺(tái),PS:由于一些已知的問題,建議 使用/dev/poll替代。
Redis IO多路復(fù)用技術(shù)
redis 是一個(gè)單線程卻性能非常好的內(nèi)存數(shù)據(jù)庫, 主要用來作為緩存系統(tǒng)。 redis 采用網(wǎng)絡(luò)IO多路復(fù)用技術(shù)來保證在多連接的時(shí)候, 系統(tǒng)的高吞吐量。
為什么 Redis 中要使用 I/O 多路復(fù)用這種技術(shù)呢?
首先,Redis 是跑在單線程中的,所有的操作都是按照順序線性執(zhí)行的,但是由于讀寫操作等待用戶輸入或輸出都是阻塞的,所以 I/O 操作在一般情況下往往不能直接返回,這會(huì)導(dǎo)致某一文件的 I/O 阻塞導(dǎo)致整個(gè)進(jìn)程無法對(duì)其它客戶提供服務(wù),而 I/O 多路復(fù)用 就是為了解決這個(gè)問題而出現(xiàn)的。
redis的io模型主要是基于epoll實(shí)現(xiàn)的,不過它也提供了 select和kqueue的實(shí)現(xiàn),默認(rèn)采用epoll。
select、poll、epoll之間的區(qū)別
看完上面的文章,相信你可以回答出來了。
epoll 水平觸發(fā)(LT)與 邊緣觸發(fā)(ET)的區(qū)別?
EPOLL事件有兩種模型:
- Edge Triggered (ET) 邊緣觸發(fā)只有數(shù)據(jù)到來,才觸發(fā),不管緩存區(qū)中是否還有數(shù)據(jù)。
- Level Triggered (LT) 水平觸發(fā)只要有數(shù)據(jù)都會(huì)觸發(fā)。
看完上面的文章,相信你可以回答出來了。