前面文章中我們講過為了充分利用現代多核計算機算力來提供應用程式的執行效率,一般情況下我們會採用多線程並發執行模型來編寫。其中為了設計好此類程序我們首先需要識別出應用程式的分類是計算密集型還是I/O密集型應用程式。
如果是計算密集型應用程式,由於CPU只需要讀取相關的數據進行計算即可,所以不適合設計成線程數多餘計算機的內核數的模型,因為如此會造成線程上下文的頻繁切換而影響性能,反而是根據計算機的內核數來啟動線程數反而更能夠提高該類應用程式在該機器集群上執行的效率。
如果是輸入/輸出密集型應用程式,由於輸入/輸出操作過程都是由作業系統調度輸入/輸出控制設備來完成的,CPU只負責相關事件相應,同時又由於輸入輸出控制設備讀取外部數據的速度遠低於CPU運行的速度,所以,此類程序執行必然面對這頻繁的上下文切換,而不可能讓一個線程阻塞等待回復。也就是說,I/O密集型應用應該採用設計多餘內核數的線程,來讓所有的CPU內核不間斷的執行事件相應,最好是線程池裡線程數遠多於CPU核數,如此可以提供CPU的利用率。
從上面的例子說明在某些情況下,為應用程式添加新資源可以提高應用程式的整體性能。
其實我們在現實的開發過程中,在某些情況下,添加某些資源並不能帶來相應的應用程式性能的提升。
那麼是為什麼呢?
我們知道在多核CPU上,多個線程可以並行執行,從而縮短執行時間,提高執行效率,但是並不是所有的應用程式都適合併行執行,而且大多數應用程式的流程都是串行和並行代碼混合型的。對於需要串行執行的代碼,是不可能由多個線程來同時執行的,也就是說,一個應用程式的效率提升一般都是在與能夠並行執行部分決定的。
所以,為了能夠計算我們的應用程式在添加更多資源時可以獲得多少性能,我們需要確定程序中必須運行序列化/同步的部分,以及程序中可以並行運行的部分。
對於衡量一個應用程式的可並行程度方面,有一個著名的阿姆達爾定律:
它假設我們在一個可用CPU內核數為 N 的伺服器上執行一個應用程式,應用程式的所有代碼中只能順序執行的代碼行數所占代碼總行數的比例為B,我們使用阿姆達爾定律可以估算出我們能夠提高效率的上限。
如下是計算一個上限的加速我們的應用程式可以實現:
如果我們讓n趨於無窮,也就是說CPU核數有無限個,(1-B)/n將無限的接近於0。
因此,此時我們可以忽略這一項,如此上面的公式就無限接近於1/B,我們知道這裡的B是未執行優化處理的代碼中不可並行的代碼行數占比。
例如,如果B是0.5,也就是或我們的應用程式的代碼行數中一半不能並行化,則0.5的倒數是2。
由此我們可以看出,即使我們向應用程式添加無限數量的處理器,我們也只能獲得大約2個處理器內核的加速。
假設我們可以重寫代碼,讓應用程式運行時需要同步執行的比例變為0.25。
那麼由於0.25的倒數是4,這意味著我們已經構建了一個應用程式,它運行在大量處理器內核上的速度大約是只有一個處理器內核時的4倍。
反過來,我們也可以使用阿姆達爾定律來計算程序運行時中必須同步執行以達到給定加速的部分。
如果我們想實現大約100的加速,其倒數值是0.01,這意味著我們應該只設計大約1%的代碼執行同步或者串行運行。
從上面對阿姆達爾定律的應用發現,我們可以得出這樣的結論:
我們的程序通過使用增加額外處理能力所能獲得的最大速度提升是受到程序在同步代碼部分所花費的時間的倒數值的限制。
當然,在我們實際的操作中,這個分數通常是不容易準確計算的,更不能預估我們要開發的應用程式的業務大小,但這個定律給了我們一個提示就是我們在應用程式開發過程中必須非常仔細地考慮同步,並且儘量的必須讓程序的序列化運算部分足夠小,以此獲得更好的性能提升。
我們知道了向應用程式添加更多的線程可以提高性能和響應能力。
但另一方面,添加更多線程也是有成本的,因為線程本身總會對性能產生一些影響。
第一個影響性能的地方是線程本身的創建,這個創建過程會消耗一些時間,因為JVM必須從底層作業系統獲取線程的資源,並在調度程序中準備數據結構,並將決定接下來執行哪個線程。
如果我們使用跟處理器核數相同的線程數,那麼每個線程都可以在自己的處理器上運行,並且不會經常被中斷。
實際上,當應用程式運行時,作業系統本身當然可能也需要處理器來處理它自己的計算。
因此,在這種情況下,線程也會被中斷,並且必須等待,直到作業系統允許它們再次運行。
當我們必須使用比CPU內核更多的線程時,情況會變得更糟。因為在這種情況下,調度程序需要不斷的中斷線程,以便讓另一個線程執行它的代碼。
這種執行使得作業系統需要保存正在運行的線程的當前狀態,然後恢復計劃運行的線程的狀態。
除此之外,調度器本身還必須對其內部數據結構執行一些更新,讓這些數據結構再次使用CPU算力。
總之,這意味著從一個線程到另一個線程的每個上下文切換都會消耗CPU的算力,因此與單線程解決方案相比,過多線程將會導致性能下降。
擁有多個線程的另一個代價是需要同步對共享數據結構的訪問。
我們都知道在開發多線程並發訪問共享資源的程序中,除了可以使用synchronized關鍵字之外,我們還可以使用volatile在多個線程之間共享數據。
如果多個線程競爭共享的數據,競爭就會產生。而我們的JVM就必須決定接下來執行哪個線程。
如果可以訪問的線程不是當前線程的話,就會中斷當前線程,引入要執行的線程,這一操作就會引入上下文切換的成本。
而當前線程就必須等待,直到它可以再次獲得可以訪問資源的鎖。當然,JVM可以自行決定如何實現這種等待。
如果鎖獲取鎖的預期時間很小時,採用自旋等待,也就是線程嘗試一次又一次地獲取鎖,可能比掛起線程並讓另一個線程占用CPU時所需的上下文切換更有效。
獲取鎖後,JVM需要將等待的線程切換會執行,這個過程需要另外一個上下文切換,這肯定會為鎖爭用增加額外的成本。
因此,由於存在鎖的爭用問題,減少必須的上下文切換次數是合理的。
本文我們簡單介紹了在多線程並發編程設計時對於增加線程數給應用程式性能提升上限的估算,介紹了阿姆達爾定律的使用,即我們要關注的參數是可用的CPU內核數或者集群處理器數,代碼中需要同步的代碼行數比例估值,可用固定一個參數去看其它參數的變化對於性能提升帶來的好處。