深入理解併發和並行

编码专家發表於2024-04-13

1 併發與並行

為什麼作業系統上可以同時執行多個程式而使用者感覺不出來?

因為作業系統營造出了可以同時執行多個程式的假象,透過排程程序以及快速切換CPU上下文,每個程序執行一會就停下來,切換到下個被排程到的程序上,這種切換速度非常快,人無法感知到,從而產生了多個任務同時執行的錯覺。

併發(concurrent) 是指的在宏觀上多個程式或任務在同時執行,而在微觀上這些程式交替執行,可以提高系統的資源利用率和吞吐量。

通常一個CPU核心在一個時間片只能執行一個執行緒(某些CPU採用超執行緒技術,物理核心數和邏輯核心數形成一個 1:2 的關係,比如4核CPU,邏輯處理器會有8個,可以同時跑8個執行緒),如果N個核心同時執行N個執行緒,就叫做並行(parallel)。我們編寫的多執行緒程式碼具備併發特性,而不一定會並行。因為能否並行取決於作業系統的排程,程式設計師無法控制,但是排程演算法會盡量讓不同執行緒使用不同的CPU核心,所以在實際使用中幾乎總是會並行。如果多個任務在一個核心中順序執行,就是序列(Serial),如下圖所示:


併發是多個程式在一段時間內同時執行的現象,而並行是多個任務在同一時刻同時執行,也是多核CPU的重要特性。

這裡有一個疑問:併發一定並行嗎?

併發並不一定並行。併發是邏輯上的同時發生,而並行是物理上的同時發生。併發可以跑在一個處理器上透過時間片進行切換,而並行需要兩個或兩個以上的執行緒跑在不同的處理器上。如果同一個任務的多個執行緒始終執行在不變的CPU核心上,那就不是並行。

舉一個生活中的例子:

  • 你吃飯吃到一半,電話來了,你一直到吃完了以後才去接,這就說明你不支援併發也不支援並行。
  • 你吃飯吃到一半,電話來了,你停了下來接了電話,接完後繼續吃飯,這說明你支援併發。
  • 你吃飯吃到一半,電話來了,你一邊打電話一邊吃飯,這說明你支援並行。

2 多核排程演算法

在多核CPU系統中,排程演算法的主要目標是有效地利用所有可用的CPU核心,以提高系統的整體效能和資源利用率。下面是一些常見的多核CPU排程演算法:

  1. 搶佔式排程(Preemptive Scheduling):這種排程演算法允許作業系統隨時中斷當前正在執行的任務,並將處理器分配給其他任務。在多核系統中,搶佔式排程器可以將任務遷移到其他核心上,以充分利用系統資源。
  2. 公平排程(Fair Scheduling):公平排程演算法旨在公平地分配CPU時間給系統中的所有任務,以確保每個任務都有機會在一定的時間內執行。在多核系統中,公平排程器通常會嘗試平衡各個核心上的負載,以避免出現某些核心過載而其他核心處於空閒狀態的情況。
  3. 負載均衡排程(Load Balancing):負載均衡排程演算法用於在多核系統中平衡各個核心上的任務負載,以確保所有核心都能夠充分利用。這可以透過將任務從負載較重的核心遷移到負載較輕的核心來實現,或者透過動態地將新任務分配給負載較輕的核心來實現。
  4. 優先順序排程(Priority Scheduling):優先順序排程演算法允許為每個任務分配一個優先順序,並根據優先順序來決定任務的執行順序。在多核系統中,可以根據任務的優先順序將其分配給不同的核心,以確保高優先順序任務優先得到執行。
  5. 混合排程(Hybrid Scheduling):混合排程演算法結合了多種排程策略的優點,以適應不同的應用場景和系統配置。例如,可以將公平排程演算法和負載均衡排程演算法結合起來,以在系統中實現公平且高效的任務排程。

這些排程演算法可以根據系統的需求進行組合和調整,以實現對多核CPU系統資源的有效管理和利用。

