併發開篇——帶你從0到1建立併發知識體系的基石

一無是處的研究僧 發表於 2022-07-18

併發開篇——帶你從0到1建立併發知識體系的基石

前言

在本篇文章當中主要跟大家介紹併發的基礎知識,從最基本的問題出發層層深入,幫助大家瞭解併發知識,並且打好併發的基礎,為後面深入學習併發提供保證。本篇文章的篇章結構如下:

併發開篇——帶你從0到1建立併發知識體系的基石

併發的需求

  • 我們常用的軟體就可能會有這種需求,對於一種軟體我們可能有多重需求,程式可能一邊在執行一邊在後臺更新,因此在很多情況下對於一個程式或者一個任務來說可能想要同時執行兩個不同的子任務,因此就需要在一個程式當中產生多個子執行緒,不同的執行緒執行不同的任務。
  • 現在的機器的CPU核心個數一般都有很多個,比如現在一般的電腦都會有4個CPU,而每一個CPU在同一個時刻都可以執行一個任務,因此為了充分利用CPU的計算資源,我們可以讓這多個CPU同時執行不同的任務,讓他們同時工作起來,而不是空閒沒有事可做。

併發開篇——帶你從0到1建立併發知識體系的基石

  • 還有就是在科學計算和高效能運算領域有這樣的需求,比如矩陣計算,如果一個執行緒進行計算的話需要很長的時間,那麼我們就可能使用多核的優勢,讓多個CPU同時進行計算,這樣一個計算任務的計算時間就會比之前少很多,比如一個任務單執行緒的計算時間為24小時,如果我們有24個CPU核心,那麼我們的計算任務可能在1-2小時就計算完成了,可以節約非常多的時間。

併發的基礎概念

在併發當中最常見的兩個概念就是程式和執行緒了,那什麼是程式和執行緒呢?

  • 程式簡單的說來就是一個程式的執行,比如說你再windows作業系統當中雙擊一個程式,在linux當中在命令列執行一條命令等等,就會產生一個程式,總之程式是一個獨立的主體,他可以被作業系統排程和執行。
  • 而執行緒必須依賴程式執行,只有在程式當中才能產生執行緒,現在通常會將執行緒稱為輕量級程式(Light Weight Process)。一個程式可以產生多個執行緒,二者多個執行緒之間共享程式當中的某些資料,比如全域性資料區的資料,但是執行緒的本地資料是不進行共享的。

併發開篇——帶你從0到1建立併發知識體系的基石

你可能會聽過程式是資源分配的基本單位,這句話是怎麼來的呢?在上面我們已經提到了執行緒必須依賴於程式而存在,在我們啟動一個程式的時候我們就會開啟一個程式,而這個程式會像作業系統申請資源,比如記憶體,磁碟和CPU等等,這就是為什麼作業系統是申請資源的基本單位。

你可能也聽過執行緒是作業系統排程的基本單位。那這又是為什麼呢?首先你需要明白CPU是如何工作的,首先需要明白我們的程式會被編譯成一條條的指令,而這些指令會存在在記憶體當中,而CPU會從記憶體當中一一的取出這些指令,然後CPU進行指令的執行,而一個執行緒通常是執行一個函式,而這個函式也是會被編譯成很多指令,因此這個執行緒也可以被CPU執行,因為執行緒可以被作業系統排程,將其放到CPU上進行執行,而且沒有比執行緒更小的可以被CPU排程的單位了,因此說執行緒是作業系統排程的基本單位。

Java實現併發

繼承Thread類

public class ConcurrencyMethod1 extends Thread {

    @Override
    public void run() {
        // Thread.currentThread().getName() 得到當前正在執行的執行緒的名字
        System.out.println(Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            // 新開啟一個執行緒
            ConcurrencyMethod1 t = new ConcurrencyMethod1();
            t.start();// 啟動這個執行緒
        }
    }
}
// 某次執行輸出的結果(輸出的順序不一定)
Thread-0
Thread-4
Thread-1
Thread-2
Thread-3

