epoll ET模式和LT模式分析
關於epoll的問題很早就像寫文章講講自己的看法,但是由於ffrpc一直沒有完工,所以也就拖下來了。Epoll主要在伺服器程式設計中使用,本文主要探討伺服器程式中epoll的使用技巧。Epoll一般和非同步io結合使用,故本文討論基於以下應用場合:
- 主要討論伺服器程式中epoll的使用,主要涉及tcp socket的相關api。
- Tcp socket 為非同步模式,包括socket的非同步讀寫,以及監聽的非同步操作。
- 本文不會過多討論API的細節,而是專注流程與設計。
Epoll 的io模型
Epoll是為非同步io操作而設計的,epoll中IO事件被分為read事件和write事件,如果大家對於linux的驅動模組或者linux io 模型有接觸的話,就會理解起來更容易。Linux中IO操作被抽象為read、write、close、ctrl幾個操作,所以epoll只提供read、write、error事件,是和linux的io模型是統一的。
- 當epoll通知read事件時,可以呼叫io系統呼叫read讀取資料
- 當epoll通知write事件時,可以呼叫io系統呼叫write傳送資料
- 當error事件時,可以close回收資源
- Ctrl相關的介面則用來設定socket的非阻塞選項等。
為什麼要了解epoll的io模型呢,本文認為,某些情況下epoll操作的程式碼的複雜性是由於程式碼中的模型(或者類設計)與epoll io模型不匹配造成的。換句話說,如果我們的編碼模型和epoll io模型匹配,那麼非阻塞socket的編碼就會很簡單、清晰。
按照epoll模型構建的類關係為:
//! 檔案描述符相關介面 typedef intsocket_fd_t; class fd_i { public: virtual ~fd_i(){} virtual socket_fd_t socket() = 0; virtual int handle_epoll_read() = 0; virtual int handle_epoll_write() = 0; virtual int handle_epoll_del() = 0; virtual void close() = 0; }; int epoll_impl_t::event_loop() {int i = 0, nfds = 0; struct epoll_event ev_set[EPOLL_EVENTS_SIZE]; do { nfds = ::epoll_wait(m_efd, ev_set, EPOLL_EVENTS_SIZE, EPOLL_WAIT_TIME); if (nfds < 0 && EINTR == errno) { nfds = 0; continue; } for (i = 0; i < nfds; ++i) { epoll_event& cur_ev = ev_set[i]; fd_i* fd_ptr = (fd_i*)cur_ev.data.ptr; if (cur_ev.data.ptr == this)//! iterupte event { if (false == m_running) { return 0; } //! 刪除那些已經出現error的socket 物件 fd_del_callback(); continue; } if (cur_ev.events & (EPOLLIN | EPOLLPRI)) { fd_ptr->handle_epoll_read(); } if(cur_ev.events & EPOLLOUT) { fd_ptr->handle_epoll_write(); } if (cur_ev.events & (EPOLLERR | EPOLLHUP)) { fd_ptr->close(); } } }while(nfds >= 0); return 0; }
Epoll的LT模式和ET模式的比較
先簡單比較一下level trigger 和 edge trigger 模式的不同。
LT模式的特點是:
- 若資料可讀,epoll返回可讀事件
- 若開發者沒有把資料完全讀完,epoll會不斷通知資料可讀,直到資料全部被讀取。
- 若socket可寫,epoll返回可寫事件,而且是隻要socket傳送緩衝區未滿,就一直通知可寫事件。
- 優點是對於read操作比較簡單,只要有read事件就讀,讀多讀少都可以。
- 缺點是write相關操作較複雜,由於socket在空閒狀態傳送緩衝區一定是不滿的,故若socket一直在epoll wait列表中,則epoll會一直通知write事件,所以必須保證沒有資料要傳送的時候,要把socket的write事件從epoll wait列表中刪除。而在需要的時候在加入回去,這就是LT模式的最複雜部分。
ET模式的特點是:
- 若socket可讀,返回可讀事件
- 若開發者沒有把所有資料讀取完畢,epoll不會再次通知epoll read事件,也就是說存在一種隱患,如果開發者在讀到可讀事件時,如果沒有全部讀取所有資料,那麼可能導致epoll在也不會通知該socket的read事件。(其實這個問題並沒有聽上去難,參見下文)。
- 若傳送緩衝區未滿,epoll通知write事件,直到開發者填滿傳送緩衝區,epoll才會在下次傳送緩衝區由滿變成未滿時通知write事件。
- ET模式下,只有socket的狀態發生變化時才會通知,也就是讀取緩衝區由無資料到有資料時通知read事件,傳送緩衝區由滿變成未滿通知write事件。
- 缺點是epoll read事件觸發時,必須保證socket的讀取緩衝區資料全部讀完(事實上這個要求很容易達到)
- 優點:對於write事件,傳送緩衝區由滿到未滿時才會通知,若無資料可寫,忽略該事件,若有資料可寫,直接寫。Socket的write事件可以一直髮在epoll的wait列表。Man epoll中我們知道,當向socket寫資料,返回的值小於傳入的buffer大小或者write系統呼叫返回EWouldBlock時,表示傳送緩衝區已滿。
讓我們換一個角度來理解ET模式,事實上,epoll的ET模式其實就是socket io完全狀態機。
先來看epoll中read 的狀態圖:
當socket由不可讀變成可讀時,epoll的ET模式返回read 事件。對於read 事件,開發者需要保證把讀取緩衝區資料全部讀出,man epoll可知:
- Read系統呼叫返回EwouldBlock,表示讀取緩衝區資料全部讀出
- Read系統呼叫返回的數值小於傳入的buffer引數,表示讀取緩衝區全部讀出。
示例程式碼
int socket_impl_t:: handle_epoll_read () { if (is_open()) { int nread = 0; char recv_buffer[RECV_BUFFER_SIZE]; do { nread = ::read(m_fd, recv_buffer, sizeof(recv_buffer) - 1); if (nread > 0) { recv_buffer[nread] = '\0'; m_sc->handle_read(this, recv_buffer, size_t(nread)); if (nread < int(sizeof(recv_buffer) - 1)) { break;//! equal EWOULDBLOCK } } else if (0 == nread) //! eof { this->close(); return -1; } else { if (errno == EINTR) { continue; } else if (errno == EWOULDBLOCK) { break; } else { this->close(); return -1; } } } while(1); } return 0; }
再來看write 的狀態機:
需要讀者注意的是,socket模式是可寫的,因為傳送緩衝區初始時空的。故應用層有資料要傳送時,直接呼叫write系統呼叫傳送資料,若write系統呼叫返回EWouldBlock則表示socket變為不可寫,或者write系統呼叫返回的數值小於傳入的buffer引數的大小,這時需要把未傳送的資料暫存在應用層待發送列表中,等待epoll返回write事件,再繼續傳送應用層待發送列表中的資料,同樣若應用層待發送列表中的資料沒有一次性發完,那麼繼續等待epoll返回write事件,如此迴圈往復。所以可以反推得到如下結論,若應用層待發送列表有資料,則該socket一定是不可寫狀態,那麼這時候要傳送新資料直接追加到待發送列表中。若待發送列表為空,則表示socket為可寫狀態,則可以直接呼叫write系統呼叫傳送資料。總結如下:
- 當傳送資料時,若應用層待發送列表有資料,則將要傳送的資料追加到待發送列表中。否則直接呼叫write系統呼叫。
- Write系統呼叫傳送資料時,檢測write返回值,若返回數值>0且小於傳入的buffer引數大小,或返回EWouldBlock錯誤碼,表示,傳送緩衝區已滿,將未傳送的資料追加到待發送列表
- Epoll返回write事件後,檢測待發送列表是否有資料,若有資料,依次嘗試傳送指導資料全部發送完畢或者傳送緩衝區被填滿。
示例程式碼:
void socket_impl_t::send_impl(const string& src_buff_) { string buff_ = src_buff_; if (false == is_open() || m_sc->check_pre_send(this, buff_)) { return; } //! socket buff is full, cache the data if (false == m_send_buffer.empty()) { m_send_buffer.push_back(buff_); return; } string left_buff; int ret = do_send(buff_, left_buff); if (ret < 0) { this ->close(); } else if (ret > 0) { m_send_buffer.push_back(left_buff); } else { //! send ok m_sc->handle_write_completed(this); } } int socket_impl_t:: handle_epoll_write () { int ret = 0; string left_buff; if (false == is_open() || true == m_send_buffer.empty()) { return 0; } do { const string& msg = m_send_buffer.front(); ret = do_send(msg, left_buff); if (ret < 0) { this ->close(); return -1; } else if (ret > 0) { m_send_buffer.pop_front(); m_send_buffer.push_front(left_buff); return 0; } else { m_send_buffer.pop_front(); } } while (false == m_send_buffer.empty()); m_sc->handle_write_completed(this); return 0; }
總結
LT模式主要是讀操作比較簡單,但是對於ET模式並沒有優勢,因為將讀取緩衝區資料全部讀出並不是難事。而write操作,ET模式則流程非常的清晰,按照完全狀態機來理解和實現就變得非常容易。而LT模式的write操作則複雜多了,要頻繁的維護epoll的wail列表。
在程式碼編寫時,把epoll ET當成狀態機,當socket被建立完成(accept和connect系統呼叫返回的socket)時加入到epoll列表,之後就不用在從中刪除了。為什麼呢?man epoll中的FAQ告訴我們,當socket被close掉後,其自動從epoll中刪除。對於監聽socket簡單說幾點注意事項:
- 監聽socket的write事件忽略
- 監聽socket的read事件表示有新連線,呼叫accept接受連線,直到返回EWouldBlock。
- 對於Error事件,有些錯誤是可以接受的錯誤,比如檔案描述符用光的錯誤
示例程式碼:
int acceptor_impl_t::handle_epoll_read() { struct sockaddr_storage addr; socklen_t addrlen = sizeof(addr); int new_fd = -1; do { if ((new_fd = ::accept(m_listen_fd, (struct sockaddr *)&addr, &addrlen)) == -1) { if (errno == EWOULDBLOCK) { return 0; } else if (errno == EINTR || errno == EMFILE || errno == ECONNABORTED || errno == ENFILE || errno == EPERM || errno == ENOBUFS || errno == ENOMEM) { perror("accept");//! if too many open files occur, need to restart epoll event m_epoll->mod_fd(this); return 0; } perror("accept"); return -1; } socket_i* socket = create_socket(new_fd); socket->open(); } while (true); return 0; }
LT:可讀,一直通知;緩衝區可寫,一直通知;
ET:可讀,一次通知;由寫緩衝區滿到不滿,通知一次;
只有socket的狀態發生變化時才會通知,也就是讀取緩衝區由無資料到有資料時通知read事件,傳送緩衝區由滿變成未滿通知write事件。