1. 程式人生 > 實用技巧 >併發與並行

併發與並行

併發與並行

學習併發程式設計之初好像就一直對這個問題含混不清,在閱讀《Java8實戰》以及網路資源的時候對這個問題有了更進一步的認識,特此梳理一下

什麼是併發、並行?

這裡引用Java8實戰中的一張圖片來加以說明

可能從上圖簡單來看,併發是單處理器核心多工的交替執行,並行是多工多處理器核心的同時執行,由於這個問題並沒有被蓋棺定論規範化,導致可能不同的人有不同的理解,我也並不能給出一個嚴格意義上準確的定義,但是我綜合他人的觀點給出的自己的定義如下,並行是併發的一種表現形式,併發只強調兩個任務的生命週期存在交集,即對用上面的任務1開始到結束的過程中,如果任務2也開始了,那麼我們就認為任務1和任務2是併發的。但是今天想梳理的並不是嚴格意義上的區分這兩個關聯緊密的概念,而是討論這兩者能夠給我們的程式帶來什麼?

併發更加側重於壓榨單個CPU的效能,以提高CPU利用率為目的,對於一串任務(task1,task2,task3...)高併發並不能加快這些任務總體完成的時間,甚至由於執行緒切換還會延長任務總體完成的時間,所以它並不是以提高整體響應速率為目的的,而並行它使得多個任務(如果不相干,簡化討論,避免多核之間的一致性要求)可以在多個處理器核中得到真正的同時處理,而這個時候對於一系列的不相干任務來說,利用平行計算,就能大大縮短整體的響應時間

單執行緒併發能夠提高任務的總體處理速度嘛?

答案是顯然的,不能,而且由於執行緒切換帶來的資源開銷,單執行緒併發還會延長整個任務的處理時間?

單執行緒併發還有必要嘛?

有必要,而且非常有必要,首先我們假定有四個任務1,2,3,4如下,每個任務的執行耗時1個單位時間,如果按照單執行緒序列的執行方式,它應該是這樣的

對於task1來說,它還能接收,畢竟執行1個單位時間它就拿到了它想要的結果,但是對於後面的task來說就不滿意了,特別是task4來說,執行task4的耗時為1個單位時間,但是它需要等4個單位時間才能拿到結果,如果在多執行緒情況下,它是如何的呢?假設每個task都另起了一個執行緒,且不考慮作業系統任務排程耗時等等,現在的處理情況是這樣的

假如理想狀況下,每個任務被切割得足夠小,那麼最終每個任務幾乎是同時開始同時結束,那麼每個task的用時就是總耗時的平均值也就是2.5,這下task4總算開心了,它不用等那麼久了。

但是實際問題中,不可能把任務無限切分,作業系統的執行緒排程也是耗時操作,那麼上面的結論就不一定那麼可靠了,甚至可能每個時間都超過3了,那還不如序列呢,至少task1和task2爽了,那為什麼還需要併發呢?

因為實際狀況下,每個任務的執行速度也不可能完全相等,每個任務執行的速度有快有慢,我們現在假設task1執行用時需要1000個單位時間,如果在序列情況下,task1後面的所有任務都會被task1所拖累,需要等待的時間為1000加,而此時的併發執行策略中,雖然由於系統排程等等開銷,task2,3,4仍然可以以一個與之前速度相差無幾的時間響應,task1帶來的惡劣影響也單單隻影響到了自己。我們上面的策略也就類似於tomcat對於請求的處理策略,針對每個請求都另起一個執行緒processor來處理。

tomcat都這麼厲害了,自己的程式碼中還有必要多執行緒嘛?

有必要,通常一個大任務是由多個小任務組合而成,如果按照CPU密集型和I/O密集型來劃分任務型別的話,對於CPU密集型任務來說,無論我們再怎麼多執行緒瘋狂操作也好,在單核處理器中,最終都需要依靠處理器來做運算,多執行緒的開銷無疑延長了整個任務的處理時間,但是在I/O密集型任務情況下(包括磁碟IO,網路IO),假設你發起了10次網路IO,發起了10個不同的RPC呼叫,無疑多執行緒的方式能夠讓你同時發起多個請求,多個請求同時等待被呼叫方的響應以及網路延遲,否則你就只能按照序列的方式,每個請求都需要等一個時延,然後再處理下一個請求,但其實這樣的併發其實更加類似於並行,因為你發起的遠端呼叫是另一個處理器去幫你處理的,我們所做的只不過是利用併發再一個請求傻等著的過程中又發起了另一個請求罷了

