Linux網絡編程 - C10K問題:高併發模型的設計初篇

C10K問題

這一篇,藉着 C10K 問題,系統地梳理一下高性能網絡編程的方法論。

C10K 問題是這樣的:如何在一臺物理機上同時服務 10000 個用戶?這裏 C 表示併發,10K 等於 10000。得益於操作系統、編程語言的發展,在現在的條件下,普通用戶使用 Java Netty、Libevent 等框架或庫就可以輕輕鬆鬆寫出支持併發超過 10000 的服務器端程序,甚至於經過優化之後可以達到十萬,乃至百萬的併發。

操作系統層面

C10K 問題本質上是一個操作系統問題,要在一臺主機上同時支持 1 萬個連接,意味着什麼呢? 需要考慮哪些方面?

  • 文件句柄數

每個客戶連接都代表一個文件描述符,一旦文件描述符不夠用了,新的連接就會被放棄,產生如下的錯誤:

Socket/File:Can't open so many files

在 Linux 下,單個進程打開的文件句柄數是有限制的,沒有經過修改的值一般都是 1024。這意味着最多可以服務的連接數上限只能是 1024。不過,我們可以對這個值進行修改,比如用 root 權限修改 /etc/sysctl.conf 文件,使得系統可用支持 10000 個描述符上限。

fs.file-max = 10000
net.ipv4.ip_conntrack_max = 10000
net.ipv4.netfilter.ip_conntrack_max = 10000
  • 系統內存

每個 TCP 連接佔用的資源除了連接套接字,還有一定的發送緩衝區和接收緩衝區。文稿裏有一段 shell 代碼,分別顯示了在 Linux 4.4.0 下發送緩衝區和接收緩衝區的值。

$cat   /proc/sys/net/ipv4/tcp_wmem
4096  16384  4194304

$ cat   /proc/sys/net/ipv4/tcp_rmem
4096  87380  6291456

這三個值分別表示了最小分配值、默認分配值和最大分配值按照默認分配值計算,一萬個連接需要的內存消耗爲

發送緩衝區: 16384*10000 = 160M bytes 

接收緩衝區: 87380*10000 = 880M bytes 

我們假設每個連接需要 128K 的緩衝區,那麼 1 萬個鏈接就需要大約 1.2G 的應用層緩衝,所以,支持 1 萬個併發連接,內存並不是一個巨大的瓶頸。

  • 網絡帶寬

假設 1 萬個連接,每個連接每秒傳輸大約 1KB 的數據,那麼帶寬需要 10000 x 1KB/s x8 = 80Mbps。這在今天的動輒萬兆網卡的時代簡直小菜一碟。

C10K 問題解決之道

通過前面在操作系統層面的資源分析,可以得出一個結論,C10K 問題是可以解決的。

但是,能解決並不意味着可以很好地解決。我們知道,在網絡編程中,涉及到頻繁的用戶態 - 內核態數據拷貝,設計不夠好的程序可能在低併發的情況下工作良好,一旦到了高併發情形,其性能可能呈現出指數級別的損失

舉一個例子,如果沒有考慮好 C10K 問題,一個基於 select 的經典程序可能在一臺服務器上可以很好處理 1000 的併發用戶,但是在性能 2 倍的服務器上,卻往往並不能很好地處理 2000 的併發用戶。要想解決 C10K 問題,就需要從兩個層面上來統籌考慮:

第一個層面,應用程序如何和操作系統配合,感知 I/O 事件發生,並調度處理在上萬個套接字上的 I/O 操作?前面講過的阻塞 I/O、非阻塞 I/O 討論的就是這方面的問題。

第二個層面,應用程序如何分配進程、線程資源來服務上萬個連接?這在接下來會詳細討論。

這兩個層面的組合就形成了解決 C10K 問題的幾種解法方案:

  • 阻塞 I/O + 進程

這種方式最爲簡單直接,每個連接通過 fork 派生一個子進程進行處理,因爲一個獨立的子進程負責處理了該連接所有的 I/O,所以即便是阻塞 I/O,多個連接之間也不會互相影響。但是這種方法效率不高,擴展性差,資源佔用率高。下面的僞代碼描述了使用阻塞 I/O,爲每個連接 fork 一個進程的做法:

do{
   accept connections
   fork for conneced connection fd
   process_run(fd)
}
  • 阻塞 I/O + 線程

