1. 程式人生 > >併發程式設計學習筆記之可伸縮性(九)

併發程式設計學習筆記之可伸縮性(九)

很多改進效能的技術增加了複雜度,因此增加了安全和活躍度失敗的可能性.

更糟糕的是,有些技術的目的是改善效能,事實上產生了相反的作用,帶來了其他的效能問題.

資料的正確性永遠是第一位的,保證程式是正確的,然後再讓它更快.只有當你的效能需求和評估標準需要程式執行得更快時,才去進行改進.

在設計併發應用程式的時候,最大可能地改進效能,通常並不是最重要的事情.

效能的思考

當活動的執行因某個特定資源受阻時,我們稱之為受限於該資源:受限於CPU,受限於資料庫.

使用執行緒的目的是希望全面提升效能,但是與單執行緒相比,使用多執行緒會引入一些額外的開銷.

如:

  • 協調執行緒相關的開銷(加鎖、訊號、記憶體同步)
  • 增加的上下文切換
  • 執行緒的建立和消亡,以及排程的開銷

當執行緒被過度使用後,這些開銷會超過提高後的吞吐量響應性和計算能力帶來的補償.

一個沒能經過良好併發設計的應用程式,甚至比相同功能的順序的程式效能更差.

效能"遭遇"可伸縮性

可伸縮性指的是:當增加計算資源的時候(比如增加額外CPU數量、記憶體、儲存器、I/O頻寬),吞吐量和生產量能夠相應地得以改進.

對效能的權衡進行評估

避免不成熟的優化,首先使程式正確,然後再加快----如果它執行得還不夠快.

很多效能的優化會損害可讀性或可維護性--程式碼越"聰明",越"晦澀",就越難理解和維護.

在多個方案之間進行選擇的時候,先問自己一些問題:

  • 你所謂的更"快"指的是什麼
  • 在什麼樣的條件下你的方案能夠真正執行得更快?在輕負載還是重負載下?大資料集還是小資料集?是否支援你的測量標準答案?
  • 這些條件在你的環境中發生的頻率?是否支援你的測量標準的答案?
  • 這些程式碼在其他環境的不同條件下被用到的可能性?
  • 你用什麼樣隱含的代價,比如增加的開發風險或維護性,換取了效能的提高?這個權衡的決定是否正確?

做出任何與效能相關的工程決定時,都應該考慮這些問題.

最好選擇保守的優化方案,因為對效能的追求很可能是併發bug唯一最大的來源.通過減少同步來提高響應性,成了不遵守同步規定的常用的藉口,但是因為併發bug是最難追蹤和消除的,所以任何引入這類bug的行動風險都需要慎重進行.

優化改進後的程式碼,一定要進行壓力測試.主觀認為會提高效能的程式碼,在實際生產環境可能會出現問題.

測評,不要臆測

Amdahl 定律

Amdahl定律描述了在一個系統中,基於可並行化和序列化的元件各自所佔的比重,程式通過獲得額外的計算資源,理論上能夠加速多少.

如果F是必須序列化執行的比重,那麼Amdahl定律告訴我們,在一個N處理器的機器中,我們最多可以加速:

image

序列執行的比率越大,處理器越多,處理器的利用率越低:

image

執行緒引入的開銷

排程和執行緒內部的協調都要付出效能的開銷: 對於改進效能的執行緒來說,並行帶來的效能優勢必須超過併發所引入的開銷.

切換上下文

如果可執行的執行緒大於CPU的數量,那麼作業系統最終會強行換出正在執行的執行緒,從而使其他執行緒能夠使用CPU,這回引起上下文切換,他會儲存當前執行執行緒的執行上下文,並重建新調入執行緒的執行上下文.

切換上下文會有資源的損耗.

一個程式發生越多的阻塞(阻塞I/O,等待競爭鎖,或者等待條件變數),與受限於CPU的程式相比,就會造成越多的上下文切換,這增加了排程的開銷,並減少了吞吐量(無阻塞的演算法可以減少上下文切換).

Unix系統的vmstat命令和Windows系統的perfmon工具都能報告上下文切換次數和核心佔用的時間等資訊.

阻塞

多個執行緒競爭加鎖的方法的時候,失敗的執行緒必然發生阻塞.

JVM在阻塞的時候有兩種處理方式:

  • 自旋等待(spin-waiting,不斷嘗試獲取鎖,直到成功).
  • 掛起(suspending)這個阻塞的執行緒.

