併發編程導論是對於分佈式計算-併發編程 https://url.wx-coder.cn/Yagu8 系列的總結與概括。java
隨着硬件性能的迅猛發展與大數據時代的來臨,併發編程日益成爲編程中不可忽略的重要組成部分。簡單定義來看,若是執行單元的邏輯控制流在時間上重疊,那它們就是併發(Concurrent)的。併發編程復興的主要驅動力來自於所謂的「多核危機」。正如摩爾定律所預言的那樣,芯片性能仍在不斷提升,但相比加快 CPU 的速度,計算機正在向多核化方向發展。正如 Herb Sutter 所說,「免費午飯的時代已然終結」。爲了讓代碼運行得更快,單純依靠更快的硬件已沒法知足要求,並行和分佈式計算是現代應用程序的主要內容,咱們須要利用多個核心或多臺機器來加速應用程序或大規模運行它們。node
併發編程是很是普遍的概念,其向下依賴於操做系統、存儲等,與分佈式系統、微服務等,而又會具體落地於 Java 併發編程、Go 併發編程、JavaScript 異步編程等領域。雲計算承諾在全部維度上(內存、計算、存儲等)實現無限的可擴展性,併發編程及其相關理論也是咱們構建大規模分佈式應用的基礎。git
本節主要討論併發編程理論相關的內容,能夠參閱 [Java 併發編程 https://url.wx-coder.cn/72vCj 、Go 併發編程 https://url.wx-coder.cn/FO9EX 等了解具體編程語言中併發編程的實踐,能夠參閱微服務實戰 https://url.wx-coder.cn/7KZ2i 或者關係型數據庫理論 https://url.wx-coder.cn/DJNQn 瞭解併發編程在實際系統中的應用。程序員
併發就是可同時發起執行的程序,指程序的邏輯結構;並行就是能夠在支持並行的硬件上執行的併發程序,指程序的運⾏狀態。換句話說,併發程序表明了全部能夠實現併發行爲的程序,這是一個比較寬泛的概念,並行程序也只是他的一個子集。併發是並⾏的必要條件;但併發不是並⾏的充分條件。併發只是更符合現實問題本質的表達,目的是簡化代碼邏輯,⽽不是使程序運⾏更快。要是程序運⾏更快必是併發程序加多核並⾏。github
簡言之,併發是同一時間應對(dealing with)多件事情的能力;並行是同一時間動手作(doing)多件事情的能力。web
併發是問題域中的概念——程序須要被設計成可以處理多個同時(或者幾乎同時)發生的事件;一個併發程序含有多個邏輯上的獨立執行塊,它們能夠獨立地並行執行,也能夠串行執行。而並行則是方法域中的概念——經過將問題中的多個部分並行執行,來加速解決問題。一個並行程序解決問題的速度每每比一個串行程序快得多,由於其能夠同時執行整個任務的多個部分。並行程序可能有多個獨立執行塊,也可能僅有一個。算法
具體而言,Redis 會是一個很好地區分併發和並行的例子。Redis 自己是一個單線程的數據庫,可是能夠經過多路複用與事件循環的方式來提供併發地 IO 服務。這是由於多核並行本質上會有很大的一個同步的代價,特別是在鎖或者信號量的狀況下。所以,Redis 利用了單線程的事件循環來保證一系列的原子操做,從而保證了即便在高併發的狀況下也能達到幾乎零消耗的同步。再引用下 Rob Pike 的描述:數據庫
A single-threaded program can definitely provides concurrency at the IO level by using an IO (de)multiplexing mechanism and an event loop (which is what Redis does).編程
從 20 世紀 60 年代初期出現時間共享以來,計算機系統中就開始有了對併發執行的支持;傳統意義上,這種併發執行只是模擬出來的,是經過使一臺計算機在它正在執行的進程間快速切換的方式實現的,這種配置稱爲單處理器系統。從 20 世紀 80 年代開始,多處理器系統,即由單操做系統內核控制的多處理器組成的系統採用了多核處理器與超線程(HyperThreading)等技術容許咱們實現真正的並行。多核處理器是將多個 CPU 集成到一個集成電路芯片上:windows
超線程,有時稱爲同時多線程(simultaneous multi-threading),是一項容許一個 CPU 執行多個控制流的技術。它涉及 CPU 某些硬件有多個備份,好比程序計數器和寄存器文件;而其餘的硬件部分只有一份,好比執行浮點算術運算的單元。常規的處理器須要大約 20 000 個時鐘週期作不一樣線程間的轉換,而超線程的處理器能夠在單個週期的基礎上決定要執行哪個線程。這使得 CPU 可以更好地利用它的處理資源。例如,假設一個線程必須等到某些數據被裝載到高速緩存中,那 CPU 就能夠繼續去執行另外一個線程。
在較低的抽象層次上,現代處理器能夠同時執行多條指令的屬性稱爲指令級並行。實每條指令從開始到結束須要長得多的時間,大約 20 個或者更多的週期,可是處理器使用了很是多的聰明技巧來同時處理多達 100 條的指令。在流水線中,將執行一條指令所須要的活動劃分紅不一樣的步驟,將處理器的硬件組織成一系列的階段,每一個階段執行一個步驟。這些階段能夠並行地操做,用來處理不一樣指令的不一樣部分。咱們會看到一個至關簡單的硬件設計,它可以達到接近於一個時鐘週期一條指令的執行速率。若是處理器能夠達到比一個週期一條指令更快的執行速率,就稱之爲超標量(Super Scalar)處理器。
在最低層次上,許多現代處理器擁有特殊的硬件,容許一條指令產生多個能夠並行執行的操做,這種方式稱爲單指令、多數據,即 SIMD 並行。例如,較新的 Intel 和 AMD 處理器都具備並行地對 4 對單精度浮點數(C 數據類型 float)作加法的指令。
如前文所述,現代計算機一般有兩個或者更多的 CPU,一些 CPU 還有多個核;其容許多個線程同時運行,每一個 CPU 在某個時間片內運行其中的一個線程。在存儲管理 https://parg.co/Z47 一節中咱們介紹了計算機系統中的不一樣的存儲類別:
每一個 CPU 包含多個寄存器,這些寄存器本質上就是 CPU 內存;CPU 在寄存器中執行操做的速度會比在主內存中操做快很是多。每一個 CPU 可能還擁有 CPU 緩存層,CPU 訪問緩存層的速度比訪問主內存塊不少,可是卻比訪問寄存器要慢。計算機還包括主內存(RAM),全部的 CPU 均可以訪問這個主內存,主內存通常都比 CPU 緩存大不少,但速度要比 CPU 緩存慢。當一個 CPU 須要訪問主內存的時候,會把主內存中的部分數據讀取到 CPU 緩存,甚至進一步把緩存中的部分數據讀取到內部的寄存器,而後對其進行操做。當 CPU 須要向主內存寫數據的時候,會將寄存器中的數據寫入緩存,某些時候會將數據從緩存刷入主內存。不管從緩存讀仍是寫數據,都沒有必要一次性所有讀出或者寫入,而是僅對部分數據進行操做。
併發編程中的問題,每每源於緩存致使的可見性問題、線程切換致使的原子性問題以及編譯優化帶來的有序性問題。以 Java 虛擬機爲例,每一個線程都擁有一個屬於本身的線程棧(調用棧),隨着線程代碼的執行,調用棧會隨之改變。線程棧中包含每一個正在執行的方法的局部變量。每一個線程只能訪問屬於本身的棧。調用棧中的局部變量,只有建立這個棧的線程才能夠訪問,其餘線程都不能訪問。即便兩個線程在執行一段相同的代碼,這兩個線程也會在屬於各自的線程棧中建立局部變量。所以,每一個線程擁有屬於本身的局部變量。全部基本類型的局部變量所有存放在線程棧中,對其餘線程不可見。一個線程能夠把基本類型拷貝到其餘線程,可是不能共享給其餘線程,而不管哪一個線程建立的對象都存放在堆中。
所謂的可見性,便是一個線程對共享變量的修改,另一個線程可以馬上看到。單核時代,全部的線程都是直接操做單個 CPU 的數據,某個線程對緩存的寫對另一個線程來講必定是可見的;譬以下圖中,若是線程 B 在線程 A 更新了變量值以後進行訪問,那麼得到的確定是變量 V 的最新值。多核時代,每顆 CPU 都有本身的緩存,共享變量存儲在主內存。運行在某個 CPU 中的線程將共享變量讀取到本身的 CPU 緩存。在 CPU 緩存中,修改了共享對象的值,因爲 CPU 並未將緩存中的數據刷回主內存,致使對共享變量的修改對於在另外一個 CPU 中運行的線程而言是不可見的。這樣每一個線程都會擁有一份屬於本身的共享變量的拷貝,分別存於各自對應的 CPU 緩存中。
可見性問題最經典的案例便是併發加操做,以下兩個線程同時在更新變量 test 的 count 屬性域的值,第一次都會將 count=0 讀到各自的 CPU 緩存裏,執行完 count+=1
以後,各自 CPU 緩存裏的值都是 1,同時寫入內存後,咱們會發現內存中是 1,而不是咱們指望的 2。以後因爲各自的 CPU 緩存裏都有了 count 的值,兩個線程都是基於 CPU 緩存裏的 count 值來計算,因此致使最終 count 的值都是小於 20000 的。
Thread th1 = new Thread(()->{ test.add10K(); }); Thread th2 = new Thread(()->{ test.add10K(); }); // 每一個線程中對相同對象執行加操做 count += 1; 複製代碼
在 Java 中,若是多個線程共享一個對象,而且沒有合理的使用 volatile 聲明和線程同步,一個線程更新共享對象後,另外一個線程可能沒法取到對象的最新值。當一個共享變量被 volatile 修飾時,它會保證修改的值會當即被更新到主存,當有其餘線程須要讀取時,它會去內存中讀取新值。經過 synchronized 和 Lock 也可以保證可見性,synchronized 和 Lock 能保證同一時刻只有一個線程獲取鎖而後執行同步代碼,而且在釋放鎖以前會將對變量的修改刷新到主存當中。所以能夠保證可見性。
所謂的原子性,就是一個或者多個操做在 CPU 執行的過程當中不被中斷的特性,CPU 能保證的原子操做是 CPU 指令級別的,而不是高級語言的操做符。咱們在編程語言中部分看似原子操做的指令,在被編譯到彙編以後每每會變成多個操做:
i++
# 編譯成彙編以後就是:
# 讀取當前變量 i 並把它賦值給一個臨時寄存器;
movl i(%rip), %eax
# 給臨時寄存器+1;
addl $1, %eax
# 把 eax 的新值寫回內存
movl %eax, i(%rip)
複製代碼
咱們能夠清楚看到 C 代碼只須要一句,但編譯成彙編卻須要三步(這裏不考慮編譯器優化,實際上經過編譯器優化能夠將這三條彙編指令合併成一條)。也就是說,只有簡單的讀取、賦值(並且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操做)纔是原子操做。按照原子操做解決同步問題方式:依靠處理器原語支持把上述三條指令合三爲一,當作一條指令來執行,保證在執行過程當中不會被打斷而且多線程併發也不會受到干擾。這樣同步問題迎刃而解,這也就是所謂的原子操做。但處理器沒有義務爲任意代碼片斷提供原子性操做,尤爲是咱們的臨界區資源十分龐大甚至大小不肯定,處理器沒有必要或是很難提供原子性支持,此時每每須要依賴於鎖來保證原子性。
對應原子操做/事務在 Java 中,對基本數據類型的變量的讀取和賦值操做是原子性操做,即這些操做是不可被中斷的,要麼執行,要麼不執行。Java 內存模型只保證了基本讀取和賦值是原子性操做,若是要實現更大範圍操做的原子性,能夠經過 synchronized 和 Lock 來實現。因爲 synchronized 和 Lock 可以保證任一時刻只有一個線程執行該代碼塊,那麼天然就不存在原子性問題了,從而保證了原子性。
顧名思義,有序性指的是程序按照代碼的前後順序執行。代碼重排是指編譯器對用戶代碼進行優化以提升代碼的執行效率,優化前提是不改變代碼的結果,即優化先後代碼執行結果必須相同。
譬如:
int a = 1, b = 2, c = 3; void test() { a = b + 1; b = c + 1; c = a + b; } 複製代碼
在 gcc 下的彙編代碼 test 函數體代碼以下,其中編譯參數: -O0
movl b(%rip), %eax
addl $1, %eax movl %eax, a(%rip) movl c(%rip), %eax addl $1, %eax movl %eax, b(%rip) movl a(%rip), %edx movl b(%rip), %eax addl %edx, %eax movl %eax, c(%rip) 複製代碼
編譯參數:-O3
movl b(%rip), %eax ;將b讀入eax寄存器
leal 1(%rax), %edx ;將b+1寫入edx寄存器
movl c(%rip), %eax ;將c讀入eax
movl %edx, a(%rip) ;將edx寫入a
addl $1, %eax ;將eax+1 movl %eax, b(%rip) ;將eax寫入b addl %edx, %eax ;將eax+edx movl %eax, c(%rip) ;將eax寫入c 複製代碼
在 Java 中與有序性相關的經典問題就是單例模式,譬如咱們會採用靜態函數來獲取某個對象的實例,而且使用 synchronized 加鎖來保證只有單線程可以觸發建立,其餘線程則是直接獲取到實例對象。
if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } 複製代碼
不過雖然咱們指望的對象建立的過程是:內存分配、初始化對象、將對象引用賦值給成員變量,可是實際狀況下通過優化的代碼每每會首先進行變量賦值,然後進行對象初始化。假設線程 A 先執行 getInstance() 方法,當執行完指令 2 時剛好發生了線程切換,切換到了線程 B 上;若是此時線程 B 也執行 getInstance() 方法,那麼線程 B 在執行第一個判斷時會發現 instance != null
,因此直接返回 instance,而此時的 instance 是沒有初始化過的,若是咱們這個時候訪問 instance 的成員變量就可能觸發空指針異常。
多處理器同時訪問共享主存,每一個處理器都要對讀寫進行從新排序,一旦數據更新,就須要同步更新到主存上 (這裏並不要求處理器緩存更新以後馬上更新主存)。在這種狀況下,代碼和指令重排,再加上緩存延遲指令結果輸出致使共享變量被修改的順序發生了變化,使得程序的行爲變得沒法預測。爲了解決這種不可預測的行爲,處理器提供一組機器指令來確保指令的順序要求,它告訴處理器在繼續執行前提交全部還沒有處理的載入和存儲指令。一樣的也能夠要求編譯器不要對給定點以及周圍指令序列進行重排。這些確保順序的指令稱爲內存屏障。具體的確保措施在程序語言級別的體現就是內存模型的定義。
POSIX、C++、Java 都有各自的共享內存模型,實現上並無什麼差別,只是在一些細節上稍有不一樣。這裏所說的內存模型並不是是指內存布 局,特指內存、Cache、CPU、寫緩衝區、寄存器以及其餘的硬件和編譯器優化的交互時對讀寫指令操做提供保護手段以確保讀寫序。將這些繁雜因素能夠籠 統的概括爲兩個方面:重排和緩存,即上文所說的代碼重排、指令重排和 CPU Cache。簡單的說內存屏障作了兩件事情:拒絕重排,更新緩存。
C++11 提供一組用戶 API std::memory_order 來指導處理器讀寫順序。Java 使用 happens-before 規則來屏蔽具體細節保證,指導 JVM 在指令生成的過程當中穿插屏障指令。內存屏障也能夠在編譯期間指示對指令或者包括周圍指令序列不進行優化,稱之爲編譯器屏障,至關於輕量級內存屏障,它的工做一樣重要,由於它在編譯期指導編譯器優化。屏障的實現稍微複雜一些,咱們使用一組抽象的假想指令來描述內存屏障的工做原理。使用 MB_R、MB_W、MB 來抽象處理器指令爲宏:
這些屏障指令在單核處理器上一樣有效,由於單處理器雖不涉及多處理器間數據同步問題,但指令重排和緩存仍然影響數據的正確同步。指令重排是很是底層的且實 現效果差別很是大,尤爲是不一樣體系架構對內存屏障的支持程度,甚至在不支持指令重排的體系架構中根本沒必要使用屏障指令。具體如何使用這些屏障指令是支持的 平臺、編譯器或虛擬機要實現的,咱們只須要使用這些實現的 API(指的是各類併發關鍵字、鎖、以及重入性等,下節詳細介紹)。這裏的目的只是爲了幫助更好 的理解內存屏障的工做原理。
內存屏障的意義重大,是確保正確併發的關鍵。經過正確的設置內存屏障能夠確保指令按照咱們指望的順序執行。這裏須要注意的是內存屏蔽只應該做用於須要同步的指令或者還能夠包含周圍指令的片斷。若是用來同步全部指令,目前絕大多數處理器架構的設計就會毫無心義。
Java 內存模型着眼於描述 Java 中的線程是如何與內存進行交互,以及單線程中代碼執行的順序等,並提供了一系列基礎的併發語義原則;最先的 Java 內存模型於 1995 年提出,致力於解決不一樣處理器/操做系統中線程交互/同步的問題,規定和指引 Java 程序在不一樣的內存架構、CPU 和操做系統間有肯定性地行爲。在 Java 5 版本以前,JMM 並不完善,彼時多線程每每會在共享內存中讀取到不少奇怪的數據;譬如,某個線程沒法看到其餘線程對共享變量寫入的值,或者由於指令重排序的問題,某個線程可能看到其餘線程奇怪的操做步驟。
Java 內存模型具有一些先天的「有序性」,即不須要經過任何手段就可以獲得保證的有序性,這個一般也稱爲 happens-before 原則。若是兩個操做的執行次序沒法從 happens-before 原則推導出來,那麼它們就不能保證它們的有序性,虛擬機能夠隨意地對它們進行重排序。
Java 內存模型對一個線程所作的變更能被其它線程可見提供了保證,它們之間是先行發生關係。
對於程序次序規則來講,就是一段程序代碼的執行在單個線程中看起來是有序的。注意,雖然這條規則中提到「書寫在前面的操做先行發生於書寫在後面的操做」,這個應該是程序看起來執行的順序是按照代碼順序執行的,由於虛擬機可能會對程序代碼進行指令重排序。雖然進行重排序,可是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。所以,在單個線程中,程序執行看起來是有序執行的,這一點要注意理解。事實上,這個規則是用來保證程序在單線程中執行結果的正確性,但沒法保證程序在多線程中執行的正確性。
在未配置 OS 的系統中,程序的執行方式是順序執行,即必須在一個程序執行完後,才容許另外一個程序執行;在多道程序環境下,則容許多個程序併發執行。程序的這兩種執行方式間有着顯著的不一樣。也正是程序併發執行時的這種特徵,才致使了在操做系統中引入進程的概念。進程是資源分配的基本單位,線程是資源調度的基本單位。
早期的操做系統基於進程來調度 CPU,不一樣進程間是不共享內存空間的,因此進程要作任務切換就要切換內存映射地址,而一個進程建立的全部線程,都是共享一個內存空間的,因此線程作任務切換成本就很低了。現代的操做系統都基於更輕量的線程來調度,如今咱們提到的「任務切換」都是指「線程切換」。
進程是操做系統對一個正在運行的程序的一種抽象,在一個系統上能夠同時運行多個進程,而每一個進程都好像在獨佔地使用硬件。所謂的併發運行,則是說一個進程的指令和另外一個進程的指令是交錯執行的。不管是在單核仍是多核系統中,能夠經過處理器在進程間切換,來實現單個 CPU 看上去像是在併發地執行多個進程。操做系統實現這種交錯執行的機制稱爲上下文切換。
操做系統保持跟蹤進程運行所需的全部狀態信息。這種狀態,也就是上下文,它包括許多信息,例如 PC 和寄存器文件的當前值,以及主存的內容。在任何一個時刻,單處理器系統都只能執行一個進程的代碼。當操做系統決定要把控制權從當前進程轉移到某個新進程時,就會進行上下文切換,即保存當前進程的上下文、恢復新進程的上下文,而後將控制權傳遞到新進程。新進程就會從上次中止的地方開始。
在虛擬存儲管理 https://url.wx-coder.cn/PeNqS 一節中,咱們介紹過它爲每一個進程提供了一個假象,即每一個進程都在獨佔地使用主存。每一個進程看到的是一致的存儲器,稱爲虛擬地址空間。其虛擬地址空間最上面的區域是爲操做系統中的代碼和數據保留的,這對全部進程來講都是同樣的;地址空間的底部區域存放用戶進程定義的代碼和數據。
在現代系統中,一個進程實際上能夠由多個稱爲線程的執行單元組成,每一個線程都運行在進程的上下文中,並共享一樣的代碼和全局數據。進程的個體間是徹底獨立的,而線程間是彼此依存的。多進程環境中,任何一個進程的終止,不會影響到其餘進程。而多線程環境中,父線程終止,所有子線程被迫終止(沒有了資源)。而任何一個子線程終止通常不會影響其餘線程,除非子線程執行了 exit()
系統調用。任何一個子線程執行 exit()
,所有線程同時滅亡。多線程程序中至少有一個主線程,而這個主線程其實就是有 main 函數的進程。它是整個程序的進程,全部線程都是它的子線程。咱們一般把具備多線程的主進程稱之爲主線程。
線程共享的環境包括:進程代碼段、進程的公有數據、進程打開的文件描述符、信號的處理器、進程的當前目錄、進程用戶 ID 與進程組 ID 等,利用這些共享的數據,線程很容易的實現相互之間的通信。進程擁有這許多共性的同時,還擁有本身的個性,並以此實現併發性:
在 Linux 2.4 版之前,線程的實現和管理方式就是徹底按照進程方式實現的;在 Linux 2.6 以前,內核並不支持線程的概念,僅經過輕量級進程(lightweight process)模擬線程,一個用戶線程對應一個內核線程(內核輕量級進程),這種模型最大的特色是線程調度由內核完成了,而其餘線程操做(同步、取消)等都是核外的線程庫(LinuxThread)函數完成的。爲了徹底兼容 Posix 標準,Linux 2.6 首先對內核進行了改進,引入了線程組的概念(仍然用輕量級進程表示線程),有了這個概念就能夠將一組線程組織稱爲一個進程,不過內核並無準備特別的調度算法或是定義特別的數據結構來表徵線程;相反,線程僅僅被視爲一個與其餘進程(概念上應該是線程)共享某些資源的進程(概念上應該是線程)。在實現上主要的改變就是在 task_struct 中加入 tgid 字段,這個字段就是用於表示線程組 id 的字段。在用戶線程庫方面,也使用 NPTL 代替 LinuxThread。不一樣調度模型上仍然採用 1 對 1
模型。
進程的實現是調用 fork 系統調用:pid_t fork(void);
,線程的實現是調用 clone 系統調用:int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...)
。與標準 fork()
相比,線程帶來的開銷很是小,內核無需單獨複製進程的內存空間或文件描寫敘述符等等。這就節省了大量的 CPU 時間,使得線程建立比新進程建立快上十到一百倍,可以大量使用線程而無需太過於操心帶來的 CPU 或內存不足。不管是 fork、vfork、kthread_create 最後都是要調用 do_fork,而 do_fork 就是根據不一樣的函數參數,對一個進程所需的資源進行分配。
線程池的大小依賴於所執行任務的特性以及程序運行的環境,線程池的大小應該應採起可配置的方式(寫入配置文件)或者根據可用的 CPU 數量 Runtime.availableProcessors()
來進行設置,其中 Ncpu 表示可用 CPU 數量,Nthreads 表示線程池工做線程數量,Ucpu 表示 CPU 的利用率 0≤ Ucpu ≤1
;W 表示資源等待時間,C 表示任務計算時間;Rtotal 表示有限資源的總量,Rper 表示每一個任務須要的資源數量。
對於對於純 CPU 計算的任務-即不依賴阻塞資源(外部接口調用)以及有限資源(線程池)的 CPU 密集型(compute-intensive)任務線程池的大小能夠設置爲:Nthreads = Ncpu+1
。
若是執行的任務除了 cpu 計算還包括一些外部接口調用或其餘會阻塞的計算,那麼線程池的大小能夠設置爲 Nthreads = Ncpu - Ucpu -(1 + W / C)
。能夠看出對於 IO 等待時間長於任務計算時間的狀況,W/C
大於 1,假設 cpu 利用率是 100%,那麼 W/C
結果越大,須要的工做線程也越多,由於若是沒有足夠的線程則會形成任務隊列迅速膨脹。
若是任務依賴於一些有限的資源好比內存,文件句柄,數據庫鏈接等等,那麼線程池最大能夠設置爲 Nthreads ≤ Rtotal/Rper
。
協程是用戶模式下的輕量級線程,最準確的名字應該叫用戶空間線程(User Space Thread),在不一樣的領域中也有不一樣的叫法,譬如纖程(Fiber)、綠色線程(Green Thread)等等。操做系統內核對協程一無所知,協程的調度徹底有應用程序來控制,操做系統無論這部分的調度;一個線程能夠包含一個或多個協程,協程擁有本身的寄存器上下文和棧,協程調度切換時,將寄存器上細紋和棧保存起來,在切換回來時恢復先前保運的寄存上下文和棧。
好比 Golang 裏的 go 關鍵字其實就是負責開啓一個 Fiber,讓 func 邏輯跑在上面。而這一切都是發生的用戶態上,沒有發生在內核態上,也就是說沒有 ContextSwitch 上的開銷。協程的實現庫中筆者較爲經常使用的譬如 Go Routine、node-fibers、Java-Quasar 等。
Go 的棧是動態分配大小的,隨着存儲數據的數量而增加和收縮。每一個新建的 Goroutine 只有大約 4KB 的棧。每一個棧只有 4KB,那麼在一個 1GB 的 RAM 上,咱們就能夠有 256 萬個 Goroutine 了,相對於 Java 中每一個線程的 1MB,這是巨大的提高。Golang 實現了本身的調度器,容許衆多的 Goroutines 運行在相同的 OS 線程上。就算 Go 會運行與內核相同的上下文切換,可是它可以避免切換至 ring-0 以運行內核,而後再切換回來,這樣就會節省大量的時間。可是,這只是紙面上的分析。爲了支持上百萬的 Goroutines,Go 須要完成更復雜的事情。
要支持真正的大併發須要另一項優化:當你知道線程可以作有用的工做時,纔去調度它。若是你運行大量線程的話,其實只有少許的線程會執行有用的工做。Go 經過集成通道(channel)和調度器(scheduler)來實現這一點。若是某個 Goroutine 在一個空的通道上等待,那麼調度器會看到這一點而且不會運行該 Goroutine。Go 更近一步,將大多數空閒的線程都放到它的操做系統線程上。經過這種方式,活躍的 Goroutine(預期數量會少得多)會在同一個線程上調度執行,而數以百萬計的大多數休眠的 Goroutine 會單獨處理。這樣有助於下降延遲。
除非 Java 增長語言特性,容許調度器進行觀察,不然的話,是不可能支持智能調度的。可是,你能夠在「用戶空間」中構建運行時調度器,它可以感知線程什麼時候可以執行工做。這構成了像 Akka 這種類型的框架的基礎,它可以支持上百萬的 Actor。
涉及多線程程序涉及的時候常常會出現一些使人難以思議的事情,用堆和棧分配一個變量可能在之後的執行中產生意想不到的結果,而這個結果的表現就是內存的非法被訪問,致使內存的內容被更改。在一個進程的線程共享堆區,而進程中的線程各自維持本身堆棧。 在 Windows 等平臺上,不一樣線程缺省使用同一個堆,因此用 C 的 malloc (或者 windows 的 GlobalAlloc)分配內存的時候是使用了同步保護的。若是沒有同步保護,在兩個線程同時執行內存操做的時候會產生競爭條件,可能致使堆內內存管理混亂。好比兩個線程分配了統一塊內存地址,空閒鏈表指針錯誤等。
最多見的進程/線程的同步方法有互斥鎖(或稱互斥量 Mutex),讀寫鎖(rdlock),條件變量(cond),信號量(Semophore)等;在 Windows 系統中,臨界區(Critical Section)和事件對象(Event)也是經常使用的同步方法。總結而言,同步問題基本的就是解決原子性與可見性/一致性這兩個問題,其基本手段就是基於鎖,所以又能夠分爲三個方面:指令串行化/臨界資源管理/鎖、數據一致性/數據可見性、事務/原子操做。在併發控制中咱們會考慮線程協做、互斥與鎖、併發容器等方面。
併發控制中主要考慮線程之間的通訊(線程之間以何種機制來交換信息)與同步(讀寫等待,競態條件等)模型,在命令式編程中,線程之間的通訊機制有兩種:共享內存和消息傳遞。Java 就是典型的共享內存模式的通訊機制;而 Go 則是提倡以消息傳遞方式實現內存共享,而非經過共享來實現通訊。
在共享內存的併發模型裏,線程之間共享程序的公共狀態,線程之間經過寫-讀內存中的公共狀態來隱式進行通訊。在消息傳遞的併發模型裏,線程之間沒有公共狀態,線程之間必須經過明確的發送消息來顯式進行通訊。同步是指程序用於控制不一樣線程之間操做發生相對順序的機制。在共享內存併發模型裏,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼須要在線程之間互斥執行。在消息傳遞的併發模型裏,因爲消息的發送必須在消息的接收以前,所以同步是隱式進行的。
常見的線程通訊方式有如下幾種:
管道(Pipe):管道是一種半雙工的通訊方式,數據只能單向流動,並且只能在具備親緣關係的進程間使用,其中進程的親緣關係一般是指父子進程關係。
消息隊列(Message Queue):消息隊列是由消息的鏈表,存放在內核中並由消息隊列標識符標識。消息隊列克服了信號傳遞信息少、管道只能承載無格式字節流以及緩衝區大小受限等缺點。
信號量(Semophore):信號量是一個計數器,能夠用來控制多個進程對共享資源的訪問。它常做爲一種鎖機制,防止某進程正在訪問共享資源時,其餘進程也訪問該資源。所以,主要做爲進程間以及同一進程內不一樣線程之間的同步手段。
共享內存(Shared Memory):共享內存就是映射一段能被其餘進程所訪問的內存,這段共享內存由一個進程建立,但多個進程均可以訪問。共享內存是最快的 IPC 方式,它是針對其餘進程間通訊方式運行效率低而專門設計的。它每每與其餘通訊機制,如信號量配合使用,來實現進程間的同步和通訊。
套接字(Socket):套接字也是一種進程間通訊機制,與其餘通訊機制不一樣的是,它可用於不一樣主機間的進程通訊。
互斥是指某一資源同時只容許一個訪問者對其進行訪問,具備惟一性和排它性;但互斥沒法限制訪問者對資源的訪問順序,即訪問是無序的。同步:是指在互斥的基礎上(大多數狀況),經過其它機制實現訪問者對資源的有序訪問。在大多數狀況下,同步已經實現了互斥,特別是全部寫入資源的狀況一定是互斥的;少數狀況是指能夠容許多個訪問者同時訪問資源。
所謂的臨界資源,即一次只容許一個進程訪問的資源,多個進程只能互斥訪問的資源。臨界資源的訪問須要同步操做,好比信號量就是一種方便有效的進程同步機制。但信號量的方式要求每一個訪問臨界資源的進程都具備 wait 和 signal 操做。這樣使大量的同步操做分散在各個進程中,不只給系統管理帶來了麻煩,並且會因同步操做的使用不當致使死鎖。管程就是爲了解決這樣的問題而產生的。
操做系統中管理的各類軟件和硬件資源,都可用數據結構抽象地描述其資源特性,即用少許信息和對該資源所執行的操做來表徵該資源,而忽略它們的內部結構和實現細節。利用共享數據結構抽象地表示系統中的共享資源。而把對該共享數據結構實施的操做定義爲一組過程,如資源的請求和釋放過程 request 和 release。進程對共享資源的申請、釋放和其餘操做,都是經過這組過程對共享數據結構的操做來實現的,這組過程還能夠根據資源的狀況接受或阻塞進程的訪問,確保每次僅有一個進程使用該共享資源,這樣就能夠統一管理對共享資源的全部訪問,實現臨界資源互斥訪問。
管程就是表明共享資源的數據結構以及由對該共享數據結構實施操做的一組過程所組成的資源管理程序共同構成的一個操做系統的資源管理模塊。管程被請求和釋放臨界資源的進程所調用。管程定義了一個數據結構和能爲併發進程所執行(在該數據結構上)的一組操做,這組操做能同步進程和改變管程中的數據。
悲觀併發控制,又名悲觀鎖(Pessimistic Concurrency Control,PCC)是一種併發控制的方法。它能夠阻止一個事務以影響其餘用戶的方式來修改數據。若是一個事務執行的操做都某行數據應用了鎖,那只有當這個事務把鎖釋放,其餘事務纔可以執行與該鎖衝突的操做。悲觀併發控制主要用於數據爭用激烈的環境,以及發生併發衝突時使用鎖保護數據的成本要低於回滾事務的成本的環境中。
在編程語言中,悲觀鎖可能存在如下缺陷:
數據庫中悲觀鎖主要由如下問題:悲觀鎖大多數狀況下依靠數據庫的鎖機制實現,以保證操做最大程度的獨佔性。若是加鎖的時間過長,其餘用戶長時間沒法訪問,影響了程序的併發訪問性,同時這樣對數據庫性能開銷影響也很大,特別是對長事務而言,這樣的開銷每每沒法承受,特別是對長事務而言。如一個金融系統,當某個操做員讀取用戶的數據,並在讀出的用戶數據的基礎上進行修改時(如更改用戶賬戶餘額),若是採用悲觀鎖機制,也就意味着整個操做過程當中(從操做員讀出數據、開始修改直至提交修改結果的全過程,甚至還包括操做員中途去煮咖啡的時間),數據庫記錄始終處於加鎖狀態,能夠想見,若是面對幾百上千個併發,這樣的狀況將致使怎樣的後果。
互斥鎖即對互斥量進行分加鎖,和自旋鎖相似,惟一不一樣的是競爭不到鎖的線程會回去睡會覺,等到鎖可用再來競爭,第一個切入的線程加鎖後,其餘競爭失敗者繼續回去睡覺直到再次接到通知、競爭。
互斥鎖算是目前併發系統中最經常使用的一種鎖,POSIX、C++十一、Java 等均支持。處理 POSIX 的加鎖比較普通外,C++ 和 Java 的加鎖方式頗有意思。C++ 中能夠使用一種 AutoLock(常見於 chromium 等開源項目中)工做方式相似 auto_ptr 智 能指針,在 C++11 中官方將其標準化爲 std::lock_guard 和 std::unique_lock。Java 中使用 synchronized 緊跟同步代碼塊(也可修飾方法)的方式同步代碼,很是靈活。這兩種實現都巧妙的利用各自語言特性實現了很是優雅的加鎖方式。固然除此以外他們也支持傳統的類 似於 POSIX 的加鎖模式。
也叫作鎖遞歸,就是獲取一個已經獲取的鎖。不支持線程獲取它已經獲取且還沒有解鎖的方式叫作不可遞歸或不支持重入。帶重入特性的鎖在重入時會判斷是否同一個線程,若是是,則使持鎖計數器+1(0 表明沒有被線程獲取,又或者是鎖被釋放)。C++11 中同時支持兩種鎖,遞歸鎖 std::recursive_mutex 和非遞歸 std::mutex。Java 的兩種互斥鎖實現以及讀寫鎖實現均支持重入。POSIX 使用一種叫作重入函數的方法保證函數的線程安全,鎖粒度是調用而非線程。
支持兩種模式的鎖,當採用寫模式上鎖時與互斥鎖相同,是獨佔模式。但讀模式上鎖能夠被多個讀線程讀取。即寫時使用互斥鎖,讀時採用共享鎖,故又叫共享-獨 佔鎖。一種常見的錯誤認爲數據只有在寫入時才須要鎖,事實是即便是讀操做也須要鎖保護,若是不這麼作的話,讀寫鎖的讀模式便毫無心義。
相對悲觀鎖而言,樂觀鎖(Optimistic Locking)機制採起了更加寬鬆的加鎖機制。相對悲觀鎖而言,樂觀鎖假設認爲數據通常狀況下不會形成衝突,因此在數據進行提交更新的時候,纔會正式對數據的衝突與否進行檢測,若是發現衝突了,則讓返回用戶錯誤的信息,讓用戶決定如何去作。上面提到的樂觀鎖的概念中其實已經闡述了他的具體實現細節:主要就是兩個步驟:衝突檢測和數據更新。其實現方式有一種比較典型的就是 Compare and Swap。
CAS 是項樂觀鎖技術,當多個線程嘗試使用 CAS 同時更新同一個變量時,只有其中一個線程能更新變量的值,而其它線程都失敗,失敗的線程並不會被掛起,而是被告知此次競爭中失敗,並能夠再次嘗試。CAS 操做包含三個操做數 —— 內存位置(V)、預期原值(A)和新值(B)。若是內存位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新爲新值。不然,處理器不作任何操做。不管哪一種狀況,它都會在 CAS 指令以前返回該位置的值。CAS 有效地說明了我認爲位置 V 應該包含值 A;若是包含該值,則將 B 放到這個位置;不然,不要更改該位置,只告訴我這個位置如今的值便可。這其實和樂觀鎖的衝突檢查+數據更新的原理是同樣的。
樂觀鎖也不是萬能的,樂觀併發控制相信事務之間的數據競爭(Data Race)的機率是比較小的,所以儘量直接作下去,直到提交的時候纔去鎖定,因此不會產生任何鎖和死鎖。但若是直接簡單這麼作,仍是有可能會遇到不可預期的結果,例如兩個事務都讀取了數據庫的某一行,通過修改之後寫回數據庫,這時就遇到了問題。
CAS 的核心思想是經過比對內存值與預期值是否同樣而判斷內存值是否被改過,但這個判斷邏輯不嚴謹,假如內存值原來是 A,後來被 一條線程改成 B,最後又被改爲了 A,則 CAS 認爲此內存值並無發生改變,但其實是有被其餘線程改過的,這種狀況對依賴過程值的情景的運算結果影響很大。解決的思路是引入版本號,每次變量更新都把版本號加一。部分樂觀鎖的實現是經過版本號(version)的方式來解決 ABA 問題,樂觀鎖每次在執行數據的修改操做時,都會帶上一個版本號,一旦版本號和數據的版本號一致就能夠執行修改操做並對版本號執行 +1 操做,不然就執行失敗。由於每次操做的版本號都會隨之增長,因此不會出現 ABA 問題,由於版本號只會增長不會減小。
Linux 內核中最多見的鎖,做用是在多核處理器間同步數據。這裏的自旋是忙等待的意思。若是一個線程(這裏指的是內核線程)已經持有了一個自旋鎖,而另外一條線程也想要獲取該鎖,它就不停地循環等待,或者叫作自旋等待直到鎖可用。能夠想象這種鎖不能被某個線程長時間持有,這會致使其餘線程一直自旋,消耗處理器。因此,自旋鎖使用範圍很窄,只容許短時間內加鎖。
其實還有一種方式就是讓等待線程睡眠直到鎖可用,這樣就能夠消除忙等待。很明顯後者優於前者的實現,可是卻不適用於此,若是咱們使用第二種方式,咱們要作幾步操做:把該等待線程換出、等到鎖可用在換入,有兩次上下文切換的代價。這個代價和短期內自旋(實現起來也簡單)相比,後者更能適應實際狀況的須要。還有一點須要注意,試圖獲取一個已經持有自旋鎖的線程再去獲取這個自旋鎖或致使死鎖,但其餘操做系統並不是如此。
自旋鎖與互斥鎖有點相似,只是自旋鎖不會引發調用者睡眠,若是自旋鎖已經被別的執行單元保持,調用者就一直循環在那裏看是 否該自旋鎖的保持者已經釋放了鎖,"自旋"一詞就是所以而得名。其做用是爲了解決某項資源的互斥使用。由於自旋鎖不會引發調用者睡眠,因此自旋鎖的效率遠 高於互斥鎖。雖然它的效率比互斥鎖高,可是它也有些不足之處:
自旋鎖比較適用於鎖使用者保持鎖時間比較短的狀況。正是因爲自旋鎖使用者通常保持鎖時間很是短,所以選擇自旋而不是睡眠是很是必要的,自旋鎖的效率遠高於互斥鎖。信號量和讀寫信號量適合於保持時間較長的狀況,它們會致使調用者睡眠,所以只能在進程上下文使用,而自旋鎖適合於保持時間很是短的狀況,它能夠在任何上下文使用。若是被保護的共享資源只在進程上下文訪問,使用信號量保護該共享資源很是合適,若是對共享資源的訪問時間很是短,自旋鎖也能夠。可是若是被保護的共享資源須要在中斷上下文訪問(包括底半部即中斷處理句柄和頂半部即軟中斷),就必須使用自旋鎖。自旋鎖保持期間是搶佔失效的,而信號量和讀寫信號量保持期間是能夠被搶佔的。自旋鎖只有在內核可搶佔或 SMP(多處理器)的狀況下才真正須要,在單 CPU 且不可搶佔的內核下,自旋鎖的全部操做都是空操做。另外格外注意一點:自旋鎖不能遞歸使用。
爲了實現可串行化,同時避免鎖機制存在的各類問題,咱們能夠採用基於多版本併發控制(Multiversion concurrency control,MVCC)思想的無鎖事務機制。人們通常把基於鎖的併發控制機制稱成爲悲觀機制,而把 MVCC 機制稱爲樂觀機制。這是由於鎖機制是一種預防性的,讀會阻塞寫,寫也會阻塞讀,當鎖定粒度較大,時間較長時併發性能就不會太好;而 MVCC 是一種後驗性的,讀不阻塞寫,寫也不阻塞讀,等到提交的時候才檢驗是否有衝突,因爲沒有鎖,因此讀寫不會相互阻塞,從而大大提高了併發性能。咱們能夠借用源代碼版本控制來理解 MVCC,每一個人均可以自由地閱讀和修改本地的代碼,相互之間不會阻塞,只在提交的時候版本控制器會檢查衝突,並提示 merge。目前,Oracle、PostgreSQL 和 MySQL 都已支持基於 MVCC 的併發機制,但具體實現各有不一樣。
MVCC 的一種簡單實現是基於 CAS(Compare-and-swap)思想的有條件更新(Conditional Update)。普通的 update 參數只包含了一個 keyValueSet’,Conditional Update
在此基礎上加上了一組更新條件 conditionSet { … data[keyx]=valuex, … }
,即只有在 D 知足更新條件的狀況下才將數據更新爲 keyValueSet’;不然,返回錯誤信息。這樣,L 就造成了以下圖所示的 Try/Conditional Update/(Try again)
的處理模式:
對於常見的修改用戶賬戶信息的例子而言,假設數據庫中賬戶信息表中有一個 version
字段,當前值爲 1 ;而當前賬戶餘額字段(balance)爲 100。
操做員 A 此時將其讀出(version=1),並從其賬戶餘額中扣除 50 (100-50)。
在操做員 A 操做的過程當中,操做員 B 也讀入此用戶信息(version=1),並從其賬戶餘額中扣除 20 (100-20)。
操做員 A 完成了修改工做,將數據版本號加一(version=2),連同賬戶扣除後餘額(balance=50),提交至數據庫更新,此時因爲提交數據版本大於數據庫記錄當前版本,數據被更新,數據庫記錄 version 更新爲 2 。
操做員 B 完成了操做,也將版本號加一(version=2)試圖向數據庫提交數據(balance=80),但此時比對數據庫記錄版本時發現,操做員 B 提交的數據版本號爲 2 ,數據庫記錄當前版本也爲 2 ,不知足提交版本必須大於記錄當前版本才能執行更新的樂觀鎖策略,所以,操做員 B 的提交被駁回。這樣,就避免了操做員 B 用基於 version=1 的舊數據修改的結果覆蓋操做員 A 的操做結果的可能。
從上面的例子能夠看出,樂觀鎖機制避免了長事務中的數據庫加鎖開銷(操做員 A 和操做員 B 操做過程當中,都沒有對數據庫數據加鎖),大大提高了大併發量下的系統總體性能表現。須要注意的是,樂觀鎖機制每每基於系統中的數據存儲邏輯,所以也具有必定的侷限性,如在上例中,因爲樂觀鎖機制是在咱們的系統中實現,來自外部系統的用戶餘額更新操做不受咱們系統的控制,所以可能會形成髒數據被更新到數據庫中。
IO 的概念,從字義來理解就是輸入輸出。操做系統從上層到底層,各個層次之間均存在 IO。好比,CPU 有 IO,內存有 IO, VMM 有 IO, 底層磁盤上也有 IO,這是廣義上的 IO。一般來說,一個上層的 IO 可能會產生針對磁盤的多個 IO,也就是說,上層的 IO 是稀疏的,下層的 IO 是密集的。磁盤的 IO,顧名思義就是磁盤的輸入輸出。輸入指的是對磁盤寫入數據,輸出指的是從磁盤讀出數據。
所謂的併發 IO,即在一個時間片內,若是一個進程進行一個 IO 操做,例如讀個文件,這個時候該進程能夠把本身標記爲「休眠狀態」並出讓 CPU 的使用權,待文件讀進內存,操做系統會把這個休眠的進程喚醒,喚醒後的進程就有機會從新得到 CPU 的使用權了。這裏的進程在等待 IO 時之因此會釋放 CPU 使用權,是爲了讓 CPU 在這段等待時間裏能夠作別的事情,這樣一來 CPU 的使用率就上來了;此外,若是這時有另一個進程也讀文件,讀文件的操做就會排隊,磁盤驅動在完成一個進程的讀操做後,發現有排隊的任務,就會當即啓動下一個讀操做,這樣 IO 的使用率也上來了。
Unix 中內置了 5 種 IO 模型,阻塞式 IO, 非阻塞式 IO,IO 複用模型,信號驅動式 IO 和異步 IO。而從應用的角度來看,IO 的類型能夠分爲:
大/小塊 IO:這個數值指的是控制器指令中給出的連續讀出扇區數目的多少。若是數目較多,如 64,128 等,咱們能夠認爲是大塊 IO;反之,若是很小,好比 4,8,咱們就會認爲是小塊 IO,實際上,在大塊和小塊 IO 之間,沒有明確的界限。
連續/隨機 IO:連續 IO 指的是本次 IO 給出的初始扇區地址和上一次 IO 的結束扇區地址是徹底連續或者相隔很少的。反之,若是相差很大,則算做一次隨機 IO。連續 IO 比隨機 IO 效率高的緣由是:在作連續 IO 的時候,磁頭幾乎不用換道,或者換道的時間很短;而對於隨機 IO,若是這個 IO 不少的話,會致使磁頭不停地換道,形成效率的極大下降。
順序/併發 IO:從概念上講,併發 IO 就是指向一塊磁盤發出一條 IO 指令後,沒必要等待它迴應,接着向另一塊磁盤發 IO 指令。對於具備條帶性的 RAID(LUN),對其進行的 IO 操做是併發的,例如:raid 0+1(1+0),raid5 等。反之則爲順序 IO。
在傳統的網絡服務器的構建中,IO 模式會按照 Blocking/Non-Blocking、Synchronous/Asynchronous 這兩個標準進行分類,其中 Blocking 與 Synchronous 大同小異,而 NIO 與 Async 的區別在於 NIO 強調的是 輪詢(Polling),而 Async 強調的是通知(Notification)。譬如在一個典型的單進程單線程 Socket 接口中,阻塞型的接口必須在上一個 Socket 鏈接關閉以後才能接入下一個 Socket 鏈接。而對於 NIO 的 Socket 而言,服務端應用會從內核獲取到一個特殊的 "Would Block" 錯誤信息,可是並不會阻塞到等待發起請求的 Socket 客戶端中止。
通常來講,在 Linux 系統中能夠經過調用獨立的 select
或者 epoll
方法來遍歷全部讀取好的數據,而且進行寫操做。而對於異步 Socket 而言(譬如 Windows 中的 Sockets 或者 .Net 中實現的 Sockets 模型),服務端應用會告訴 IO Framework 去讀取某個 Socket 數據,在數據讀取完畢以後 IO Framework 會自動地調用你的回調(也就是通知應用程序自己數據已經準備好了)。以 IO 多路複用中的 Reactor 與 Proactor 模型爲例,非阻塞的模型是須要應用程序自己處理 IO 的,而異步模型則是由 Kernel 或者 Framework 將數據準備好讀入緩衝區中,應用程序直接從緩衝區讀取數據。
同步阻塞:在此種方式下,用戶進程在發起一個 IO 操做之後,必須等待 IO 操做的完成,只有當真正完成了 IO 操做之後,用戶進程才能運行。
同步非阻塞:在此種方式下,用戶進程發起一個 IO 操做之後邊可返回作其它事情,可是用戶進程須要時不時的詢問 IO 操做是否就緒,這就要求用戶進程不停的去詢問,從而引入沒必要要的 CPU 資源浪費。
異步非阻塞:在此種模式下,用戶進程只須要發起一個 IO 操做而後當即返回,等 IO 操做真正的完成之後,應用程序會獲得 IO 操做完成的通知,此時用戶進程只須要對數據進行處理就行了,不須要進行實際的 IO 讀寫操做,由於真正的 IO 讀取或者寫入操做已經由內核完成了。
而在併發 IO 的問題中,較常見的就是所謂的 C10K 問題,即有 10000 個客戶端須要連上一個服務器並保持 TCP 鏈接,客戶端會不定時的發送請求給服務器,服務器收到請求後需及時處理並返回結果。
IO 多路複用就經過一種機制,能夠監視多個描述符,一旦某個描述符就緒(通常是讀就緒或者寫就緒),可以通知程序進行相應的讀寫操做。select,poll,epoll 都是 IO 多路複用的機制。值得一提的是,epoll 僅對於 Pipe 或者 Socket 這樣的讀寫阻塞型 IO 起做用,正常的文件描述符則是會馬上返回文件的內容,所以 epoll 等函數對普通的文件讀寫並沒有做用。
首先來看下可讀事件與可寫事件:當以下任一狀況發生時,會產生套接字的可讀事件:
當以下任一狀況發生時,會產生套接字的可寫事件:
select,poll,epoll 本質上都是同步 IO,由於他們都須要在讀寫事件就緒後本身負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步 IO 則無需本身負責進行讀寫,異步 IO 的實現會負責把數據從內核拷貝到用戶空間。select 自己是輪詢式、無狀態的,每次調用都須要把 fd 集合從用戶態拷貝到內核態,這個開銷在 fd 不少時會很大。epoll 則是觸發式處理鏈接,維護的描述符數目不受到限制,並且性能不會隨着描述符數目的增長而降低。
方法 | 數量限制 | 鏈接處理 | 內存操做 |
---|---|---|---|
select | 描述符個數由內核中的 FD_SETSIZE 限制,僅爲 1024;從新編譯內核改變 FD_SETSIZE 的值,可是沒法優化性能 | 每次調用 select 都會線性掃描全部描述符的狀態,在 select 結束後,用戶也要線性掃描 fd_set 數組才知道哪些描述符準備就緒(O(n)) | 每次調用 select 都要在用戶空間和內核空間裏進行內存複製 fd 描述符等信息 |
poll | 使用 pollfd 結構來存儲 fd,突破了 select 中描述符數目的限制 | 相似於 select 掃描方式 | 須要將 pollfd 數組拷貝到內核空間,以後依次掃描 fd 的狀態,總體複雜度依然是 O(n)的,在併發量大的狀況下服務器性能會快速降低 |
epoll | 該模式下的 Socket 對應的 fd 列表由一個數組來保存,大小不限制(默認 4k) | 基於內核提供的反射模式,有活躍 Socket 時,內核訪問該 Socket 的 callback,不須要遍歷輪詢 | epoll 在傳遞內核與用戶空間的消息時使用了內存共享,而不是內存拷貝,這也使得 epoll 的效率比 poll 和 select 更高 |