併發王者課-青銅03:興利除弊-如何理解多執行緒帶來的安全問題

nt1979發表於2021-09-09

歡迎來到《併發王者課》,本文是該系列文章中的第3篇

在前面的兩篇文章中,我們體驗了執行緒的建立,並從OS程式層面認識了執行緒。現在,我們已經知曉多執行緒在解決一些場景問題時有特效。

然而,不知你可曾想過,多執行緒雖然效率很高,但是它卻有著你無法迴避併發問題。舉個王者中常見的場景,雙方10人同時進攻主宰,最後擊敗主宰的玩家才是真正的贏家,而且只能有一位。所以問題來了,假如這10位玩家代表10個執行緒,它們在併發訪問同一個資源時,如何保證資料的安全性?總不至於,主宰只有一條命,可是卻有多位玩家獲得主宰,這顯然不符合邏輯。

這個簡單例子的背後,是計算機系統中一個普遍且基本的問題,即多執行緒的安全問題。在設計多執行緒時,我們追求它的優點,也務必要理解它存在的安全隱患,併為之設計合理的解決方案。否則,多執行緒這把雙刃劍必將給我們以教訓。

本文將從併發並行的概念觸發,幫助你入門這些概念並理解競態相關問題。

一、理解併發(Concurrency)和並行(Parallelism)

在併發程式設計中,併發與並行像一對孿生兄弟,不僅長相相似,又容易讓人混淆。但是,它們又有著本質的區別。所以,理解並行與併發,不要嘗試去死記硬背概念,在你未能從本質上認識它們之前,你無法欺騙你的大腦去記住它。

簡而言之,並行與併發的區別的核心在於所競爭的 資源 不同。舉個通俗的例子:

  • 藍方5個人一起打主宰,是併發(Concurrency),因為競爭的目標資源只有一個
  • 藍方2人去打主宰,3人去打暴君,是並行(Parallelism),因為競爭的目標資源是兩個

類似的,從CPU計算的角度看, 併發和並行的概念可以理解為:

  • 如果1個CPU同時執行5個任務,就是併發
  • 如果5個CPU同時執行5個任務,並且是每個CPU執行一個,那麼就是並行

圖片描述

以上是對並行和併發的通俗概述,如果你有興趣,可以透過檢索資料詳細瞭解單CPU下是如何模擬併發的。

二、理解競態(Race Condition)下的安全問題

顯然易見,無論是併發還是並行,都有助於提高計算效率。然而,效率是一方面,安全則是更重要的一方面。比如上面進攻主宰的案例中,一定要能知道是誰給予了最後一擊,也就是資料不能出錯。所以,我們就需要理解多執行緒下的 競態(Race Condition) 和解決策略。

所謂競態,你可以理解為多個執行緒試圖在同一時刻修改共享資料的情況。你看,從字面上理解的話,Race這個詞就是比賽的意思。比賽的目標是什麼?是看誰先獲得共享資源,即進入臨界區(Critical Section)

常見的競態有下面這兩種模式:

  • Read-modify-write
  • Check-then-act

1. Read-modify-write

先看下面這段程式碼,玩家每次進攻,主宰的血量都會減少:

public class Master {
    //主宰的初始血量
    private int blood = 100;

    //每次被擊打後血量減5
    public int decreaseBlood() throws Exception {
        if(blood  0){
            throw new Exception("主宰已經被擊敗!");
        }
        blood = blood - 5;
        return blood;
    } 
}

當執行緒執行decreaseBlood()方法呼叫時,事情是這樣發展的:

  • 第一步:從記憶體中讀取blood的值到暫存器(Read);
  • 第二步:修改暫存器中的blood值(Modify);
  • 第三步:將暫存器的值寫回記憶體(Write)。

這就是Read-modify-write模式。整個過程看起來一氣呵成,實則禍根已經種下。想想看,如果在第一步時,兩個執行緒同時都讀取到了值(比如100),隨後兩個執行緒同時做了修改,此時在第三步,無論是哪個執行緒率先將值寫回記憶體,後面的執行緒都會覆蓋記憶體中的值。換句話說,主宰承受了兩次攻擊,血量應該降低到90,可結果卻是95,不是它耐操,而是你程式碼寫錯了!

2. Check-then-act

 //每次被擊打後血量減5
    public int decreaseBlood() throws Exception {
        if(blood  0){
            throw new Exception("主宰已經被擊敗!");
        }
        blood = blood - 5;
        return blood;
    } 

我們再近距離觀察下decreaseBlood()方法,你會發現,它不僅會讓主宰出現攻擊兩次但血量卻只減少一次的情況,還會出現血量為負值的情況!這是為什麼?

注意decreaseBlood()中有一行if(blood ,也就是說如果此時主宰已經被擊敗,那就不要再往下繼續執行,直接丟擲異常。但是,問題來了。假設此時主宰的血量是 5 ,就差最後一擊了!然後,執行緒A和執行緒B兩個執行緒同時進來:

  • 第一步:執行緒A和執行緒B檢查血量是否為 0Check);
  • 第二步:執行緒A和執行緒B都透過了檢查;
  • 第三步:執行緒A和執行緒B執行血量扣減動作,但順序未知(Act)。

問題是,如果執行緒A在執行blood = blood - 5時,blood的值不再是 5 ,而是已經被執行緒B更改為 0 了呢?那麼結果就是主宰最後的血量是 -5 !很顯然,這樣的結果就扯淡了。

以上就是兩種常見的競態情況。簡單來說, Read-modify-write是在寫入時因併發導致值被覆蓋,而Check-then-act則是因併發導致條件判斷失效

3. 如何預發競態

既然多執行緒是不安全的,那如何預防競態的發生?其核心在於鎖+原子操作,即對臨界區進行加鎖,讓臨界區每次有且只能有一個執行緒訪問,在當前執行緒未離開臨界區時,其他執行緒不得進入,且執行緒在臨界區的操作必須保證原子性。

在Java中,最簡單的加鎖方式是使用synchronized關鍵字,我們會在下一篇中對它詳細講解。

以上就是文字的全部內容,恭喜你又上了一顆星✨

夫子的試煉

  • 寫一段多執行緒併發程式碼,體驗併發時的資料錯誤。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4822/viewspace-2797133/,如需轉載,請註明出處,否則將追究法律責任。

相關文章