分而合之,並行不悖。html
技術人首先應當擁有良好的技術素養。技術素養首先是一絲不苟地嚴謹探索與求知的能力和精神,其次是對一個事物可以構建自底向上的邏輯,具備宏觀清晰的視野和對細節的把握。java
要設法閱讀第一手的資料,論文、官方文檔、源碼、經典著做,與發明者交流;要努力追溯本質與本源。知識是相對客觀的,不能浮誇和造假。redis
技術人還應當保持謙遜,要明白本身所知的永遠是冰山一角,以冰山一角去評判另外一個冰山一角,只是五十步比百步。
算法
併發,就是在同一時間段內有多個任務同時進行着。這些任務或者互不影響互不干擾,或者共同協做來完成一個更大的任務。編程
好比我在作項目 A,修改工程 a ; 你在作項目 B, 修改工程 b 。咱們各自作完本身的項目後上線。我和你作的事情就是併發的。若是我和你修改同一個工程,就可能須要協調處理衝突。併發是一種高效的運做方式,但每每也要處理併發帶來的衝突和協做。後端
世界自然是併發的。本文總結併發相關的知識和實踐。緩存
總入口見:「互聯網應用服務端的經常使用技術思想與機制綱要」
安全
計算機中實現併發的方式有:多核、多進程、多線程;共享內存模型。基本方法是分而治之、劃分均衡任務、獨立工做單元、隔離訪問共享資源。能夠將一個大任務劃分爲多個互相協做的子任務,將一個大數據集劃分爲多個小的子數據集,分別處理後合併起來完成整個任務。併發須要解決執行實體之間的資源共享和通訊機制。服務器
多核:有多個 CPU 核心,每一個 CPU 核心都擁有專享的寄存器、高速緩存。多個 CPU 核心能夠分別處理不一樣的指令和數據集。多核心之間的通訊機制是系統總線和共享內存。多核是併發的硬件基礎。網絡
多進程模型:進程是程序的一次執行實例,具備私有地址空間,由內核調度。進程有父子關係。進程間通訊方式:管道(無名管道和命名管道 FIFO)、消息隊列、共享內存、套接字、信號機制。進程建立和切換的開銷都比較大。多進程是多任務執行的上下文基礎。
多線程模型:線程是運行在進程上下文中的共享同一個進程私有地址空間的執行單元,亦由內核調度。線程之間是對等的。線程通訊方式:消息隊列、共享內存。線程的建立和切換開銷比進程要小不少。多線程是多任務的調度基礎。
在 Java 應用語境中,執行實體對應着線程。如下涉及到執行實體的時候,直接以線程代替。併發能有效利用多個線程同時工做,大幅提高性能。同時,也是有必定代價的:線程阻塞與上下文切換(5000-10000 CPU 時鐘)、內存同步開銷(使 CPU 緩存失效、禁止編譯器優化等)。不良的併發設計,可能致使大量線程等待、阻塞、切換,反而不如串行的執行效率高。
如何去思考和分析併發問題呢? 併發的難點在於,(不一樣線程裏的)任務執行的不一樣順序會引起不一樣的結果,而這些順序都是有必定機率性存在的。
所以,併發的關鍵點在於如何在合理的程度上協調任務執行順序產生預期結果,同時又不對任務的進展產生過大的干預。就像宏觀調控之於市場經濟。市場經濟是很是有活力的經濟形式,但聽憑市場經濟的自由發展,會有失衡的風險。此時,就須要必定的宏觀調控來干預一下。而宏觀調控也不能過分,不然會抑制市場經濟的活力。
注意,是協調執行順序而不是控制。實際上,執行順序是難以控制的。大多數時候,能作的是對少數步驟執行施加一些影響,使執行順序符合某些前後約束,從而可以產生預期結果。絕大多數的步驟執行,仍是任之天然進行。
資源依賴
要正確協調執行順序,先得弄清楚要協調哪些任務,或者說,任務執行受什麼影響:
好比,同一個訂單的下單過程,兩個線程去分別讀寫訂單數據(假設都是讀 DB 主庫):
所以,任務執行受有限共享的資源及資源依賴影響。若是多個任務併發執行,首先要理清楚這些任務所依賴的資源以及資源之間的依賴。資源類型包括:變量、數據行(記錄)、文件句柄、網絡鏈接、端口等。若是兩個任務沒有資源依賴,則各自執行便可;若是有共享資源依賴,則須要在合適的時候自動調節彼此獲取共享資源的順序。
值得說起的是,有一種隱式的資源依賴。好比一個大的任務拆分爲 A,B,C 三個任務,A 和 B 都執行完成後,才能執行 C。此時 C 的執行依賴於 A,B 的執行完成狀態(也極可能依賴 A,B 的執行結果集)。 這種隱式的資源依賴,也稱爲任務協做。
邏輯時鐘
如何判斷併發執行結果是準確的呢?好比 x = 1 。任務 A 在 t 時刻讀 x ,任務 B 在 t+1 時刻寫 x =5, 任務 C 在 t+2 讀 x,按理 A 讀到是 1 ,C 讀到是 5 。 但因爲網絡延時,可能 C 的讀請求在 B 的寫請求提交以前就到達了,所以 C 也可能讀到 1。因爲網絡的不可靠及機器各自的時鐘是有細微不一樣步的,所以,執行讀寫 x 的服務器沒法判斷 B, C 請求的前後性。
須要有一個邏輯時鐘,給任務進行順序編號,根據任務編號以及讀寫的因果性,就能判斷 C 讀到 1 的結果是錯誤的了。
happen-before
定一些基本的準則是必要的。就像歐幾里得幾何首先定義了五條公理而後纔開始推導同樣。
happen-before 是可見性判斷的基本準則:符合準則的兩個操做,前面的操做必然先行於/可見於後面的操做。換句話說,就是關於併發的基本定理。若是定理都不成立,那麼併發的肯定性結果就無從談起了。 happen-before 的具體細則:
要正確協調任務的執行順序,須要解決任務之間的協做與同步。任務之間的協做與同步方式主要有:快照機制、原子操做、指令屏障、鎖機制、信號機制、消息/管道機制。
快照機制
生成某個時間點的歷史版本的不可變的快照數據,以必定策略去生成新的快照;直接讀快照而不是讀最新數據。將數據與版本號綁定,根據版本號來讀取對應的數據;更新時不會修改已有的快照,而是生成新的版本號和數據。快照機制能夠用來回溯歷史數據。Git 是運用快照機制的典範。
快照機制並無對任務的天然進展施加影響,只是記錄了某個數據集的某個時刻的狀態。應用能夠根據須要去讀取不一樣時刻的狀態,作進一步處理。快照機制通常用來提高併發讀的吞吐量。
原子操做
將多個操做封裝爲一個不可分割的總體操做,其它操做不可能在這個總體操做之間插入更新相關變量。
實現原子操做有兩種方式:
指令屏障
指令屏障是在普通指令中插入特殊指令,從而在讀寫指令的執行之間加以執行順序的前後約束,控制某些指令必須在另外一些指令以前執行且執行結果可見,禁止 CPU 經過指令重排序來優化內存讀寫(有性能損失)。最經常使用的指令屏障是內存屏障 Memory Barrier。
鎖機制
鎖機制用於有限共享資源的保護性訪問,每次只容許一個執行體來訪問可得到的共享資源。
鎖機制的基礎是 P-V 原語和阻塞/喚醒機制:
信號機制
信號機制是發出特定的信號,讓接受信號的任務作相應的處理。中斷是信號機制的一種典型場景。中斷由某個中斷源發出一個信號給某個線程,當線程收到這個信號時,能夠作一些特定的動做。
Java 線程有一箇中斷標誌位。處於不一樣狀態時,線程對於中斷有不一樣的反應。處於 New 和 Terminated 時,無心義;處於 Running 和 Blocked 時,只是設置中斷標誌位,不會影響線程狀態; 處於 Time Sleep 時,會拋出異常並清空中斷標誌位。Java 將中斷的具體處理的權力交給了應用。
消息/管道
經過在兩個任務之間傳遞消息或者創建管道,來串聯起兩個任務的順序執行。消息機制經常使用於解耦服務,而管道經常使用於 Pipeline 流水線模式中。
從併發思路中能夠推導出一些經常使用的同步模式,來確保併發訪問的安全性。主要有:Immutable、Unshared Copies、Monitor Locks 、Memory Barrier、Protected Lock、CAS。
Immutable
不可變數據。典型的不可變數據有字符串、快照。ES 分片裏的倒排索引就是不可變的。ES 會將不可變的倒排索引與更新的倒排索引進行查詢合併,獲得最終的查詢結果。
Unshared Copies
每一個線程都有一份本身的拷貝,不共享,互不影響。ThreadLocal 便是應用 Unshared Copies 模式。
Monitor locks
Java synchronized 塊應用 Monitor locks 模式,基於 object monitor 和 monitorenter/2 monitorexit 實現,由編譯器和 JVM 共同協做實現。JVM 規範指明:每一個對象關聯一個 object monitor ,當線程執行 monitorenter 時會去獲取 monitor 的 ownership ,而執行 monitorexit 則會釋放 monitor 的 ownership。第二個 monitorexit 是爲了在異常退出時與 monitorenter 匹配。在 hotSpot 虛擬機中,monitor 是由 ObjectMonitor 實現的。其源碼位於 hotSpot 虛擬機源碼 ObjectMonitor.hpp 文件中。
synchronized 方法是基於方法常量池中的方法表結構中的 ACC_SYNCHRONIZED 標識符實現。synchronized 是可重入的,同一線程連續屢次獲取同一個鎖,不須要每次都加鎖,只需記錄加鎖次數。同步容器 SynchronizedList, SynchronizedMap 是基於 synchronized(mutex) { // target.operation(); } 實現的對應容器的簡單併發版。
Memory Barrier
Memory Barrier 內存屏障。當插入內存屏障後,其後的指令不會當即放在 CPU 緩存裏,而是和內存屏障一塊兒放在 FIFO 隊列裏,待 CPU 緩存裏的指令都執行完成後,從 FIFO 中取出內存屏障後的指令來執行。
內存屏障主要有兩種:
兩兩組合,有四種:StoreStoreBarrier, StoreLoadBarrier, LoadStoreBarrier, LoadLoadBarrier。 XYBarrier 是指,在 XYBarrier 以前的全部 X 操做都必須在 XYBarrier 以後的任一 Y 操做以前執行完成。而且寫操做對全部處理器可見。
volatile 關鍵字在寫操做以後插入 StoreStore Barrier, 在讀操做以前操做 LoadLoad Barrier。volatile 適合作單個簡單狀態標識符的更新、生命週期裏的初始化或退出。volatile 是不加鎖的。
Protected Lock
輕量級更靈活的鎖。形式一般以下:
Lock lock = lock.lock(lockKey, time, timeUnit); try { // doBizLogic; } finally { lock.unlock(); }
CAS
Compare-And-Swap 。CAS(V,E,N) 操做便是「先將 V 與 E 比較是否相等,若是相等,則更新到指定值 N ,不然什麼都不作」。CAS 是無鎖的非阻塞的,沒有線程切換開銷,所以在併發程度不高的狀況下性能更優。Java 併發包裏的絕大多數同步工具都有 CAS 的影子。 Java CAS 操做是經過 Unsafe 類的 native 方法支持的。
CAS 操做的原子語義是經過底層硬件和指令來支持的。相關指令以下:
在IA64,x86 指令集中有 cmpxchg 指令完成 CAS 功能。CPU 的原子操做在底層能夠經過總線鎖定和緩存鎖定來實現。總線鎖定是 CPU 在總線上輸出一個 LOCK# 信號,阻塞其它處理器操做該共享變量的緩存的請求,獨佔內存;緩存鎖定是經過 MESI 緩存一致性機制來保證操做的原子性。在以下狀況下只能使用總線鎖定:當操做的數據不能被緩存在處理器內部,或者操做的數據跨多個緩存行時,或者 CPU 不支持緩存鎖定。
CAS 有兩個問題:
理解了併發的模型、思路和模式以後,再來看併發工具如何實現。Java 併發包裏的絕大多數同步工具都是基於 CAS 和 AQS 的。所以,深刻理解 CAS 和 AQS 是很是重要的。
AQS
實現 Java 同步工具的基本框架,也是整個 Java 併發包的核心基礎類。AQS 實現了「根據某種許可獲取的狀況將線程入隊/出隊以及相應的線程阻塞/喚醒」的通用機制,而將什麼時候入隊/出隊(是否可以得到許可)的控制權交給了庫的使用者。AQS 支持按照中斷(互斥)或者超時兩種模式來獲取/釋放許可,協調線程執行順序。
AQS 包含一個同步隊列和一個條件隊列。兩個隊列都是基於鏈表實現的。
鏈表節點包含以下成員:
如前所述,AQS 實現了通用的入隊/出隊以及相應的阻塞/喚醒機制,那麼什麼時候會入隊/出隊呢?這就是自定義方法的做用了。使用 AQS 開發同步工具,須要定義好 state 的同步語義,實現以下方法:tryAcquire/tryRelease,tryAcquireShared/tryReleaseShared,isHeldExclusively。
AtomicXXX
原子類,提供基本數據類型的原子化更新操做。經過 volatile variable + offset (字段的固定的內存地址偏移量) + Unsafe 來獲取的狀態字段的可見值,CAS 實現原子操做,適用於計數、安全引用更新等。可閱讀 AtomicInteger 和 LongAdder 的實現。
ReentrantLock
Protected Lock 模式的一種實現。基於 CAS 和 AQS 實現,提供公平鎖 FairSync 和非公平鎖 NonfairSync。默認非公平鎖。非公平鎖吞吐量更高,公平鎖傾向於訪問授予等待時間最長的線程,吞吐量可能較低,適合防線程飢餓上波動小一點。
ReentrantLock 能夠返回一個ConditionObject 對象,用做條件等待阻塞和喚醒。
ConcurrentHashMap
HashMap 的併發加鎖版。要點以下:
ConcurrentHashMap 體現了一些提高併發性能的技巧:減小串行化部分的耗時、減小持鎖邏輯耗時(下降鎖粒度)、減小鎖競爭程度(數據分段及分段鎖)。使用多個細粒度鎖交互時要注意防止死鎖。
ThreadLocal
ThreadLocal 類裏維護了一個哈希表 ThreadLocalMap[ThreadLocal, Value] ,每一個線程都持有一個對 ThreadLocalMap 的引用,在該線程裏調用 ThreadLocal.setInitialValue 方法時被初始化。當調用某個 ThreadLocal 對象的 set 方法時,會先獲取當前線程,而後將當前線程的 TheadLocal 對象及對應的值寫入所持有的 ThreadLocalMap 中。ThreadLocal 對象的哈希碼值是經過一個 AtomicInteger 每次自增 0x61c88647 獲得的。0x61c88647 是斐波那契乘數,可保證哈希散列分佈均勻一些。
ThreadLocal 在一個長流程中存儲須要的 Context 。ThreadLocal 使用要注意的問題:
線程池
線程池是受控的可執行多任務的線程管理器。Java 線程池實現是 ThreadPoolExecutor。 線程池的主要組成部分以下:
線程池的實現要點以下:
要作到併發的準確與安全,須要很是當心地避免一些常見陷阱:
InnoDB鎖
分佈式鎖
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1])
。 可使用 Redisson API 。大流量
併發大流量是引發應用不穩定甚至將應用擊潰的常見殺手之一。應對併發大流量的措施:1. 緩存,減小對後端存儲壓力;2. 降級,暫時移除非核心鏈路;3. 限流; 4. 架構升級,作到動靜分離、冷熱分離、讀寫分離、服務器分離、服務分離、分庫分表、負載均衡、NoSQL 技術、(多機房)冗餘、容器化、上雲。
不一致
因爲任務順序的不肯定性及腦力思考的侷限性,加上大流量,在少量情形下,可能會觸發程序的細微 BUG, 引發數據的不一致。
因爲人力的有限性,對於高併發引發的不一致,最好能構建準實時的監控、對帳、補償和對帳報表。
死鎖
多個線程同時要獲取多個類型的共享資源時,申請鎖的順序不當,可能致使死鎖。
世界自然是併發的。併發既是一種高效的運做方式,亦是一種符合天然的設計。本文總結了併發的基礎知識、思路、模式、工具、陷阱、應用、挑戰。
PS: 當我回過頭來看寫下的這些知識時,發現大部分都是描述性的,只有少部分是原理性的。或者說,在某個層次上是原理性的知識,在更底層看來是描述性的知識。從描述性的知識中,應當提煉出原理與思想。
我忽然感到:不只要對整個模型和機制有宏觀清晰的視野,也要能紮下去,研究第一手的論文和資料。如此,才能自下而上地融會貫通。而這樣的探索,纔是技術的精神本質。