Linux之I/O 多路復(fù)用之Select、Poll、EPoll

前言

我們知道Android消息機(jī)制是通過Handler、Looper與MessageQueue建立的整個(gè)體系,Looper.loop()方法是開啟了一個(gè)死循環(huán),不停地從MessageQueue中取消息,但是為什么死循環(huán)不會(huì)浪費(fèi)CPU資源呢?這就要開啟我們的EPoll了。

Message next() {
    for (;;) {
        // 調(diào)用了native pollonce
        nativePollOnce(ptr, nextPollTimeoutMillis);
    }
}

前提鋪墊

  • 同步/異步 IO
    同步:執(zhí)行IO操作時(shí),CPU等待,直到IO操作結(jié)束,再繼續(xù)執(zhí)行CPU后續(xù)操作。
    異步:執(zhí)行IO操作時(shí),CPU執(zhí)行后續(xù)操作,無需等待IO操作結(jié)束,結(jié)束后通知CPU即可。
    形象化表達(dá) 點(diǎn)餐:
    同步:去KFC點(diǎn)餐,服務(wù)員說蛋撻還得等20分鐘出爐,那么客戶準(zhǔn)備吃完蛋撻再去逛街,所以就一直等著蛋撻直到蛋撻出爐再去逛街。
    異步: 去KFC點(diǎn)餐,服務(wù)員說蛋撻還得等20分鐘出爐,那么客戶準(zhǔn)備吃完蛋撻再去逛街,所以就先去逛街,20分鐘后再來取蛋撻。
  • 阻塞/非阻塞IO
    理論上,阻塞 = 同步,異步 = 非阻塞
    阻塞/非阻塞IO在不同的語境中與同步/異步IO是代表不同的意思的,這就涉及對(duì)IO模型的討論了,這個(gè)我們后續(xù)會(huì)說。
  • 用戶空間/內(nèi)核空間
  • 緩存IO
    緩存IO稱為標(biāo)準(zhǔn)IO,大多數(shù)的文件系統(tǒng)默認(rèn)的IO操作都是緩存I/O。在Linux中,數(shù)據(jù)會(huì)先從磁盤復(fù)制到內(nèi)核控件的緩沖區(qū),然后從內(nèi)核空間緩沖區(qū)復(fù)制到應(yīng)用程序的地址控件。
    優(yōu)點(diǎn):
    1、緩存IO在一定程度上將用戶空間與內(nèi)核控件進(jìn)行了數(shù)據(jù)分離,確保了系統(tǒng)本身的應(yīng)用安全。
    2、減少了讀取磁盤的次數(shù),從而提升性能。
    缺點(diǎn):
    數(shù)據(jù)在進(jìn)行傳輸?shù)倪^程中,需要多次進(jìn)行應(yīng)用程序地址空間(用戶空間)與內(nèi)核緩存之間進(jìn)行多次數(shù)據(jù)拷貝過程,因?yàn)閼?yīng)用程序地址空間無法直接與磁盤進(jìn)行操作,所以拷貝所帶來的CPU開銷是非常大的。
  • 進(jìn)程的阻塞
    正在執(zhí)行任務(wù)的進(jìn)程,由于期待的某些時(shí)間還未發(fā)生,如請(qǐng)求系統(tǒng)資源失敗、等待某些操作未完成、新數(shù)據(jù)尚未到達(dá)或沒有新的工作等等,則由系統(tǒng)自動(dòng)執(zhí)行Block阻塞原語,使之放棄CPU使用權(quán),移交給其他進(jìn)程,所以不占用CPU資源。
  • 文件描述符
    文件描述符(File descriptor)是計(jì)算機(jī)科學(xué)中的一個(gè)術(shù)語,是一個(gè)用于表述指向文件的引用的抽象化概念。
    在Linux中,文件描述符在形式上就是一個(gè)非負(fù)整數(shù)。相當(dāng)于一個(gè)索引值,指向內(nèi)核為每一個(gè)進(jìn)程所維護(hù)該進(jìn)程打開的文件的記錄表。程序中一個(gè)文件對(duì)應(yīng)一個(gè)文件描述符,是內(nèi)核在創(chuàng)建或打開時(shí)返回的。

網(wǎng)絡(luò)數(shù)據(jù) I/O