搶佔式排程(Preemptive Scheduling)的使用最為廣泛,它允許作業系統在任何時候中斷當前正在執行的任務,並將處理器分配給其他任務。這種排程策略使得作業系統能夠及時響應各種事件和請求,從而提高系統的響應性和實時性。

在搶佔式排程中,每個任務都被賦予一個優先順序,作業系統會根據任務的優先順序來決定哪個任務應該在當前時間片執行。如果某個高優先順序任務準備就緒並且當前正在執行的任務的優先順序低於它,作業系統會中斷當前任務的執行,並將處理器分配給高優先順序任務,從而實現搶佔。搶佔式排程的主要優點包括:

  1. 實時性:搶佔式排程允許作業系統及時地響應外部事件和請求,從而滿足實時性要求。
  2. 靈活性:作業系統可以根據任務的優先順序動態地調整任務的執行順序,以適應不同的系統負載和需求。
  3. 公平性:搶佔式排程可以確保高優先順序任務得到及時執行,而不會被低優先順序任務長時間佔用處理器。
  4. 多工併發:透過在任務之間進行快速的切換,搶佔式排程可以實現多工併發執行,從而提高系統的吞吐量和效率。

搶佔式排程也存在一些挑戰和限制:

  1. 上下文切換開銷:頻繁的任務切換會導致上下文切換的開銷增加,可能會影響系統的效能。
  2. 優先順序反轉:如果低優先順序任務持有某些資源而高優先順序任務需要訪問這些資源,可能會導致優先順序反轉問題,從而影響系統的實時性。
  3. 飢餓問題:如果某個任務的優先順序始終較低,並且總是被更高優先順序的任務搶佔,可能會導致該任務長時間無法執行,出現飢餓問題。

搶佔式排程在許多作業系統中得到了廣泛應用,包括Windows、Linux、MacOS等,它為實時系統和響應式系統提供了一種高效的任務排程機制。

3 Java並行程式設計

在編碼層面上看,採用Java語言建立多執行緒程式碼,不需要程式設計師打上並行的標記,因為為了充分利用計算資源,作業系統一定會盡可能排程多執行緒到不同的CPU核心上。併發的任務通常有多執行緒競爭資源和頻繁的CPU上下文切換,這些都會降低執行效率。

在實際的業務場景裡,許多計算任務其實互不干擾,最後彙總結果就可以了,比如統計不同使用者的每日活動次數。它們不存在競爭資源,並行處理的效率非常高,Java語言提供了多執行緒並行執行的 API。

3.1 Future

在Java併發程式設計中,Future是一種用於表示非同步計算結果的介面。它允許你提交一個任務並且在將來的某個時候獲取任務的結果。Future的原理是透過一個佔位符來表示非同步操作的結果,在任務完成之前,可以透過Future物件獲取佔位符,並且在需要的時候等待任務的完成並獲取結果。Future介面定義了非同步計算結果的標準,具體的非同步計算由實現了Future介面的類來執行,比如ExecutorService的submit方法會返回一個Future物件,用於跟蹤任務的執行狀態和結果。

Future提供了以下主要方法:

  • isDone():判斷任務是否已經完成。
  • cancel(boolean mayInterruptIfRunning):取消任務的執行。
  • get():獲取任務的執行結果,在任務完成之前會阻塞當前執行緒。
  • get(long timeout, TimeUnit unit):獲取任務的執行結果,但最多等待指定的時間,超時後會丟擲TimeoutException。

看看下面這個程式碼示例:

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
 
public class FutureParallelExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);
 
        Callable<Integer> task1 = () -> {
            // 模擬耗時計算
            Thread.sleep(2000);
            return 10;
        };
 
        Callable<Integer> task2 = () -> {
            // 模擬耗時計算
            Thread.sleep(3000);
            return 20;
        };
 
        Future<Integer> future1 = executor.submit(task1);
        Future<Integer> future2 = executor.submit(task2);
 
        // 非同步執行,繼續執行下面的程式碼
        System.out.println("Asynchronous computation is executing.");
 
        // 獲取第一個任務的結果
        Integer result1 = future1.get(); // 這將會阻塞直到任務1完成
        System.out.println("Task 1 result: " + result1);
 
        // 獲取第二個任務的結果
        Integer result2 = future2.get(); // 這將會阻塞直到任務2完成
        System.out.println("Task 2 result: " + result2);
 
        // 關閉ExecutorService
        executor.shutdown();
    }
}