並行

並行的好處是顯而易見的,多個處理器幹活肯定是快於一個人幹活的,對於上面討論的情況,如果在多核心的處理器下,併發之後可能整個處理過程就是並行的,小的任務可以在多個處理器核心中同時執行,在這裡也不太過多討論併發安全的問題,主要討論如何高效並行

在tomcat中想要並行很簡單,你併發就好,如果你有多個處理器核心它自然會並行執行,可能並不太需要我們對整個處理過程進行並行處理,關注更多的是不同請求之間的並行,但是在一些場景下,可能就需要我們關注整個任務本身的並行,這時候並行就不那麼容易,假設你要計算1-1000000000的和,你當然可以選擇併發執行,自己分割每個處理器計算多少到多少的和,然後自行彙總結果,就想下面的程式碼一樣

public class ConcurrentVsParallel {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //序列
        long sum=0;
        long time1=System.currentTimeMillis();
        for (long i = 1; i <= 10000000000L; i++) {
            sum+=i;
        }
        System.out.println("序列計算結果為:"+sum);
        System.out.println("序列耗時:"+(System.currentTimeMillis()-time1));
        long time2= System.currentTimeMillis();
        long res = concurrentCal(10000000000L);
        System.out.println("計算結果為:"+res);
        System.out.println("並行耗時為:"+(System.currentTimeMillis()-time2));
    }

    public static long concurrentCal(final long n) throws ExecutionException, InterruptedException {
        //4等分來處理
        ExecutorService executor = Executors.newFixedThreadPool(4);
        long quarter=n/4;
        long allSum=0;
        Future[] parts = new Future[4];
        for (int i = 0; i < 4L; i++) {
            final int temp=i;
            Future<Long> partSum = executor.submit(() -> {
                long sum = 0;
                for (long j = temp * quarter + 1; j <= (temp + 1) * quarter; j++) {
                    sum += j;
                }
                return sum;
            });
            parts[i]=partSum;
        }
        for (int i = 0; i < parts.length; i++) {
            allSum+=(long)parts[i].get();
        }
        return allSum;
    }
}

輸出結果如下:

序列計算結果為:-5340232216128654848
序列耗時:4617
計算結果為:-5340232216128654848
並行耗時為:1847

上述的程式碼能夠實現我們既定的目標,但是存在著可讀性和可拓展性的問題,效能也存在著問題,如果需要對(2-n)求和呢,很簡單,給我們的程式碼加入一個start即可,但是如果需要對(2-n)中所有的偶數求和呢?豈不是又需要改程式碼,更加嚴重的問題是任務規模的劃分是定下來的,當然你也可以再新增一個引數設定任務規模的劃分,但是上述這些操作都會導致程式碼的膨脹和難以維護,利用java8的Stream可以做如下簡單實現

long time3=System.currentTimeMillis();
long res = LongStream.rangeClosed(1, 10000000000L).parallel().sum();
System.out.println("stream計算結果為:"+res);
System.out.println("stream耗時為:"+(System.currentTimeMillis()-time3));

結果如下:

序列計算結果為:-5340232216128654848
序列耗時:4631
stream計算結果為:-5340232216128654848
stream耗時為:3605

雖然這裡的耗時可能比不過我們直接手動劃分,併發的方式去進行計算,但是這裡的程式碼可讀性以及簡潔讀是非常好的,誠然這個結果也受限於我僅僅只有四核的垃圾筆記本,無論如何通過Stream的方式,java在並行方面的能力也是非常強的

參考資料

《Java8實戰》

https://www.zhihu.com/question/37396742

https://www.zhihu.com/question/33515481