計(jì)算機(jī)是如何接收網(wǎng)絡(luò)數(shù)據(jù)的呢?我們知道計(jì)算機(jī)網(wǎng)卡是用來進(jìn)行無線通訊的,網(wǎng)卡首先接收到的數(shù)據(jù),通過網(wǎng)卡接口傳輸?shù)接布娐分?,再將?shù)據(jù)寫入到內(nèi)存中某個(gè)地址。其中涉及到DMA(Direct Memory Access)傳輸以及IO通路選擇。

  • DMA(Direct Memory Access)
    根據(jù)計(jì)組知識(shí),我們知道CPU是通過地址總線,數(shù)據(jù)總線,控制信號(hào)總線來與系統(tǒng)其他部分進(jìn)行數(shù)據(jù)通信的。地址線用于提供內(nèi)存或I/O設(shè)備的地址,即指明需要讀/寫數(shù)據(jù)的具體位置。數(shù)據(jù)線用于在 CPU 和內(nèi)存或 I/O 設(shè)備之間提供數(shù)據(jù)傳輸?shù)耐ǖ溃刂凭€則負(fù)責(zé)指揮執(zhí)行的具體讀/寫操作。所以說32位機(jī)器代表的是內(nèi)部地址線和數(shù)據(jù)線的根數(shù)有32根。那么DMA數(shù)據(jù)傳輸?shù)姆绞?,就是說外設(shè)試圖通過總線向另一個(gè)設(shè)備發(fā)送數(shù)據(jù),并且是大量數(shù)據(jù)的時(shí)候,外設(shè)會(huì)先去請(qǐng)求CPU發(fā)送DMA請(qǐng)求信號(hào)。外設(shè)通過專門的接口電路(DMA控制器),向CPU發(fā)出接管總線控制權(quán),當(dāng)CPU接收到信號(hào)后,在當(dāng)前的總線周期結(jié)束后,會(huì)按照DMA信號(hào)的優(yōu)先級(jí)和提出DMA請(qǐng)求的先后順序進(jìn)行響應(yīng)DMA信號(hào)。CPU會(huì)讓出總線控制權(quán),這時(shí)外設(shè)持有總線控制權(quán),可以直接與存儲(chǔ)器進(jìn)行數(shù)據(jù)通信,不在需要CPU干預(yù),等待數(shù)據(jù)發(fā)送完畢之后,會(huì)向CPU發(fā)送DMA結(jié)束信號(hào),歸還總線控制權(quán)。

中斷信號(hào)

我們?cè)趯W(xué)習(xí)計(jì)組的時(shí)候,一定聽說過CPU中斷信號(hào)。也就是說,若硬件產(chǎn)生的信號(hào),需要讓CPU進(jìn)行響應(yīng),換句話說,硬件產(chǎn)生的信號(hào)一般為中斷信號(hào),因?yàn)樵撔盘?hào)一旦不作出相應(yīng),就會(huì)將數(shù)據(jù)丟失掉。比如,當(dāng)計(jì)算機(jī)收到了斷電信號(hào)時(shí),CPU會(huì)立即去通知保存數(shù)據(jù)的程序,防止數(shù)據(jù)丟失,電容可以保存一些電量用于提供CPU運(yùn)行一小段時(shí)間。那么計(jì)算機(jī)如何知道已經(jīng)接受到了網(wǎng)卡數(shù)據(jù)呢?網(wǎng)卡寫入內(nèi)存以后,網(wǎng)卡會(huì)向CPU發(fā)出一個(gè)中斷信號(hào),操作系統(tǒng)就能夠知道有新數(shù)據(jù)到來,通過網(wǎng)卡中斷程序去處理數(shù)據(jù)。

進(jìn)程阻塞為什么不會(huì)消耗CPU資源?

阻塞的概念我們已經(jīng)理解了,我們先看一段基礎(chǔ)的網(wǎng)絡(luò)編程代碼。

int socket = createSocket()
bindSocket();
listenSocket();
int c = accept(socket,.....);
// 死循環(huán)之類的從socket inputStream 阻塞線程
recv(c,...)