自旋等待適合短期的等待.掛起適合長期間等待.,有一些JVM基於過去等待時間的資料剖析來在這兩者之間選擇,但是大多數等待鎖的執行緒都是被掛起的.

減少鎖的競爭

序列化會損害可伸縮性,上下文切換會損害效能.競爭性的鎖會同時導致這兩種損失,所以減少鎖的競爭能夠改進效能和可伸縮性.

訪問獨佔鎖守護的資源是序列的--一次只能有一個執行緒訪問它.使用鎖可以避免過期資料,但是安全性是用很大的代價換來的,對鎖長期的競爭會限制可伸縮性.

併發程式中,對可伸縮性首要的威脅是獨佔的資源鎖.

有兩個原因影響著鎖的競爭性:

  • 鎖被請求的頻率
  • 每次持有鎖的時間

如果這兩者的乘積足夠小,俺麼大多數請求鎖的嘗試都是非競爭的,這樣競爭性的鎖將不會成為可伸縮性巨大的障礙.

但是,如果這個鎖的請求量很大,執行緒將會阻塞以等待鎖.在極端的情況下,處理器將會閒置,即使仍有大量工作等待著完成.

有三種方式來減少鎖的競爭:

  • 減少持有鎖的時間;
  • 減少請求鎖的頻率;
  • 或者用協調機制取代獨佔鎖,從而允許更強的併發性.

縮小鎖的範圍("快進快出")

減少競爭發生可能性的有效方式是儘可能縮短把持鎖的時間.儘量縮小synchronized程式碼塊,尤其是那些耗時的操作,以及那些潛在的阻塞操作(I/O).

減少鎖的粒度

減少持有鎖的時間比例的另一種方式是讓執行緒減少呼叫它的頻率(因此減少發生競爭的可能性).

可以通過使用分拆鎖(lock splitting)和分離鎖(lock striping)來實現,也就是採用相互獨立的鎖,守衛多個獨立的狀態變數,在改變之前,它們都是由一個鎖守護的.這些技術減少了鎖發生時的粒度,潛在實現了更好的可伸縮性---但是使用更多的鎖同樣會增加死鎖的風險.

如果一個鎖 守衛數量大於一、且相互獨立的狀態變數,你可能能通過分拆鎖,使每一個鎖守護不同的變數,從而改進可伸縮性.結果是每個鎖被請求的頻率都減少 了.

使用相同的鎖:

public class NewLock {
    //物件A
    private final Object objA = new Object();
    //隊相比
    private final Object objB = new Object();

    public synchronized Object getObjA(){
            return objA;
    }

    public synchronized Object getObjB(){
            return objB;
    }

}

使用不同的鎖(分拆鎖),減少了鎖的請求頻率:

public class NewLock {
    //物件A
    private final Object objA = new Object();
    //隊相比
    private final Object objB = new Object();

    public Object getObjA(){
        synchronized (objA){
            return objA;
        }
    }

    public Object getObjB(){
        synchronized (objB){
            return objB;
        }
    }

}

分拆鎖對於競爭並不激烈的鎖,能夠在效能和吞吐量方面產生一些純粹的改進,儘管這可能會在效能開始因為競爭而退化時增加負載的極限.

分拆鎖對於中等競爭強度的鎖,能夠切實地把它們大部分轉化成非競爭的鎖,這個結果是效能和可伸縮性都期望得到的.

分離鎖

分拆鎖對效能的改進有一些侷限性,不能大幅地提高多個處理器在同一系統中併發性的能力.

分拆鎖有時候可以被擴充套件,分成可大可小加鎖塊的集合,並且它們歸屬於相互獨立的物件,這樣的情況就是分離鎖.

分離鎖的一個負面作用是:對容器加鎖,進行獨佔訪問更加困難,並且更加昂貴了.

分拆鎖和分離鎖能夠改進可伸縮性,因為它們能夠使不同的執行緒操作不同的資料(或者相同資料結構的不同部分),而不會發生相互干擾.

能夠從分拆鎖收益的程式,通常是那些對鎖的競爭普遍大於對鎖守護資料競爭的程式.

例如: 一個鎖守護兩個獨立變數X和Y,執行緒A想要訪問X,而執行緒B想要訪問Y,這兩個執行緒沒有競爭任何資料,然而它們競爭相同的鎖.

獨佔鎖的替代方法

用於減輕競爭鎖帶來的影響的第三種技術是提前使用獨佔鎖,這有助於使用更友好的併發方式進行共享狀態的管理.

這包括:

  • 使用併發容器
  • 讀-寫鎖
  • 不可變物件
  • 原子變數