在這個例子中,啟動了兩個非同步任務,並分別獲取了它們的 Future 物件。透過 Future.get() 方法,我們可以等待任務完成並獲取結果。ExecutorService 使用了一個固定的執行緒池,大小為2。這意味著兩個任務將會並行執行。

3.2 Fork / Join

Fork / Join 框架是Java 7中新增的併發程式設計工具,主要有兩個步驟,第一是fork:將一個大任務分成很多個小任務;第二是 join:將第一個任務的結果 join 起來,生成最後的結果。如果第一步中並沒有任何返回值,join將會等到所有的小任務都結束。

斐波那契數列由義大利數學家斐波那契首次提出,這個數列從第三項開始,每一項都等於前兩項之和,通常以遞迴方式定義,即F(0)=1,F(1)=1,對於n>=2的任何正整數n,F(n)=F(n-1)+F(n-2),數列的前幾個數字是1,1,2,3,5,8,13,21,34。我們嘗試使用遞迴計算第n項的數值,程式碼如下:

/**
* 遞迴實現斐波那契數列
**/
public class FibonacciRecursion {
    public static int fibonacciRecursive(int n) {
        if (n <= 1)
            return n;
        return fibonacciRecursive(n - 1) + fibonacciRecursive(n - 2);
    }
 
    public static void main(String[] args) {
        int n = 10;
        System.out.println("Fibonacci of " + n + " is " + fibonacciRecursive(n));
    }
}

以上程式碼輸出結果是:55。

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
/**
* fork/join實現斐波那契數列
**/
public class FibonacciFork extends RecursiveTask<Integer> {
    final int n;
 
    public FibonacciFork(int n) {
        this.n = n;
    }
 
    @Override
    protected Integer compute() {
        if (n <= 1)
            return n;
        FibonacciFork f1 = new FibonacciFork(n - 1);
        FibonacciFork f2 = new FibonacciFork(n - 2);
 
        f1.fork();
        return f2.compute() + f1.join();
    }
 
    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool();
        FibonacciFork fib = new FibonacciFork(10);
        Integer result = pool.invoke(fib);
        System.out.println(result);
    }
}

以上程式碼中,定義了RecursiveTask的子類FibonacciFork類,用於計算斐波那契數列的第n項。在main方法中,建立了一個ForkJoinPool並提交了任務執行。這個任務會遞迴地分解成更小的子任務,並且使用fork/join模式來並行處理這些子任務,最後透過join方法獲取子任務的結果並累加,輸出結果是:55。

3.3 Stream API

Java 8 加入了新特性 Stream API(叫做流式計算或並行流),極大地提升了處理集合資料的靈活性與效率。Stream API 簡化了集合操作的程式碼量,還透過 lambda 表示式增強了函數語言程式設計風格,核心邏輯是將資料集合分成多個小塊,然後在多個處理器上並行處理,最後將結果合併成一個結果集。Java Stream API 的底層原理主要涉及兩個方面:流的管道化操作和惰性求值:

  • 流的管道化操作:Java Stream API 提供了一種功能強大的管道化操作模式,可以透過一系列的中間操作和終端操作對資料進行處理。這些操作可以串聯起來形成一個流水線,每個中間操作都會生成一個新的流,而終端操作則會觸發實際的計算。這種管道化操作的設計允許開發者透過簡單的鏈式呼叫實現複雜的資料處理邏輯,同時也方便了 JVM 在內部進行最佳化,例如進行流的並行處理以提高效能。
  • 惰性求值:Java Stream API 採用了惰性求值的策略,也就是說中間操作並不會立即觸發實際的計算,而是在終端操作被呼叫時才開始進行計算。這種設計使得 Stream API 可以在需要的時候才對資料進行處理,從而避免了不必要的計算開銷。另外,惰性求值還使得 Stream API 具備了延遲特性,即使是處理大規模資料時也可以節省記憶體和計算資源。