先創(chuàng)建Socket,綁定,監(jiān)聽 和 接受 在通過死循環(huán)接收數(shù)據(jù)。recv 是一個(gè)阻塞方法,當(dāng)程序執(zhí)行recv就一直等到收到數(shù)據(jù)再往下執(zhí)行。
所以操作系統(tǒng)為了執(zhí)行多任務(wù),也就類似進(jìn)程調(diào)度,會(huì)分為運(yùn)行與等待狀態(tài)等等(線程同樣如此)。運(yùn)行就是所謂的獲取到CPU使用權(quán),阻塞就是等待狀態(tài),也就失去CPU使用權(quán)。CPU就是不停地進(jìn)行各個(gè)進(jìn)程間的使用切換,所以由于速度處理的很快,看上去是同時(shí)執(zhí)行多個(gè)任務(wù)。
如何知道哪些任務(wù)要進(jìn)行CPU處理,哪些需要讓出CPU呢?

  • 工作隊(duì)列
    進(jìn)程運(yùn)行狀態(tài),將會(huì)把該進(jìn)程依次放入到工作執(zhí)行隊(duì)列中,需要cpu執(zhí)行處理的隊(duì)列。


    工作隊(duì)列
  • 等待隊(duì)列
    進(jìn)程一旦處于阻塞狀態(tài),也就是等待狀態(tài)時(shí),比如Socket在執(zhí)行創(chuàng)建語句時(shí),操作系統(tǒng)會(huì)創(chuàng)建一個(gè)由文件系統(tǒng)管理的Socket對(duì)象,這個(gè)對(duì)象包含發(fā)送、接收緩沖區(qū)以及等待隊(duì)列成員。
    當(dāng)最終進(jìn)程A執(zhí)行recv函數(shù)的時(shí)候,操作系統(tǒng)會(huì)將進(jìn)程A從工作隊(duì)列移除到等待隊(duì)列中,這時(shí)候工作隊(duì)列只剩下進(jìn)程B、C,之后CPU輪流執(zhí)行進(jìn)程B、C的代碼。所以CPU不會(huì)區(qū)執(zhí)行等待隊(duì)列中的進(jìn)程,因此也就不會(huì)浪費(fèi)CPU資源。


    等待隊(duì)列

    工作隊(duì)列
  • 喚醒進(jìn)程
    喚醒進(jìn)程這里,一旦Socket接收到消息,我們之前描述的會(huì)收到一個(gè)中斷信號(hào),告訴CPU接收到了消息 同樣的,又將進(jìn)程A重新放回工作隊(duì)列中。

Epoll在Android中的應(yīng)用

Code from Android Data Source 5.1 -- Looper.cpp
Epoll 初始化
Looper::Looper(bool allowNonCallbacks) :
        mAllowNonCallbacks(allowNonCallbacks), mSendingMessage(false),
        mResponseIndex(0), mNextMessageUptime(LLONG_MAX) {
    int wakeFds[2];
    // Android 采用pipe通道進(jìn)行數(shù)據(jù)的通信 result = 0成功,失敗返回-1
    int result = pipe(wakeFds);
    // 管道讀端 fd
    mWakeReadPipeFd = wakeFds[0];
    // 管道寫端 fd
    mWakeWritePipeFd = wakeFds[1];
    result = fcntl(mWakeReadPipeFd, F_SETFL, O_NONBLOCK);
    result = fcntl(mWakeWritePipeFd, F_SETFL, O_NONBLOCK);
    mIdling = false;
    // Allocate the epoll instance and register the wake pipe.
    // 創(chuàng)建Epoll句柄,返回一個(gè)文件描述符 方法參數(shù)為EPOLL_SIZE_HINT = 8的內(nèi)核監(jiān)聽容量
    mEpollFd = epoll_create(EPOLL_SIZE_HINT);
    struct epoll_event eventItem;
    memset(& eventItem, 0, sizeof(epoll_event)); // zero out unused members of data field union
    eventItem.events = EPOLLIN;
    eventItem.data.fd = mWakeReadPipeFd;
    // epoll 事件注冊(cè)函數(shù) 第一個(gè)傳入mEpollFd文件描述符,第二個(gè)參數(shù)代表動(dòng)作,EPOLL_CTL_ADD代表注冊(cè)新的fd到epfd中
    // 第三個(gè)參數(shù) 代表監(jiān)聽哪個(gè)文件描述符 也就是說在Android中epoll是通過監(jiān)聽讀端fd,來告知MessageQueue來數(shù)據(jù)了
    // 第四個(gè)參數(shù) 用于處理監(jiān)聽什么事件
    result = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, mWakeReadPipeFd, & eventItem);
}

其中epoll_ctl()方法中的第二個(gè)參數(shù)代表執(zhí)行操作宏定義,分為三種。

  • EPOLL_CTL_ADD
    添加新的事件到epoll中
  • EPOLL_CTL_MOD
    修改epoll中的事件
  • EPOLL_CTL_DEL
    刪除epoll中的事件

