高併發-網絡IO模型

高併發服務器編程經歷了從同步IO到異步IO,從多進程或多線程模型到事件驅動的演變,基於事件的併發編程依賴於操作系統提供的IO多路複用技術。這篇文章從什麼是IO多路複用談起,列舉基於事件的高併發服務器,並且對比了select,poll和epoll三種事件通知機制,libevent,libev和libuv三個事件框架,最後給出了分別使用select和epoll實現的echo server示例。

 

什麼是IO多路複用(I/O Multiplexing)

這個概念來自通信領域,指一個信道上傳輸多路信號的技術,在計算機裏表示使用一個線程監視多個描述符的就緒狀態。IO多路複用的技術是由操作系統提供的功能,比如POSIX標準下的select或linux特有的的epoll以及BSD特有的kqueue。首先向操作系統註冊一個描述符集合的可讀或可寫事件,如果某個(或某些)描述符就緒時,操作系統會通知。這樣,多個描述符就能在一個線程內併發通信。

這裏的描述符通常是socket,I/O多路複用也就是很多網絡連接(多路),共(復)用一個線程。

有哪些事件

以select和tcp socket爲例,所謂的可讀事件是指:

  1. socket內核接收緩衝區中的可用字節數大於或等於其低水位SO_RCVLOWAT;
  2. socket通信的對方關閉了連接,這個時候在緩衝區裏有個文件結束符EOF,此時讀操作將返回0
  3. 監聽socket的backlog隊列有已經完成三次握手的連接請求,可以調用accept
  4. socket上有未處理的錯誤,此時可以用getsockopt來讀取和清除該錯誤。

所謂可寫事件,則是指:

  1. socket的內核發送緩衝區的可用字節數大於或等於其低水位SO_SNDLOWAIT;
  2. socket的寫端被關閉,繼續寫會收到SIGPIPE信號;
  3. 非阻塞模式下,connect返回之後,發起連接成功或失敗;
  4. socket上有未處理的錯誤,此時可以用getsockopt來讀取和清除該錯誤。

select vs poll vs epoll

select

select是一種古老穩定的IO多路複用技術,最大的優點是兼容性好,幾乎所有的平臺都支持,它的缺點是:

  • 當可讀或可寫事件發生時,需要通過手動遍歷所有的描述符,判斷FD_ISSET位是否被設置的方式來找出是哪個描述符就緒
  • 描述符集合數有上限,由FD_SETSIZE定義,linux上是1024
  • select會修改fd_sets,導致它們不能被複用。每次調用select都需要重新創建描述符集合
  • 不能在另一個線程修改描述符,比如close

poll

poll是爲了解決select的缺陷而發明的,它有以下優點:

  • 沒有描述符數的限制
  • 不會修改pollfd,多次poll可以直接複用描述符集合

它同樣包括以下缺點:

  • 需遍歷找出觸發事件的描述符
  • 處於監聽的描述符不能被close

epoll

epoll是linux上特有的IO多路複用技術,它的內部機制與select或poll很不同,相比之下,它具有很多性能和功能方面的優勢:

  • 返回觸發事件的描述符集合,不需要遍歷
  • 可以在被監視的事件上綁定額外的數據
  • 任何時候都可以移除或加入socket
  • 支持edge triggering 模式

但它並不是poll的改進版,相比poll它也有缺點:

  • 修改事件的flag需要調用epoll_ctl,會帶來用戶態和內核態切換的開銷

儘管epoll很高效,但並不是任何場景都適合,在以下場景應該使用poll而不是epoll:

  • 不只在linux上使用
  • socket數不超過1000
  • socket數超過1000,這些連接的生命都很短暫

那麼問題來了,epoll是怎麼實現的?這是一個很好的面試題。

事件驅動與多線程對比

服務端程序的特點是IO操作頻繁,大部分時間都在等待IO上。 多線程不僅佔用更多的內存,而且線程切換也會帶來一定的開銷。另一方面,多個線程修改共享的數據會產生競爭條件,需要加鎖,這就容易導致另一個嚴重的問題:死鎖。

使用事件驅動則只有一個線程,沒有線程切換的開銷,效率高,並且不用考慮競爭條件。redis就是使用單進程單線程模型,所有的命令自然就是原子的。

這裏有一個有趣的問題,如果大部分進程阻塞在socket I/O上,操作系統會繼續調度一直處於阻塞狀態的線程嗎?

基於事件的應用舉例

最成功的例子莫過於nginx。nginx內部封裝了poll,epoll等各種事件模型,它會自動選擇當前平臺支持的最高效的方式,在linux上是epoll,也可以通過use指令手動指定。nginx最擅長的是做反向代理服務器,即轉發用戶的請求到後端服務器。一個work進程在等待後端返回時並不會被阻塞,而是繼續處理其他請求,當後端返回數據時,該事件自動觸發回調函數進行處理。

同樣的,redis也實現了自己的事件框架。除此之外,memcached使用了libevent,nodejs使用libuv。

以上都是服務器端程序,作爲客戶端的爬蟲框架scrapy,底層使用了twisted,同樣是由事件驅動的。

如今,事件驅動的網絡應用已經取得了巨大的成功。事實上,單進程單線程的事件驅動併發編程並不能充分利用多核CPU,因此很多情況下都是混合使用,比如nginx使用多個worker進程,每個進程內部是基於事件的,而memcached使用多線程,每個線程內部又是基於事件的。

libev vs libevent vs libuv

這些庫爲不同平臺的事件模型編程封裝了統一的接口。

libevent

libevent封裝了現有的polling方法,使你只需要寫一遍代碼就可以在很多系統上編譯運行。

libev

libev最初是爲了解決libevent中的設計問題而開發的,它的設計哲學是”do one thing only”。與libevent相比,libev不使用全局變量,可以安全的在多線程環境使用;不同的事件類型使用不同的數據結構,比如有I/O,時間和信號等類型;移除了額外的組件,比如http服務器和DNS客戶端。因此,libev是一個輕量的庫。

libuv

libuv則是專門爲node.js開發,在libev的基礎上加入了對windows的支持,具有很好的跨平臺兼容性。

無論是什麼庫,底層都是用了由操作系統提供的系統調用,比如select或epoll。

 

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章