上面程式碼當中不同的執行緒需要得到CPU資源,在CPU當中被執行,而這些執行緒需要被作業系統排程,然後由作業系統放到不同的CPU上,最終輸出不同的字串。

併發開篇——帶你從0到1建立併發知識體系的基石

使用匿名內部類實現runnable介面

public class ConcurrencyMethod2 extends Thread {

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    // Thread.currentThread().getName() 得到當前正在執行的執行緒的名字
                    System.out.println(Thread.currentThread().getName());
                }
            });
            thread.start();
        }
    }
}
// 某次執行輸出的結果(輸出的順序不一定)
Thread-0
Thread-1
Thread-2
Thread-4
Thread-3

當然你也可以採用Lambda函式去實現:

public class ConcurrencyMethod3 {

    public static void main(String[] args) {
        for (int i=0; i < 5; i++) {
            Thread thread = new Thread(() -> {
                System.out.println(Thread.currentThread().getName());
            });
            thread.start();
        }
    }
}
// 輸出結果
Thread-0
Thread-1
Thread-2
Thread-4
Thread-3

其實還有一種JDK給我們提供的方法去實現多執行緒,這個點我們在後文當中會進行說明。

理解主執行緒和Join函式

假如現在我們有一個任務,子執行緒輸出一下自己的執行緒的名字,線上程輸出完自己的名字之後,主執行緒再輸出字串“執行緒執行完成”。

在完成上面的任務之前,首先我們需要明白什麼是主執行緒和子執行緒,所謂主執行緒就是在執行Java程式的時候不是通過new Thread操作這樣顯示的建立的執行緒。比如在我們的非併發的程式當中,執行程式的執行緒就是主執行緒。

public class MainThread {

    public static void main(String[] args) {
        System.out.println("我是主執行緒");
    }
}

比如在上面的程式碼當中執行語句System.out.println("我是主執行緒");的執行緒就是主執行緒。

public class MainThread {

    public static void main(String[] args) {
        // 下面這段程式碼是由主執行緒執行的
        // 主執行緒通過下面這段程式碼建立一個子執行緒
        Thread thread = new Thread(() -> {
            System.out.println("我是主執行緒建立的子執行緒");
        });
        // 這句程式碼也是主執行緒執行的
        // 主要意義就是主執行緒啟動子執行緒
        thread.start();
        System.out.println("我是主執行緒");
    }
}

現在我們再來看一下我們之前的任務:

假如現在我們有一個任務,子執行緒輸出一下自己的執行緒的名字,線上程輸出完自己的名字之後,主執行緒再輸出字串“執行緒執行完成”。

上面的任務很明確就是主執行緒在執行輸出自己執行緒的名字的語句必之前,須等待子執行緒執行完成,而在Java執行緒當中給我提供了一種方式,幫助我們實現這一點,可以保證主執行緒的某段程式碼可以在子執行緒執行完成之後再執行。

public class MainThread {

    public static void main(String[] args) throws InterruptedException {
        // 下面這段程式碼是由主執行緒執行的
        // 主執行緒通過下面這段程式碼建立一個子執行緒
        Thread thread = new Thread(() -> {
            System.out.println(Thread.currentThread().getName());
        });
        // 這句程式碼也是主執行緒執行的
        // 主要意義就是主執行緒啟動子執行緒
        thread.start();
        // 這句程式碼的含義就是阻塞主執行緒
        // 直到 thread 的 run 函式執行完成
        thread.join();
        System.out.println(Thread.currentThread().getName());
    }
}
// 輸出結果
Thread-0
main

上面程式碼的執行流程大致如下圖所示:

併發開篇——帶你從0到1建立併發知識體系的基石

我們需要知道的一點是thread.join()這條語句是主執行緒執行的,它的主要功能就是等待執行緒thread執行完成,只有thread執行完成之後主執行緒才會繼續執行thread.join()後面的語句。

第一個併發任務——求\(x^2\)的積分

