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

来源:https://www.cnblogs.com/Chang-LeHung/archive/2022/07/18/16491719.html
-Advertisement-
Play Games

在本篇文章當中主要跟大家介紹併發的基礎知識,從最基本的問題出發層層深入,幫助大家瞭解併發知識,並且打好併發的基礎!!! ...


前言

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

併發的需求

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

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

併發的基礎概念

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

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

你可能會聽過進程是資源分配的基本單位,這句話是怎麼來的呢?在上面我們已經提到了線程必須依賴於進程而存在,在我們啟動一個程式的時候我們就會開啟一個進程,而這個進程會向操作系統申請資源,比如記憶體,磁碟和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上,最終輸出不同的字元串。

使用匿名內部類實現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

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

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

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

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

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

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

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

下麵我們用一個單線程先寫出求\(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段,每個線程去執行一小段,最終我們將每一個小段的結果加起來就行,整個過程大致如下。

首先我們先定義一個繼承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

從上面的繼承結構我們可以看出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、電腦系統基礎、演算法與數據結構)知識。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 巨集 #define命令是C語言中的一個巨集定義命令,它用來將一個標識符定義為一個字元串,該標識符被稱為巨集名,被定義的字元串稱為替換文本. 使用巨集時是簡單的代碼段替換. #define的概念 簡單的巨集定義 #define <巨集名> <字元串> 例: #define PI 3.1415926 註:使用簡單 ...
  • 源碼地址 https://gitee.com/bin-0821/chat-room-demo-go-websocket 關於websocket,上一篇文章講述瞭如何通過websocket進行服務端與客戶端的通信,本篇將會帶領大家把各個websocket進行相互通信,在開始之前,請確保有理解 1 go ...
  • # 流程式控制制練習題 # 一、編程題 1、實現一個課程名稱和課程代號的轉換器:輸入下表中的課程代號,輸出課程的名稱。用戶可以迴圈進行輸入,如果輸入0就退出系統。(**使用****switch +while****迴圈實現**) **課程名稱和課程代號對照表** | **課程名稱** | **課程代碼* ...
  • 看《C++ Primer Plus》時整理的學習筆記,部分內容完全摘抄自《C++ Primer Plus》(第6版)中文版,Stephen Prata 著,張海龍 袁國忠譯。只做學習記錄用途。 ...
  • # 流程式控制制 學習目標: ~~~txt1. idea安裝與使用2. 流程式控制制if...else結構3. 流程式控制制switch結構4. 流程式控制制迴圈結構5. 流程式控制制關鍵字~~~ # 一、流程式控制制概述 什麼是流程式控制制? 流程式控制制是用來控製程序中各語句執行順序的語法。流程式控制制主要包含: * 順序結構 * ...
  • 一、字典、元組的多重嵌套 例 1:記錄全班學生的成績。 分析:定義一個 SimpleGradebook類, 學生名是字典self._grades的鍵,成績是字典self._grades的值。 class SimpleGradebook(): def __init__(self): self._gra ...
  • Django python網路編程回顧 之前我們介紹過web應用程式和http協議,簡單瞭解過web開發的概念。Web應用程式的本質 接收並解析HTTP請求,獲取具體的請求信息 處理本次HTTP請求,即完成本次請求的業務邏輯處理 構造並返回處理結果——HTTP響應 import socket ser ...
  • 三、SpringAMQP SpringAMQP是基於RabbitMQ封裝的一套模板,並且還利用SpringBoot對其實現了自動裝配,使用起來非常方便 SpringAMQP的官方地址 https://spring.io/projects/spring-amqp AMQP Spring AMQP Sp ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...