接下來,MessageQueue next()方法實(shí)則是調(diào)用了pollOnce方法,我們來看下。

android_os_MessageQueue.cpp
static void android_os_MessageQueue_nativePollOnce(JNIEnv* env, jobject obj,
        jlong ptr, jint timeoutMillis) {
    NativeMessageQueue* nativeMessageQueue = reinterpret_cast<NativeMessageQueue*>(ptr);
    nativeMessageQueue->pollOnce(env, obj, timeoutMillis);
}
int Looper::pollOnce(int timeoutMillis, int* outFd, int* outEvents, void** outData) {
         //.... 省略了大量代碼
        // 最終調(diào)用了 pollInner 方法并傳入了超時(shí)時(shí)長
        result = pollInner(timeoutMillis);
    }
}
int Looper::pollInner(int timeoutMillis) {
    // Poll.
    int result = POLL_WAKE;
    mResponses.clear();
    mResponseIndex = 0;

    // We are about to idle.
    // 是否是空轉(zhuǎn)狀態(tài) 也就是在調(diào)用epoll_wait時(shí),加入一個(gè)標(biāo)記,表明是否阻塞
    mIdling = true;

    struct epoll_event eventItems[EPOLL_MAX_EVENTS];
    // 執(zhí)行epoll_wait方法進(jìn)行阻塞,等待fd socket 返回?cái)?shù)據(jù)
    int eventCount = epoll_wait(mEpollFd, eventItems, EPOLL_MAX_EVENTS, timeoutMillis);

    // No longer idling.
   // 取消空轉(zhuǎn)狀態(tài),表明目前已經(jīng)收到了數(shù)據(jù)
    mIdling = false;

    // Acquire lock.
    // 加鎖用于后續(xù)的fd遍歷
    mLock.lock();

// 偽代碼... 
// epoll_wait 之后一般是一個(gè)循環(huán)
for (int i = 0; i < eventCount; i++) {
        int fd = eventItems[i].data.fd;
        uint32_t epollEvents = eventItems[i].events;
        // 若接收到的數(shù)據(jù)來源是正在監(jiān)聽的pipe通道所開辟的fd,則說明當(dāng)前Looper綁定的Thread中的MessageQueue接收到了數(shù)據(jù),
        // 可以繼續(xù)走Java代碼中的For循環(huán)之后的代碼了,繼續(xù)獲取CPU執(zhí)行權(quán)。
        if (fd == mWakeReadPipeFd) {
            if (epollEvents & EPOLLIN) {
                //  喚醒 也就是說開始讀管道中的數(shù)據(jù)
                awoken();
            } else {
                 //... 省略
            }
        } else {
           //... 省略
        }
    }
}


void Looper::awoken() {
    char buffer[16];
    ssize_t nRead;
    do {
        // 讀管道中的數(shù)據(jù)
        nRead = read(mWakeReadPipeFd, buffer, sizeof(buffer));
    } while ((nRead == -1 && errno == EINTR) || nRead == sizeof(buffer));
}

以上就是Epoll在android中的應(yīng)用。具體細(xì)節(jié),之后還會(huì)另寫一篇文章詳細(xì)說明。

要解決的問題

一個(gè)應(yīng)用進(jìn)程對(duì)應(yīng)一個(gè)Socket文件,操作系統(tǒng)中應(yīng)用無上限,因此代表有多個(gè)Socket文件接收到信息后,要知道到底發(fā)送給那個(gè)應(yīng)用進(jìn)程?所有的進(jìn)程關(guān)心的Socket都由一個(gè)線程進(jìn)行處理,所以也就是構(gòu)成I/O多路復(fù)用問題。

數(shù)據(jù)結(jié)構(gòu)

當(dāng)然這些Socket集合,當(dāng)FD=XXX發(fā)生了事件變化,那么是那些進(jìn)程需要關(guān)心的呢?
這里就涉及到Socket集合的數(shù)據(jù)結(jié)構(gòu)了。
一個(gè)Socket文件,能夠由多個(gè)進(jìn)程使用,而一個(gè)進(jìn)程也可以使用多個(gè)Socket文件,因此Socket與進(jìn)程是多對(duì)多的關(guān)系。另外,一個(gè)Socket也會(huì)有不同的事件類型。所以操作系統(tǒng)將很難判斷出將哪樣的事件給那個(gè)進(jìn)程。

鏈表/數(shù)組線性結(jié)構(gòu)

