併發編程的目標與挑戰

来源:https://www.cnblogs.com/dwlovelife/archive/2019/01/23/9944085.html
-Advertisement-
Play Games

If I had only one hour to save the worlds,I would spend fifty five minutes defining the problem,and only five minutes finding the solution. 如果我只有1小時拯救 ...


If I had only one hour to save the worlds,I would spend fifty-five minutes defining the problem,and only five minutes finding the solution.

如果我只有1小時拯救世界,我將花55分鐘定義這個問題而只花分鐘去尋找解決方案 ——Albert Einstein

本文講解的將是多線程的一些重要概念,為接下來自己以及讀者更好的理解併發編程做個鋪墊。

之後會講解volatile關鍵字,CAS , AQS 等等,總之概念是實踐的基石

1.1 競態

多線程編程中經常遇到一個問題就是對於同樣的輸入,程式的輸出有時候是正確的,而有時候卻是錯誤的。這種一個計算結果的正確性與時間有關的現象就被稱為競態(Race Condition)。

java核心技術-多線程基礎 中 1.1 (2)

public class Ticket implements Runnable{
    
    private int ticket = 100;

    @Override
    public void run() {
        while(ticket > 0){
            System.out.println(Thread.currentThread().getName() + "=" + --ticket);
        }
        
    }
}
public class TestThread2 {
    public static void main(String[] args) {
        
        Ticket ticket = new Ticket();
        
        //雖然是實現了Runnable介面 本質上只是實現了線程執行體 啟動工作還是需要Thread類來進行
        Thread t1 = new Thread(ticket,"售票視窗一");
        t1.start();
        
        Thread t2 = new Thread(ticket,"售票視窗二");
        t2.start();
        
        Thread t3 = new Thread(ticket,"售票視窗三");
        t3.start();
    }
}

賣票的CASE,此案例中競態導致的結果是不同業務的線程可能拿到了重覆的ticket(票),且可能出現ticket為負數的情況。

可見 while(ticket > 0) 以及 --ticket 這兩個操作 是禍端之源。

進一步來說,導致競態的常見因素是多個線程 在沒有採取任何控制措施的情況下,併發地更新、讀取同一個共用變數

有朋友可能會說:--ticket 操作 是一個操作啊 你怎麼能說是禍端之源

其實不是的,只是看起來像是一個操作而已,它實際上 相當於如下偽代碼所表示的三個指令

load(ticket,r1); //指令①:將變數ticket 的值從記憶體讀到寄存器r1
decrement(r1); //指令②:將寄存器r1的值減少1
store(ticket,r1);//指令③:將寄存器r1的內容寫入變數ticket所對應的記憶體空間

而 ①②③並不能保證是一個原子操作,兩個業務線程可能在同一時刻讀取到ticket的同一個值,一個業務線程對ticket所做的更新也可能"覆蓋"其他線程對該變數做的更新,所以,問題不言而喻.....

1.2 競態的模式與競態產生的條件

從上述競態的典型實例中,我們可以提煉出競態的兩種模式:

① read-modify-write(讀改寫)

② check-then-act (檢測而後行動)

read-modify-write(讀改寫)操作可以被細分為這樣幾個步驟:讀取一個共用變數的值(read),然後根據該值做一些計算(modify),接著更新該共用變數的值。例如 --ticket

check-then-act (檢測而後行動) ,該操作可以被細分為這樣幾個步驟:讀取某個共用變數的值,根據該共用變數的值決定下一步的動作是什麼。while(ticket > 0) --ticket

但是對於局部變數(包括形式參數和方法體內定義的變數),由於不同的線程各自訪問的各自訪問的是各自的那一份局部變數,因此局部變數的使用不會導致競態,如下例

public class NoRaceCondition {
    
    
    public int nextSequence(int sequence){
        if(sequence >= 999){
            sequence = 0;
        }else{
            sequence++;
        }
        return sequence;
    }
    
}

1.3 線程安全性