進程模型佔用的資源太大,還有一種輕量級的資源模型,這就是線程。通過爲每個連接調用 pthread_create 創建一個單獨的線程,也可以達到上面使用進程的效果。

do{
   accept connections
   pthread_create for conneced connection fd
   thread_run(fd)
}while(true)

因爲線程的創建是比較消耗資源的,況且不是每個連接在每個時刻都需要服務,因此,我們可以預先通過創建一個線程池,並在多個連接中複用線程池來獲得某種效率上的提升。

  • 非阻塞 I/O +  readiness notification + 單線程

應用程序其實可以採取輪詢的方式來對保存的套接字集合進行挨個詢問,從而找出需要進行 I/O 處理的套接字,像文稿中給出的僞碼一樣,其中 is_readble 和 is_writeable 可以通過對套接字調用 read 或 write 操作來判斷。

for fd in fdset{
   if(is_readable(fd) == true){
     handle_read(fd)
   }else if(is_writeable(fd)==true){
     handle_write(fd)
   }
}

但這個方法有一個問題,如果這個 fdset 有一萬個之多,每次循環判斷都會消耗大量的 CPU 時間,而且極有可能在一個循環之內,沒有任何一個套接字準備好可讀,或者可寫。既然這樣,那麼幹脆讓操作系統來告訴我們哪個套接字可以讀,哪個套接字可以寫。在這個結果發生之前,我們把 CPU 的控制權交出去,讓操作系統來把寶貴的 CPU 時間調度給那些需要的進程,這就是 select、poll 這樣的 I/O 分發技術。

do {
    poller.dispatch()
    for fd in registered_fdset{
         if(is_readable(fd) == true){
           handle_read(fd)
         }else if(is_writeable(fd)==true){
           handle_write(fd)
     }
}while(ture)

但是,這樣的方法需要每次 dispatch 之後,對所有註冊的套接字進行逐個排查,效率並不是最高的。如果 dispatch 調用返回之後只提供有 I/O 事件或者 I/O 變化的套接字,這樣排查的效率不就高很多了麼?這就是前面我們講到的 epoll 設計。

do {
    poller.dispatch()
    for fd_event in active_event_set{
         if(is_readable_event(fd_event) == true){
           handle_read(fd_event)
         }else if(is_writeable_event(fd_event)==true){
           handle_write(fd_event)
     }
}while(ture)

Linux 是互聯網的基石,epoll 也就成爲了解決 C10K 問題的鑰匙。FreeBSD 上的 kqueue,Windows 上的 IOCP,Solaris 上的 /dev/poll,這些不同的操作系統提供的功能都是爲了解決 C10K 問題。

  • 非阻塞 I/O +  readiness notification + 多線程

前面的做法是所有的 I/O 事件都在一個線程裏分發,如果我們把線程引入進來,可以利用現代 CPU 多核的能力,讓每個核都可以作爲一個 I/O 分發器進行 I/O 事件的分發。這就是所謂的主從 reactor 模式。基於 epoll/poll/select 的 I/O 事件分發器可以叫做 reactor,也可以叫做事件驅動,或者事件輪詢(eventloop)。

我沒有把基於 select/poll 的所謂“level triggered”通知機制和基於 epoll 的“edge triggered”通知機制分開(C10K 問題總結裏是分開的),我覺得這只是 reactor 機制的實現高效性問題,而不是編程模式的巨大區別。

  • 異步 I/O+ 多線程

異步非阻塞 I/O 模型是一種更爲高效的方式,當調用結束之後,請求立即返回,由操作系統後臺完成對應的操作,當最終操作完成,就會產生一個信號,或者執行一個回調函數來完成 I/O 處理。這就涉及到了 Linux 下的 aio 機制,後面接着討論。

總之, 爲了解決 C10K 問題,需要重點考慮兩個方面的問題:

  • 如何和操作系統配合,感知 I/O 事件的發生?
  • 如何分配和使用進程、線程資源來服務上萬個連接?

基於這些組合,產生了一些通用的解決方法,在 Linux 下,解決高性能問題的利器是非阻塞 I/O 加上 epoll 機制,再利用多線程能力。

這裏再提了一個延伸性的問題,C10M問題?

有一個思路,10個處理epoll隊列的線程。 每個線程處理一個epoll隊列,每個epoll隊列容納最多100萬個socket。

 

溫故而知新 !

 

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