轉載自:http://blog.csdn.net/wangtaomtk/article/details/51811011
1 C10K問題
大家都知道互聯網的基礎就是網絡通信
,早期的互聯網可以說是一個小群體的集合。互聯網還不夠普及,用戶也不多。一台服務器同時在線100個用戶估計在當時已經算是大型應用了。所以並不存在什么C10K的難題。互聯網的爆發期應該是在www網站,瀏覽器,雅虎出現后。最早的互聯網稱之為Web1.0,互聯網大部分的使用場景是下載一個Html頁面,用戶在瀏覽器中查看網頁上的信息。這個時期也不存在C10K問題。
Web2.0時代到來后就不同了,一方面是普及率大大提高了,用戶群體幾何倍增長。另一方面是互聯網不再是單純的瀏覽萬維網網頁,逐漸開始進行交互,而且應用程序的邏輯也變的更復雜,從簡單的表單提交,到即時通信和在線實時互動。C10K的問題才體現出來了。每一個用戶都必須與服務器保持TCP連接才能進行實時的數據交互
。Facebook這樣的網站同一時間的並發TCP連接可能會過億。
騰訊QQ也是有C10K問題的,只不過他們是用了UDP這種原始的包交換協議來實現的,繞開了這個難題。當然過程肯定是痛苦的。如果當時有epoll技術,他們肯定會用TCP。后來的手機QQ,微信都采用TCP協議。
這時候問題就來了,最初的服務器都是基於進程/線程模型的
,新到來一個TCP連接,就需要分配1個進程(或者線程)。而進程又是操作系統最昂貴的資源
,一台機器無法創建很多進程。如果是C10K就要創建1萬個進程,那么操作系統是無法承受的
。如果是采用分布式系統,維持1億用戶在線需要10萬台服務器,成本巨大,也只有Facebook,Google,雅虎才有財力購買如此多的服務器。這就是C10K問題的本質
。
實際上當時也有異步模式,如:select/poll模型,這些技術都有一定的缺點,如selelct最大不能超過1024,poll沒有限制,但每次收到數據需要遍歷每一個連接查看哪個連接有數據請求。
2 解決方案
解決這一問題,主要思路有兩個:一個是對於每個連接處理分配一個獨立的進程/線程;另一個思路是用同一進程/線程來同時處理若干連接
。
2.1 每個進程/線程處理一個連接
這一思路最為直接。但是由於申請進程/線程會占用相當可觀的系統資源,同時對於多進程/線程的管理會對系統造成壓力,因此這種方案不具備良好的可擴展性。
因此,這一思路在服務器資源還沒有富裕到足夠程度的時候,是不可行的;即便資源足夠富裕,效率也不夠高。
問題:資源占用過多,可擴展性差。
2.2 每個進程/線程同時處理多個連接(IO多路復用)
-
傳統思路
最簡單的方法是循環挨個處理各個連接,每個連接對應一個 socket
,當所有 socket 都有數據的時候,這種方法是可行的。但是當應用讀取某個 socket 的文件數據不 ready 的時候,
整個應用會阻塞在這里等待該文件句柄
,即使別的文件句柄 ready,也無法往下處理。思路:直接循環處理多個連接。
問題:任一文件句柄的不成功會阻塞住整個應用。
-
select
要解決上面阻塞的問題,思路很簡單,如果我在讀取文件句柄之前,
先查下它的狀態,ready 了就進行處理,不 ready 就不進行處理
,這不就解決了這個問題了嘛?於是有了 select 方案。
用一個 fd_set 結構體來告訴內核同時監控多個文件句柄,當其中有文件句柄的狀態發生指定變化(例如某句柄由不可用變為可用)或超時,則調用返回
。之后應用可以使用 FD_ISSET 來逐個查看是哪個文件句柄的狀態發生了變化
。這樣做,
小規模的連接問題不大,但當連接數很多(文件句柄個數很多)的時候,逐個檢查狀態就很慢了
。因此,select 往往存在管理的句柄上限(FD_SETSIZE)
。同時,在使用上,因為只有一個字段記錄關注和發生事件,每次調用之前要重新初始化 fd_set 結構體
。int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
思路:有連接請求抵達了再檢查處理。
問題:句柄上限+重復初始化+逐個排查所有文件句柄狀態效率不高。
-
poll
poll 主要解決 select 的前兩個問題:
通過一個 pollfd 數組向內核傳遞需要關注的事件消除文件句柄上限
,同時使用不同字段分別標注關注事件和發生事件,來避免重復初始化
。int poll(struct pollfd *fds, nfds_t nfds, int timeout);
思路:設計新的數據結構提供使用效率。
問題:逐個排查所有文件句柄狀態效率不高。
-
epoll
既然逐個排查所有文件句柄狀態效率不高,很自然的,
如果調用返回的時候只給應用提供發生了狀態變化(很可能是數據 ready)的文件句柄
,進行排查的效率不就高多了么。epoll 采用了這種設計,適用於大規模的應用場景。
實驗表明,
當文件句柄數目超過 10 之后,epoll 性能將優於 select 和 poll;當文件句柄數目達到 10K 的時候,epoll 已經超過 select 和 poll 兩個數量級
。int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
思路:只返回狀態變化的文件句柄。
問題:依賴特定平台(Linux)。
因為Linux是互聯網企業中使用率最高的操作系統,
Epoll就成為C10K killer、高並發、高性能、異步非阻塞這些技術的代名詞了
。FreeBSD推出了kqueue,Linux推出了epoll,Windows推出了IOCP,Solaris推出了/dev/poll。這些操作系統提供的功能就是為了解決C10K問題
。epoll技術的編程模型就是異步非阻塞回調
,也可以叫做Reactor,事件驅動,事件輪循(EventLoop)。Nginx,libevent,Node.js這些就是Epoll時代的產物。select、poll、epoll具體原理詳解,請參見:《聊聊IO多路復用之select、poll、epoll詳解》。
-
libevent
由於epoll, kqueue, IOCP每個接口都有自己的特點,程序移植非常困難,於是需要對這些接口進行封裝,以讓它們易於使用和移植,其中libevent庫就是其中之一。跨平台,封裝底層平台的調用,提供統一的 API,但底層在不同平台上自動選擇合適的調用。
按照libevent的官方網站,libevent庫提供了以下功能:
當一個文件描述符的特定事件(如可讀,可寫或出錯)發生了,或一個定時事件發生了,libevent就會自動執行用戶指定的回調函數,來處理事件
。目前,libevent已支持以下接口/dev/poll, kqueue, event ports, select, poll 和 epoll。Libevent的內部事件機制完全是基於所使用的接口的
。因此libevent非常容易移植,也使它的擴展性非常容易。目前,libevent已在以下操作系統中編譯通過:Linux,BSD,Mac OS X,Solaris和Windows。使用libevent庫進行開發非常簡單,也很容易在各種unix平台上移植。一個簡單的使用libevent庫的程序如下:
3 協程(coroutine)
隨着技術的演進,epoll 已經可以較好的處理 C10K 問題,但是如果要進一步的擴展,例如支持 10M 規模的並發連接,原有的技術就無能為力了。
那么,新的瓶頸在哪里呢?
從前面的演化過程中,我們可以看到,根本的思路是要高效的去阻塞,讓 CPU 可以干核心的任務
。所以,千萬級並發實現的秘密:內核不是解決方案,而是問題所在!
這意味着:
不要讓內核執行所有繁重的任務。將數據包處理,內存管理,處理器調度等任務從內核轉移到應用程序高效地完成。讓Linux只處理控制層,數據層完全交給應用程序來處理。
當連接很多時,首先需要大量的進程/線程來做事
。同時系統中的應用進程/線程們可能大量的都處於 ready 狀態,需要系統去不斷的進行快速切換
,而我們知道系統上下文的切換是有代價的
。雖然現在 Linux 系統的調度算法已經設計的很高效了,但對於 10M 這樣大規模的場景仍然力有不足。
所以我們面臨的瓶頸有兩個,一個是進程/線程作為處理單元還是太厚重了;另一個是系統調度的代價太高了
。
很自然地,我們會想到,如果有一種更輕量級的進程/線程作為處理單元,而且它們的調度可以做到很快(最好不需要鎖)
,那就完美了。
這樣的技術現在在某些語言中已經有了一些實現,它們就是 coroutine(協程),或協作式例程
。具體的,Python、Lua 語言中的 coroutine(協程)模型,Go 語言中的 goroutine(Go 程)模型,都是類似的一個概念
。實際上,多種語言(甚至 C 語言)都可以實現類似的模型。
它們在實現上都是試圖用一組少量的線程來實現多個任務,一旦某個任務阻塞,則可能用同一線程繼續運行其他任務,避免大量上下文的切換
。每個協程所獨占的系統資源往往只有棧部分
。而且,各個協程之間的切換,往往是用戶通過代碼來顯式指定的(跟各種 callback 類似)
,不需要內核參與,可以很方便的實現異步。
這個技術本質上也是異步非阻塞技術,它是將事件回調進行了包裝,讓程序員看不到里面的事件循環
。程序員就像寫阻塞代碼一樣簡單。比如調用 client->recv() 等待接收數據時,就像阻塞代碼一樣寫。實際上是底層庫在執行recv時悄悄保存了一個狀態,比如代碼行數,局部變量的值。然后就跳回到EventLoop中了。什么時候真的數據到來時,它再把剛才保存的代碼行數,局部變量值取出來,又開始繼續執行。
這就是協程的本質。協程是異步非阻塞的另外一種展現形式
。Golang,Erlang,Lua協程都是這個模型。
3.1 同步阻塞
不知道大家看完協程是否感覺得到,實際上協程和同步阻塞是一樣的
。答案是的。所以協程也叫做用戶態進/用戶態線程
。區別就在於進程/線程是操作系統充當了EventLoop調度,而協程是自己用Epoll進行調度
。
協程的優點是它比系統線程開銷小,缺點是如果其中一個協程中有密集計算,其他的協程就不運行了。操作系統進程的缺點是開銷大,優點是無論代碼怎么寫,所有進程都可以並發運行。
Erlang解決了協程密集計算的問題,它基於自行開發VM,並不執行機器碼
。即使存在密集計算的場景,VM發現某個協程執行時間過長,也可以進行中止切換
。Golang由於是直接執行機器碼的,所以無法解決此問題。所以Golang要求用戶必須在密集計算的代碼中,自行Yield
。
實際上同步阻塞程序的性能並不差,它的效率很高,不會浪費資源。當進程發生阻塞后,操作系統會將它掛起,不會分配CPU。直到數據到達才會分配CPU。多進程只是開多了之后副作用太大,因為進程多了互相切換有開銷
。所以如果一個服務器程序只有1000左右的並發連接,同步阻塞模式是最好的
。
3.2 異步回調和協程哪個性能好
協程雖然是用戶態調度,實際上還是需要調度的,既然調度就會存在上下文切換
。所以協程雖然比操作系統進程性能要好,但總還是有額外消耗的。而異步回調是沒有切換開銷的,它等同於順序執行代碼
。所以異步回調程序的性能是要優於協程模型的。