1. 程式人生 > >自己趟過epoll的坑

自己趟過epoll的坑

坑的背景

本人用epoll來實現多路複用,epoll觸發模式有兩種:

  • ET(邊緣模式)
  • LT(水平模式)

LT模式

是標準模式,意味著每次epoll_wait()返回後,事件處理後,如果之後還有資料,會不斷觸發,也就是說,一個套接字上一次完整的資料,epoll_wait()可能會返回多次,直到沒有資料為止。

ET模式

也稱高效模式,有資料過來後,epoll_wait()會返回一次,一段時間內,該套接字就算有資料來源源不斷地過來,epoll_wait()也不會返回了。這裡注意,是一段時間,不代表這個套接字上有資料就只觸發一次。時間過長,還是會返回多次的。比如我寫FTP用了epoll+多執行緒,但是每次套接字上有資訊就開執行緒處理,同一時間內希望一個套接字只被一個執行緒持有,但是因為檔案傳輸時間過長,就算使用ET模式,套接字還是會返回多次。這裡要特別強調一個引數EPOLLONESHOT,如果要保證套接字同一時段只被一個執行緒處理,必須加上。

解決方案:給accept()後的套接字加上引數EPOLLONESHOT,執行緒結束後處理完之後,再重置EPOLLONESHOT屬性,但是,千萬不可以給listen()後的監聽套接字設定此屬性,這會造成同一時刻只能處理一個連線的情況。

深入理解EPOLLONESHOT事件

即使使用ET模式,一個socket上的某個事件還是可能被觸發多次,這是跟資料報的大小有關係,常見的情景就是一個執行緒,而在資料的處理過程中該socket上又有新資料可讀(EPOLLIN再次被觸發),此時另外一個執行緒被喚醒處理這些新的資料,於是出現了兩個執行緒同時操作一個socket,為了避免這種情況,就可以採用epoll的EPOLLONESPOT事件。同時要注意,註冊了EPOLLONESHOT事件的socket一旦被某個執行緒處理完畢,該執行緒就應該立即重置這個socket的EPOLLONESHOT的事件,以確保這個socket下次可讀時,其EPOLLIN事件被觸發,進而讓其他的工作執行緒有機會繼續處理這個socket。

網路事件EAGIN

在一個非阻塞的socket上呼叫read/write函式, 返回EAGAIN或者EWOULDBLOCK(注: EAGAIN就是EWOULDBLOCK)從字面上看, 意思是:EAGAIN: 再試一次,EWOULDBLOCK: 如果這是一個阻塞socket, 操作將被block,perror輸出: Resource temporarily unavailable.

小結:

這個錯誤表示資源暫時不夠,能read時,讀緩衝區沒有資料,或者write時,寫緩衝區滿了。遇到這種情況,如果是阻塞socket,read/write就要阻塞掉。而如果是非阻塞socket,read/write立即返回-1, 同時errno設定為EAGAIN。所以,對於阻塞socket,read/write返回-1代表網路出錯了。但對於非阻塞socket,read/write返回-1不一定網路真的出錯了。可能是Resource temporarily unavailable。這時你應該再試,直到Resource available。

EAGAIN: 再試一次,EWOULDBLOCK: 如果這是一個阻塞socket, 操作將被block,perror輸出: Resource temporarily unavailable。這個錯誤表示資源暫時不夠,能read時,讀緩衝區沒有資料,或者write時,寫緩衝區滿了。遇到這種情況,如果是阻塞socket,read/write就要阻塞掉。而如果是非阻塞socket,read/write立即返回-1, 同時errno設定為EAGAIN。所以,對於阻塞socket,read/write返回-1代表網路出錯了。但對於非阻塞socket,read/write返回-1不一定網路真的出錯了。可能是Resource temporarily unavailable。這時你應該再試,直到Resource available。

綜上,對於non-blocking的socket,正確的讀寫操作為:
讀:忽略掉errno = EAGAIN的錯誤,下次繼續讀
寫:忽略掉errno = EAGAIN的錯誤,下次繼續寫

對於select和epoll的LT模式,這種讀寫方式是沒有問題的。但對於epoll的ET模式,這種方式還有漏洞。

epoll的兩種模式LT和ET

二者的差異在於level-trigger模式下只要某個socket處於readable/writable狀態,無論什麼時候進行epoll_wait都會返回該socket;而edge-trigger模式下只有某個socket從unreadable變為readable或從unwritable變為writable時,epoll_wait才會返回該socket。如下兩個示意圖:
從socket讀資料:
socket_read
從socket寫資料:
socket_write
所以,在epoll的ET模式下,正確的讀寫方式為:
讀:只要可讀,就一直讀,直到返回0,或者 errno = EAGAIN 寫:只要可寫,就一直寫,直到資料傳送完,或者 errno = EAGAIN。