我們來看看簡單的程式碼示例:

public class StreamDemo {

    public static void main(String[] args) {
        Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
              .reduce((a, b) -> a + b)
              .ifPresent(System.out::println); // 輸出結果:45
    }
}

上面程式碼建立了一個包含1到9整數的並行流,然後透過 reduce 方法計算所有數字的和,並列印結果。在預設情況下,這些操作是在單執行緒中按順序逐個執行的。

public class StreamParallelDemo {
    public static void main(String[] args) {
        Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
              .parallel()
              .reduce((a, b) -> a + b)
              .ifPresent(System.out::println);
    }
}

上面程式碼呼叫了 parallel() 後,reduce()方法內部邏輯發生了變化,它會根據當前執行緒池資源分配任務,並行地在不同的工作執行緒上執行累加操作,而不是序列執行的。

Java 並行流是基於 Fork/Join 框架實現的,它使用了多執行緒來處理流操作。在多核環境中,Fork/Join 框架會根據系統資源自動調整任務分配,儘可能多地利用空閒核心,更充分地發揮硬體潛力。比如,當CPU具有8個核心時,平行計算的耗時遠小於序列計算耗時的8倍,但是由於執行緒建立、銷燬以及上下文切換等開銷,實際效能提升並非線性。

平行計算並不總是適用於所有場景,特別是在資料集較小或者任務分解後產生的子任務粒度較小時,執行緒管理的開銷可能超過平行計算帶來的優勢。如果硬體只有單核或少核,則平行計算效果有限甚至可能會因執行緒切換而降低效率。綜合考慮以下因素:

  • 資料量:對於大規模資料集,尤其是需要複雜運算的任務,採用平行計算可以顯著提高執行速度。
  • 硬體配置:確保執行環境為多核處理器,不適用於 IO 密集型操作,僅適用於 CPU 密集型操作。
  • 任務性質:若任務可以輕鬆拆分為獨立的子任務,並且結果合併相對簡單,更適合應用平行計算。
  • 系統負載:在高負載系統中,要避免過度增加併發,以免引發資源競爭和瓶頸問題。

3.4 CompletableFuture

CompletableFuture是一個實現了Future介面的類,它提供了一種更加靈活和強大的方式來進行非同步程式設計。CompletableFuture可以用來表示一個非同步計算的結果,並且提供了豐富的方法來處理非同步操作的完成、組合多個非同步操作、處理異常等。CompletableFuture相比於傳統的Future介面,具有以下優勢:

  1. 更加靈活的方法鏈:CompletableFuture提供了一系列的方法,可以鏈式地進行非同步操作,比如thenApply、thenAccept、thenCompose等,使得程式碼更加簡潔清晰。
  2. 組合多個非同步操作:CompletableFuture允許你組合多個非同步操作,可以按照順序執行、並行執行,或者根據一定的條件來執行。
  3. 異常處理:CompletableFuture提供了exceptionally和handle等方法來處理非同步操作中的異常情況,使得異常處理變得更加靈活。
  4. 支援回撥函式:你可以透過thenApply、thenAccept等方法設定回撥函式,以便在非同步操作完成時執行特定的操作。
  5. 可程式設計式地完成非同步操作:CompletableFuture提供了complete、completeExceptionally等方法,可以手動地完成非同步操作,從而更加靈活地控制非同步任務的執行過程。

我們看看簡單的程式碼示例:

  • 簡單的非同步任務
import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {

    public static void main(String[] args) {
        CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
            // 非同步任務,返回結果為100
            return 100;
        });

        // 在任務完成後輸出結果
        future.thenAccept(result -> System.out.println("非同步任務結果為:" + result));
    }
}
  • 組合多個CompletableFuture