讀寫鎖

讀寫鎖實行了一個多讀者-單寫者(multiple-reader,single-write)加鎖規則:只要沒有改變,多個讀者可以併發訪問共享資源,但是寫者必須和獨佔獲得鎖.

對於多數操作都為讀操作的資料結構,ReadWriteLock與獨佔的鎖相比,可以提供更好的併發性.

對於只讀的資料結構,不變性可以完全消除加鎖的必要.

原子變數

原子變數類提供了針對整數或物件引用的非常精妙的原子操作,因此更具可伸縮性.

如果你的類只有少量熱點域(例如:多個方法都在呼叫的計數操作,就是一個熱點域),並且該類不參與其它變數的不變約束,那麼使用原子變數替代它可能會提高可伸縮性.

檢測CPU利用率

當我們測試可伸縮性的時候,我們的目標通常是保持處理器的充分利用.

Unix系統的vmstat和mpstat,或者Windows系統的perfmon都能夠告訴你處理器有多忙碌.

如果所有的CPU都沒有被均勻地利用(有時CPU很忙碌地執行,有時很清閒),那麼你的首要目標應該是增強你程式的並行性.

不均勻的利用率表名,大多數計算都有很小的執行緒集完成,你的應用程式將不能夠利用額外的處理器資源.

如果你的CPU沒有完全利用,你需要找出原因.有以下幾種:

  • 不充足的負載. 資料量不夠多
  • I/O限制
  • 外部限制.可能你的應用程式取決於外部服務,比如資料庫或者Web Service 那麼瓶頸可能不在於你自己的程式碼.
  • 鎖競爭. 使用Profiling工具能夠告訴你,程式中存在多少個鎖的競爭,哪些鎖很"搶手".或者使用執行緒轉儲,如果執行緒因等待鎖被阻塞,與執行緒轉儲的棧框架會宣告"waiting to lock monitor...".非競爭的鎖幾乎不會出現線上程轉儲中:競爭激烈的鎖幾乎總會只要有一個執行緒在等待獲得它,所以會頻繁出現線上程轉儲中.

向"物件池"說"不"

不要使用物件池,物件池跟執行緒池差不多,為了減少建立和銷燬物件的開銷,能夠重複使用物件,建立了一個物件池,但是現代的JVM物件的分配和垃圾回收已經非常快了.

如果使用物件池,那麼執行緒從池中請求物件,協調訪問池的資料結構的同步就成為必然了,這邊產生了執行緒阻塞的可能性.

又因為由鎖的競爭產生的阻塞,其代價比直接分配的代價多幾百倍,即使是很小的池競爭都會造成可伸縮性的瓶頸(甚至是非競爭的同步,其代價也會比分配一個物件大很多).

所以使用物件池有點得不償失了,反而效率更低.

比較Map的效能

單執行緒的時候ConcurrentHashMap的效能要比同步的HashMap的效能稍好一點,但是在併發應用中,這種作用就十分明顯了.

ConcurrentHashMap對get操作做了一些優化,提供最好的效能和併發性.

同步的Map對所用的操作用的都是一個鎖,所以同一時刻只有一個執行緒能夠訪問map.

而ConcurrentHashMap並沒有對成功的讀操作加鎖,只對寫操作和真正需要鎖的讀操作使用了分離鎖的方法.因此多執行緒能夠併發地訪問Map而不被阻塞.

image

隨著執行緒數的增加,併發的map吞吐量得到增長.看ConcurrentHashMap線上程數到達16的時候,它的吞吐量不在提高,因為它的內部使用的是16個分離鎖的陣列,可以支援16個執行緒同時寫,當執行緒多餘這個數量的時候,就得不到提升了(可以增加鎖的數量,提高並行性)

再看同步容器,執行緒數越多,反而吞吐量降低.

在對鎖的競爭小的境況下,每個操作花費的時間取決於真正工作的時間,吞吐量會因為執行緒數的增加而增加.

一旦競爭變得激烈,每個操作花費的時間就由上下文切換和排程延遲決定了,並且加入更多的執行緒不會對吞吐量有什麼幫助.

總結

  • Amdahl定律告訴我們,程式的可伸縮性是由必須連續執行的程式碼比例決定的.
  • Java程式中序列化首要的來源是獨佔的資源鎖,所以可伸縮性通常可以通過以下這些方式提升:
  1. 減少獲取鎖的時間
  2. 減少鎖的粒度
  3. 減少鎖的佔用時間
  4. 用非獨佔或非阻塞鎖來取代獨佔鎖