正確的讀:

  1. n =0;
  2. while((nread = read(fd, buf + n, BUFSIZ-1))>0){
  3. if(nread ==-1&& errno != EAGAIN)
  4. {
  5. perror("read error");
  6. }
  7. n += nread;
  8. }

正確的寫:

  1. int nwrite, data_size = strlen(buf);
  2. n = data_size;
  3. while(n >0)
  4. {
  5. nwrite = write(fd, buf + data_size - n, n);
  6. if(nwrite < n)
  7. {
  8. if(nwrite ==-1&& errno != EAGAIN)
  9. {
  10. perror("write error");
  11. }
  12. break;
  13. }
  14. n -= nwrite;
  15. }

accept上的問題

  • 阻塞模式 accept 存在的問題
    考慮這種情況:TCP連線被客戶端夭折,即在伺服器呼叫accept之前,客戶端主動傳送RST終止連線,導致剛剛建立的連線從就緒佇列中移出,如果套介面被設定成阻塞模式,伺服器就會一直阻塞在accept呼叫上,直到其他某個客戶建立一個新的連線為止。但是在此期間,伺服器單純地阻塞在accept呼叫上,就緒佇列中的其他描述符都得不到處理。

解決方案:把監聽套介面設定為非阻塞,當客戶在伺服器呼叫accept之前中止某個連線時,accept呼叫可以立即返回-1,這時源自Berkeley的實現會在核心中處理該事件,並不會將該事件通知給epoll,而其他實現把errno設定為ECONNABORTED或者EPROTO錯誤,我們應該忽略這兩個錯誤。

  • ET模式下accept存在的問題。

考慮這種情況:多個連線同時到達,伺服器的TCP就緒佇列瞬間積累多個就緒連線,由於是邊緣觸發模式,epoll只會通知一次,accept只處理一個連線,導致TCP就緒佇列中剩下的連線都得不到處理。
解決辦法是用while迴圈抱住accept呼叫,處理完TCP就緒佇列中的所有連線後再退出迴圈。如何知道是否處理完就緒佇列中的所有連線呢?accept返回-1並且errno設定為EAGAIN就表示所有連線都處理完。

綜合以上兩種情況,伺服器應該使用非阻塞地accept,accept在ET模式下的正確使用方式為:

  1. while((conn_sock = accept(listenfd,(struct sockaddr *)&remote,(size_t*)&addrlen))>0)
  2. {
  3. handle_client(conn_sock);
  4. }
  5. if(conn_sock ==-1)
  6. {
  7. if(errno != EAGAIN && errno != ECONNABORTED && errno != EPROTO && errno != EINTR)
  8. perror("accept");
  9. }

一道騰訊後臺開發的面試題:

使用Linux epoll模型,水平觸發模式;當socket可寫時,會不停的觸發socket可寫的事件,如何處理?
+ 第一種最普遍的方式:
需要向socket寫資料的時候才把socket加入epoll,等待可寫事件。接受到可寫事件後,呼叫write或者send傳送資料。當所有資料都寫完後,把socket移出epoll。
這種方式的缺點是,即使傳送很少的資料,也要把socket加入epoll,寫完後在移出epoll,有一定操作代價。

  • 第二種的方式:
    開始不把socket加入epoll,需要向socket寫資料的時候,直接呼叫write或者send傳送資料。如果返回EAGAIN,把socket加入epoll,在epoll的驅動下寫資料,全部資料傳送完畢後,再移出epoll。
    這種方式的優點是:資料不多的時候可以避免epoll的事件處理,提高效率。

我自己程式碼的問題

因為我之前才用的是非阻塞ET模式,這樣我在傳送緩衝資料的資料,會出現EAGAIN的問題。這個問題並不可怕,最可怕的是會發生,因為上面已經有方法解決。為了看起來方便我這邊再拷貝一份。但是我原始碼採用的writev原始碼下載地址614行進行傳送,根本無法才採用下面的方法。

  1. int nwrite, data_size = strlen(buf);
  2. n = data_size;
  3. while(n >0)
  4. {
  5. nwrite = write(fd, buf + data_size - n, n);
  6. if(nwrite < n)
  7. {
  8. if(nwrite ==-1&& errno != EAGAIN)
  9. {
  10. perror("write error");
  11. }
  12. break;
  13. }
  14. n -= nwrite;
  15. }

我的解決方法採用阻塞寫,這個方法很好的解決上面的問題:對於非阻塞的TCP套接字,如果緩衝區根本就沒空間,則返回一個EWOULDBLOCK錯誤。如果緩衝區有一些空間,返回值是核心能夠複製到該緩衝區的位元組數。這個位元組數也叫作不足計數.。詳細見我上述連線的程式碼檔案。

在讀取緩衝區也是這樣。也是採用阻塞進行讀取,這樣做,雖然降低併發性,但是為了準確處理資料。

總結

ET模式下:
如果read返回0,那麼說明已經接受所有資料
如果errno=EAGAIN,說明還有資料未接收,等待下一次通知
如果read返回-1,說明發生錯誤,停止處理