import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {

    public static void main(String[] args) {
        CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
            // 非同步任務1,返回結果為100
            return 100;
        });

        CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
            // 非同步任務2,返回結果為200
            return 200;
        });

        // 將兩個非同步任務的結果相加
        CompletableFuture<Integer> combinedFuture = future1.thenCombine(future2, (result1, result2) -> result1 + result2);

        // 在組合任務完成後輸出結果
        combinedFuture.thenAccept(result -> System.out.println("兩個非同步任務的結果之和為:" + result));
    }
}
  • 處理異常情況
import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {

    public static void main(String[] args) {
        CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
            // 模擬一個可能發生異常的非同步任務
            if (Math.random() < 0.5) {
                throw new RuntimeException("Oops! Something went wrong.");
            }
            return 100;
        });

        // 處理異常情況
        future.exceptionally(throwable -> {
            System.out.println("非同步任務發生異常:" + throwable.getMessage());
            return null; // 返回預設值或者做其他處理
        });

        // 在任務完成後輸出結果
        future.thenAccept(result -> System.out.println("非同步任務結果為:" + result));
    }
}
  • 自定義執行緒池
ExecutorService executorService = Executors.newSingleThreadExecutor();
        CompletableFuture<Void> voidCompletableFuture = CompletableFuture.runAsync(() -> {
            try {
                System.out.println("執行非同步操作。。。");
                Thread.sleep((long) (Math.random() * 1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, executorService);
        System.out.println("結果:"+voidCompletableFuture.get());

這些示例展示了使用CompletableFuture進行非同步程式設計的一些常見用法,包括簡單的非同步任務、組合多個CompletableFuture、處理異常情況等。總的來說,CompletableFuture是Java併發程式設計中一個強大而靈活的工具,它使得非同步程式設計變得更加簡單、清晰和可控。

4 總結

要更好地掌握Java併發程式設計技能,可以採取以下幾個步驟:

  1. 學習基礎知識: 對Java併發程式設計的基本概念和術語有清晰的理解,比如執行緒、鎖、同步、併發問題等。可以透過閱讀相關的書籍、教程或者線上課程來學習。
  2. 熟悉併發工具類: Java提供了豐富的併發工具類,比如 ThreadRunnableExecutorThreadPoolExecutorSemaphoreCountDownLatch等。深入瞭解這些工具類的使用方法和特性,以及在不同場景下的應用。
  3. 掌握多執行緒程式設計: 多執行緒程式設計是Java併發程式設計的核心,要熟練掌握如何建立執行緒、管理執行緒生命週期、執行緒同步和通訊等技術。瞭解執行緒的狀態、優先順序、排程方式等概念,以及如何避免常見的多執行緒問題,比如死鎖、競態條件等。
  4. 深入理解併發模型: 瞭解併發模型,比如共享記憶體模型和訊息傳遞模型,以及它們的優缺點。掌握在這些模型下如何設計和實現併發程式。
  5. 學習併發設計模式: 掌握常見的併發設計模式,比如生產者-消費者模式、讀寫鎖模式、工作竊取模式等。瞭解這些模式的原理和實現方式,以及在實際專案中的應用。
  6. 實踐專案經驗: 透過實際專案來鍛鍊併發程式設計技能,嘗試在專案中應用所學的知識解決實際的併發問題。可以選擇一些開源專案或者自己構建小型專案來練習。

參考

https://zhuanlan.zhihu.com/p/622768247
https://www.cnblogs.com/badaoliumangqizhi/p/17021500.html
https://blog.csdn.net/weixin_44073836/article/details/123346035
https://blog.csdn.net/m0_71149992/article/details/125327370
https://www.php.cn/faq/530720.html
https://zhuanlan.zhihu.com/p/424501870
https://zhuanlan.zhihu.com/p/339472446
https://zhuanlan.zhihu.com/p/622218563

相關文章