一般而言,如果一個類在單線程環境下能夠正常運行,並且在多線程環境下,在其使用方不必為其做任何改變的情況下也能正常運行,那麼我們就稱其是線程安全的,相應的我們稱這個類具有線程安全性,反之亦然。而一個類如果是線程安全的,那麼它就不會導致競態。

線程安全問題概括來說表現為3個方面: 原子性、可見性、有序性

1.3.1 原子性

原子(Atomic) 的字面意思是不可分割的。其含義簡單的來說就是,訪問(讀、寫)某個共用變數的操作從執行線程以外的任何線程來看,該操作要麼已經執行結束,要麼尚未發生,即其他線程不會"看到"該操作線程執行了部分的中間效果

在生活中我們可以找到的一個原子操作的例子就是人們從 ATM 機提取現金; 儘管從ATM軟體的角度來說,一筆交易涉及扣減主賬戶餘額、吐鈔器吐出鈔票、新增交易記錄等一系列操作,但是從用戶的角度來看 ATM取款就是一個操作。 該操作要麼成功了,我們拿到了現金。要麼失敗了,我們沒有拿到現金。

理解原子操作要註意以下兩點:

  • 原子操作是針對訪問共用變數的操作而言的
  • 原子操作是從該操作的執行線程以外的線程來描述的

總的來說,Java 中有兩種方式來實現原子性。

一種是使用鎖(Lock)。鎖具有排他性,即它能保證一個共用變數在任意時刻只能夠被一個線程訪問。這就排除了多個線程在同一時刻訪問通一個共用變數而導致干擾與衝突的可能,即消除了競態。

另一種是利用處理器處理器專門提供的 CAS(Compare-and-Swap)指令 ,CAS 指令實現原子性的方式與鎖實現原子性的方式實質上相同的,差別在於鎖通常是在軟體這一層次實現的,而CAS 是直接在硬體(處理器和記憶體) 這一層次實現的,它可以被看作"硬體鎖"

在Java 語言中,long型 和 double型 以外的任何基礎類型的變數的寫操作 都是原子操作。

對 long/double 型變數的寫操作 由於 Java語言規範並不保障其具有原子性,因此多個線程併發訪問同 一 long/double型變數的情況下,一個線程可能會讀取到其他線程更新該變數的"中間結果"(64位的虛擬機應該不會出現這個問題);

註:使用32位虛擬機 用對個線程對long,double型數據進行操作 會有低32位 高32位的問題,儘管如此可以使用volatile關鍵字進行解決,它可以保證變數寫操作的原子性,即線程共用變數 刷新到主存這個動作是原子的

1.3.2 可見性

在多線程環境下,一個線程對某個共用變數進行更新後,後續訪問該變數的線程可能無法立刻讀取到這個更新的結果,甚至永遠無法讀取到這個更新的結果。這就是線程安全問題的另外一個表現形式:可見性

下麵我們來一個Demo吧

public class ThreadVolatile{
    public static void main(String[] args) {
        ThreadDemo td = new ThreadDemo(); //01
        new Thread(td).start();//02
        
        while(true){
            if(td.isFlag()){//03
                System.out.println("-----------------");
                break;
            }
        }
    }
}

class ThreadDemo implements Runnable{
    
    private boolean flag = false;
    
    @Override
    public void run() {
        //此處的目的 是讓main線程 從主存那 先獲取flag等於false的值 
        try {
            Thread.sleep(200);
        } catch (Exception e) {
        }
        flag = true;//04
        System.out.println("flag=" + flag);
    }
    
    public boolean isFlag(){
        return flag;
    }
    
    public void setFlag(boolean flag){
        this.flag = flag;
    }
    
}

運行結果:

列印flag=true, 但迴圈無法終止

在解釋原因之前先說幾個概念:(很重要)

  • 棧:線程獨有,保存其運行狀態以及局部自動變數,操作系統在切換線程的時候會自動切換棧,也就是切換寄存器
  • 堆:保存對象的實體以及全局變數,可以把堆記憶體 約看成 主記憶體