接下來我們用一個例子去具體體會併發帶來的效果提升。我們的這個例子就是求函式的積分,我們的函式為最簡單的二次函式\(x^2\),當然我們就積分(下圖當中的陰影部分)完全可以根據公式進行求解(如果你不懂積分也沒有關係,下文我們會把這個函式寫出來,不會影響你對併發的理解):

\[\int_0^{10} x^2\mathrm{d}x = \frac{1}{3}x^3+C \]

併發開篇——帶你從0到1建立併發知識體系的基石

但是我們用程式去求解的時候並不是採用上面的方法,而是使用微元法:

\[\int_0^{10} x^2\mathrm{d}x =\sum_{ i= 0}^{1000000}(i * 0.00001) ^2 * 0.00001 \]

併發開篇——帶你從0到1建立併發知識體系的基石

下面我們用一個單執行緒先寫出求\(x^2\)積分的程式碼:

public class X2 {

  public static double x2integral(double a, double b) {
    double delta = 0.001;
    return x2integral(a, b, delta);
  }

  /**
   * 這個函式是計算 x^2 a 到 b 位置的積分
   * @param a 計算積分的起始位置
   * @param b 計算積分的最終位置
   * @param delta 表示微元法的微元間隔
   * @return x^2 a 到 b 位置的積分結果
   */
  public static double x2integral(double a, double b, double delta) {
    double sum = 0;
    while (a <= b) {
      sum += delta * Math.pow(a, 2);
      a += delta;
    }
    return sum;
  }

  public static void main(String[] args) {
    // 這個輸出的結果為 0.3333333832358528
    // 這個函式計算的是 x^2 0到1之間的積分
    System.out.println(x2integral(0, 1, 0.0000001));
  }
}

上面程式碼當中的函式x2integral主要是用於計算區間\([a, b]\)之間的二次函式\(x^2\)的積分結果,我們現在來看一下如果我們想計算區間[0, 10000]之間的積分結果且delta = 0.000001需要多長時間,其中delta表示每一個微元之間的距離。

public static void main(String[] args) {
    long start = System.currentTimeMillis();
    System.out.println(x2integral(0, 10000, 0.000001));
    long end = System.currentTimeMillis();
    System.out.println(end - start);
}

從上面的結果來看計算區間[0, 10000]之間的積分結果且delta = 0.000001,現在假設我們使用8個執行緒來做這件事,我們改如何去規劃呢?

因為我們是採用8個執行緒來做這件事兒,因此我們可以將這個區間分成8段,每個執行緒去執行一小段,最終我們將每一個小段的結果加起來就行,整個過程大致如下。

併發開篇——帶你從0到1建立併發知識體系的基石

首先我們先定義一個繼承Thread的類(因為我們要進行多執行緒計算,所以要繼承這個類)去計算區間[a, b]之間的函式\(x^2\)的積分:

class ThreadX2 extends Thread {
    private double a;
    private double b;
    private double sum = 0;
    private double delta = 0.000001;

    public double getSum() {
        return sum;
    }

    public void setSum(double sum) {
        this.sum = sum;
    }

    public double getDelta() {
        return delta;
    }

    public void setDelta(double delta) {
        this.delta = delta;
    }

    /**
   * 重寫函式 run
   * 計算區間 [a, b] 之間二次函式的積分
   */
    @Override
    public void run() {
        while (a <= b) {
            sum += delta * Math.pow(a, 2);
            a += delta;
        }
    }

    public double getA() {
        return a;
    }

    public void setA(double a) {
        this.a = a;
    }

    public double getB() {
        return b;
    }

    public void setB(double b) {
        this.b = b;
    }
}

我們最終開啟8個執行緒的程式碼如下所示:

public static void main(String[] args) throws InterruptedException {
	// 單執行緒測試計算時間
    System.out.println("單執行緒");
    long start = System.currentTimeMillis();
    ThreadX2 x2 = new ThreadX2();
    x2.setA(0);
    x2.setB(1250 * 8);
    x2.start();
    x2.join();
    System.out.println(x2.getSum());
    long end = System.currentTimeMillis();
    System.out.println("花費時間為:" + (end - start));
    System.out.println("多執行緒");
	
    // 多執行緒測試計算時間
    start = System.currentTimeMillis();
    ThreadX2[] threads = new ThreadX2[8];
    for (int i = 0; i < 8; i++) {
        threads[i] = new ThreadX2();
        threads[i].setA(i * 1250);
        threads[i].setB((i + 1) * 1250);
    }
    // 這裡要等待每一個執行緒執行完成
    // 因為只有執行完成才能得到計算的結果
    for (ThreadX2 thread : threads) {
        thread.start();
    }
    for (ThreadX2 thread : threads) {
        thread.join();
    }
    end = System.currentTimeMillis();
    System.out.println("花費時間為:" + (end - start));
    double ans = 0;
    for (ThreadX2 thread : threads) {
        ans += thread.getSum();
    }
    System.out.println(ans);
}
// 輸出結果
單執行緒
3.333332302493948E11
花費時間為:14527
多執行緒
花費時間為:2734
3.333332303236695E11
單執行緒 多執行緒(8個執行緒)
計算結果 3.333332302493948E11 3.333332303236695E11
執行時間 14527 2734

從上面的結果來看,當我們使用多個執行緒執行的時候花費的時間比單執行緒少的多,幾乎減少了7倍,由此可見併發的“威力”。

FutureTask機制

在前文和程式碼當中,我們發現不論是我們繼承自Thread類或者寫匿名內部內我們都沒有返回值,我們的返回值都是void,那麼如果我們想要我們的run函式有返回值怎麼辦呢?JDK為我們提供了一個機制,可以讓執行緒執行我們指定函式並且帶有返回值,我們來看下面的程式碼:

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class FT {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        FutureTask<Integer> task = new FutureTask<>(new MyCallable());
        Thread thread = new Thread(task);
        thread.start();
        // get 函式如果結果沒有計算出來
        // 主執行緒會在這裡阻塞,如果計算
        // 出來了將返回結果
        Integer integer = task.get();
        System.out.println(integer);
    }
}

class  MyCallable implements Callable<Integer> {

    @Override
    public Integer call() throws Exception {
        System.out.println("執行緒正在執行");
        return 101;
    }
}
// 輸出結果
執行緒正在執行
101

併發開篇——帶你從0到1建立併發知識體系的基石

從上面的繼承結構我們可以看出FutureTask實現了Runnable介面,而上面的程式碼當中我們最終會將一個FutureTask作為引數傳入到Thread類當中,因此執行緒最終會執行FutureTask當中的run方法,而我們也給FutureTask傳入了一個Callable介面實現類物件,那麼我們就可以在FutureTask當中的run方法執行我們傳給FutureTaskCallable介面中實現的call方法,然後將call方法的返回值儲存下來,當我們使用FutureTaskget函式去取結果的時候就將call函式的返回結果返回回來,在瞭解這個過程之後你應該可以理解上面程式碼當中FutureTask的使用方式了。

需要注意的一點是,如果我們在呼叫get函式的時候call函式還沒有執行完成,get函式會阻塞呼叫get函式的執行緒,關於這裡面的實現還是比較複雜,我們在之後的文章當中會繼續討論,大家現在只需要在邏輯上理解上面使用FutureTask的使用過程就行。

總結

在本篇文章當中主要給大家介紹了一些併發的需求和基礎概念,並且使用了一個求積分的例子帶大家切身體會併發帶來的效果提升,並且給大家介紹了在Java當中3中實現併發的方式,並且給大家梳理了一下FutureTask的方法的大致工作過程,幫助大家更好的理解FutureTask的使用方式。除此之外給大家介紹了join函式,大家需要好好去理解這一點,仔細去了解join函式到底是阻塞哪個執行緒,這個是很容搞錯的地方。

以上就是本文所有的內容了,希望大家有所收穫,我是LeHung,我們下期再見!!!(記得點贊收藏哦!)


更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。