Select與Poll都是采用了線性結(jié)構(gòu)。Select允許用戶傳入三個(gè)集合,這三個(gè)集合分別對(duì)應(yīng)了Socket的三種不同的狀態(tài)的集合,讀集合、寫集合、異常集合。

Select

fd_set read_fd_set, write_fd_set, error_fd_set;
while(true) {
  // 進(jìn)程讀關(guān)心、寫關(guān)心、異常時(shí)關(guān)心的Socket集合。
  select(..., &read_fd_set, &write_fd_set, &error_fd_set); 
  for (i = 0; i < FD_SETSIZE; ++i)
        if (FD_ISSET (i, &read_fd_set)){
          // Socket可以讀取
        } else if(FD_ISSET(i, &write_fd_set)) {
          // Socket可以寫入
        } else if(FD_ISSET(i, &error_fd_set)) {
          // Socket發(fā)生錯(cuò)誤
        } 
}

因此呢,每次進(jìn)行Select操作的時(shí)候呢,會(huì)阻塞當(dāng)前的線程,在阻塞期間所有的操作系統(tǒng)產(chǎn)生的每個(gè)信息,都會(huì)通過遍歷的方式去查找這個(gè)消息是否存在于這三個(gè)集合之中。其中 FD_SETSIZE代表最大并發(fā)數(shù)量,一般是1024個(gè)并發(fā)。

Select缺點(diǎn)

  • 每次需要遍歷這三種集合,并且需要進(jìn)行比較操作,浪費(fèi)了很大的時(shí)間。
  • 不是一個(gè)很好的編程模型,不應(yīng)該讓用戶來設(shè)置自己的集合而是通過系統(tǒng)的Api來拿到對(duì)應(yīng)的消息。

Poll

while(true) {
  events = poll(fds, ...)
  for(evt in events) {
    fd = evt.fd;
    type = evt.revents;
    if(type & POLLIN ) {
       // 有數(shù)據(jù)需要讀,讀取fd中的數(shù)據(jù)
    } else if(type & POLLOUT) {
       // 可以寫入數(shù)據(jù)
    } 
    else ...
  }
}

Poll 在性能上呢和Select差別不大,還是需要進(jìn)行遍歷,只是說在編程模型上進(jìn)行了優(yōu)化處理。

總結(jié)Select/Poll

都是阻塞式IO,都需要進(jìn)行Socket線性集合的遍歷找到當(dāng)前進(jìn)程所需要的Socket。

EPoll

為了解決上述的問題,epoll 通過更好的方案實(shí)現(xiàn)了從操作系統(tǒng)訂閱消息。epoll 將進(jìn)程關(guān)注的文件描述符存入一棵二叉搜索樹,通常是紅黑樹的實(shí)現(xiàn)。在這棵紅黑樹當(dāng)中,Key 是 Socket 的編號(hào),值是這個(gè) Socket 關(guān)注的消息。因此,當(dāng)內(nèi)核發(fā)生了一個(gè)事件:比如 Socket 編號(hào) 1000 可以讀取。這個(gè)時(shí)候,可以馬上從紅黑樹中找到進(jìn)程是否關(guān)注這個(gè)事件。另外當(dāng)有關(guān)注的事件發(fā)生時(shí),epoll 會(huì)先放到一個(gè)隊(duì)列當(dāng)中。當(dāng)用戶調(diào)用epoll_wait時(shí)候,就會(huì)從隊(duì)列中返回一個(gè)消息。epoll 函數(shù)本身是一個(gè)構(gòu)造函數(shù),只用來創(chuàng)建紅黑樹和隊(duì)列結(jié)構(gòu)。epoll_wait調(diào)用后,如果隊(duì)列中沒有消息,也可以馬上返回。因此epoll是一個(gè)非阻塞模型。
總結(jié)一下,select/poll 是阻塞模型,epoll 是非阻塞模型。當(dāng)然,并不是說非阻塞模型性能就更好。在多數(shù)情況下,epoll 性能更好是因?yàn)閮?nèi)部有紅黑樹的實(shí)現(xiàn)。

優(yōu)點(diǎn)

  • 內(nèi)部使用紅黑樹減少了內(nèi)核的比較操作;
  • 對(duì)于程序員而言,非阻塞的模型更容易處理各種各樣的情況。程序員習(xí)慣了寫出每一條語句就可以馬上得到結(jié)果,這樣不容易出 Bug。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容