01-初始化完ThreadDemo 記憶體空間:

02.子線程ThreadDemo啟動 獲取到flag=false的值 開始睡覺

03.main線程獲得了flag=false的值 在迴圈體中跑了若幹次

04.由於03步驟main線程獲得了flag=flase,雖然主存變了,但是由於while(true)執行效率太高,根本沒有時間讓主存中的數據同步到main線程中去,所以main線程一直在死迴圈

那麼,在Java平臺中 如何保證可見性呢?

對於上例Demo,我們只需將其flag的聲明添加一個volatile關鍵字即可,即

private volatile boolean flag = false;

這裡,volatile關鍵字所起到的一個作用就是,提示JIT編譯器被修飾的變數可能被多個線程共用,以組織JIT編譯器做出可能導致運行不正常的優化 (重排序)。另外一個作用就是 讀取一個volatile關鍵字所修飾的變數會使相應的處理器執行刷新處理器緩存的動作

1.3.3 有序性

有序性 指在什麼情況下一個處理器上的運行的一個線程所執行的記憶體訪問操作在另外一個處理器上運行的其他線程看來是亂序的。(某書定義)

我的理解:程式運行順序要與代碼邏輯順序保持基本一致,避免多線程情況由於重排導致的錯誤

所謂亂序,是指記憶體訪問操作的順序看起來像是發生了變化。在進一步介紹有序性概念之前,我們需要介紹重排序的概念

重排序:是指編譯器和處理器為了優化程式性能而對指令序列進行重新排序的一種手段

  • 指令重排序:源代碼順序與程式順序不一致,或者程式順序與執行順序不一致的情況下 (編譯器,處理器)
  • 存儲子系統重排:源代碼順序、程式順序和執行順序這三者保持一致,但是感知順序與執行順序不一致 (高速緩存,寫緩衝器)

註:這一塊建議瞭解編譯原理 以及彙編

as-if-serial語義:編譯器和處理器不會對存在數據依賴關係的操作做重排序,因為這種重排序會改變程式執行結果。但是,如果操作之間不存在數據依賴關係,這些操作就可以被編譯器和處理器重排序。

示例:

double pi = 3.14;  // A 
double r = 1.0;     //B
double area = pi * r * r; //C

分析:A與C之間存在數據依賴關係,所以C不能排到A的前面,同時B與C之間也存在數據依賴關係,所以,C也不能排到B的前面,但是A與B之間是不存在數據依賴關係的,所以A與B之間是可以進行重排序的。

程式順序規則:

根據happens-before的程式規則,上面的計算圓的示例代碼存在3個happens-before關係:

A happens-before B ; B happens-before C; A happens-before C;

重排序對多線程的影響:

class RecorderExample{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1; // 1
        flag = true; // 2
}
    public void reader(){
        if(flag){          // 3
            int i = a * a;  // 4
             ......
    }
} 
}

flag是一個變數,用來表示變數a是否已被寫入。這裡假設有兩個線程A和B ,A線程首先執行writer方法,隨後線程B執行reader方法。線程B在執行操作4的時候,能否看到線程A在操作共用變數a的寫入呢?

答案是:在多線程的情況下,不一定能看到;

由於操作1和操作2沒有數據依賴的關係,編譯器和處理器可以對這兩個操作進行重排序,操作3和操作4沒有數據依賴關係,編譯器和處理器也可以對其進行重排序,下麵我們看一下可能的執行情況的示意圖:

如上所示,操作1 和操作2 進行了重排序。程式執行時,線程A首先寫標記變數flag,隨後線程B讀這個變數。由於判斷條件為真,線程B將讀取變數a。此時,變數a還沒有被線程A寫入,所以在這裡,多項層程式的語義就被重排序破壞了。

下麵在看一下操作3和操作4重排序會發生什麼效果:

在程式中,操作3和操作4存在控制依賴關係。當代碼中存在控制依賴行時,會影響指令序列執行的並行度。為此,編譯器和處理器會採用猜測執行來剋服控制相關性對並行度的影響。以處理器的猜測執行為例,執行線程B的處理器可以提前讀取並計算a*a,然後把計算結果臨時保存到一個名為重排序緩衝的硬體緩存中。當操作3的條件判斷為真的時候,就把該結算結果寫入到變數i中。

從上圖我們可以看出,猜測執行實質上是對操作3和操作4進行了重排序,重排序在這裡破壞了多線程程式的語義。

在單線程程式中,對存在控制依賴的操作進行重排序,不會改變執行結果(這也是as-if-serial 語義允許對存在控制依賴的操作做重排序的原因),但是在多線程的程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。


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

-Advertisement-
Play Games
更多相關文章
  • 一、需求背景: 二、Dubbo和Spring Cloud 的比較 首先Dubbo是一個分散式服務框架,以及SOA治理方案。它的功能主要包括:高性能NIO通訊及多協議集成,服務動態定址與路由,軟負載均衡與容錯,依賴分析與降級等,它是著名的阿裡服務治理的核心框架。Spring Cloud更加關心為開發人 ...
  • 一個分散式服務集群管理通常需要一個協調服務,提供服務註冊、服務發現、配置管理、組服務等功能,而協調服務自身應是一個高可用的服務集群,ZooKeeper是廣泛應用且眾所周知的協調服務。協調服務自身的高可用需要選舉演算法來支撐,本文將講述選舉原理並以分散式服務集群NebulaBootstrap的協調服務N ...
  • 其旨在打造一個集應用開發、大數據存儲、處理、分散式計算、自動化部署的無節點微服務集中開發與運行平臺,以響應業務的快速變更,滿足系統對大數據,大併發與開發效率的需求; 平臺設計以數據為核,以groovy腳本為基礎,通過提供api、非同步消息處理、調度等基礎構件來支持應用的快速開發; 核心是通過整合現有開 ...
  • 對象實例化過程: 1.看類是否已載入,未載入的話先初始化類。 2.在堆記憶體中分配空間。 3.初始化父類的屬性 4.初始化父類的構造方法 5.初始化子類的屬性 6.初始化子類的構造方法 實例: package com.xm.load; public class Animal { static Stri ...
  • 我相信對於很多愛好和習慣寫博客的人來說,如果自己的博客有很多人閱讀和評論的話,自己會非常開心,但是你發現自己用心寫的博客卻沒什麼人看,多多少少會覺得有些傷心吧?我們今天就來看一下為什麼你的博客沒人看呢? 一、頁面分析 首先進入博客園首頁,可以看到一頁有20篇博客簡介,然後有200頁,也就是說總共有2 ...
  • 使用static關鍵字修飾的變數和方法為靜態變數、靜態方法。 非靜態方法可以訪問靜態變數/方法和非靜態變數/方法,但靜態方法只能訪問靜態變數/方法。 可以看到在靜態方法中調用非靜態變數和非靜態方法時,Java會報錯。 所謂的靜態是指變數或方法可以不依賴對象而直接使用類名來調用,這也是static的意 ...
  • 第74節:第74節:Java中的Cookie和Session : 什麼是 ,有什麼用哦,怎麼用呢? 啟動伺服器後,會給每個應用程式創建一個 ,並且這個 對象只有一個。可以用於獲取全局參數,工程下的資源,和存取數據,共用數據。 例子,如何獲取全局參數,如何獲取工程下的資源,如何進行存取數據,用例子代碼 ...
  • 今天在寫條件語句時,一老出錯 自認為程式上是沒什麼問題的,所以將逗號去掉試試看 得出了正確的值,很意外,然後多試了幾次之後,逗號也可以輸入了 原來我誤把中文輸入法的逗號輸入到scanf中,而運行時又用英文輸入法 然後得出來的總結就是,scanf函數中必須用自己制定的格式,輸入,輸出。 ...
一周排行
    -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.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...