Java 併發(零)- 原子性

轉載自:https://lotabout.me/2020/Java-Concurrency-0-Shared-Mutable-State/

敘述

併發問題主要有三個根源:原子性、可見性及有序性。作爲 Java 併發系列的開篇,我們先來談談原子性,以及引發原子性問題的 Shared Mutable State(共享可變狀態)。

多個線程多十倍煩惱

沒有多線程就不存在併發問題[1],一旦有多個線程,情況就複雜了起來。下例中我們起了兩個線程,分別嘗試對全局變量 counter 做++ 操作,最終輸出的結果會是多少呢?

public class AtomicTest {
  private static int counter = 0;

  public static void main(String[] args) throws InterruptedException {
    Thread th1 = new Thread(AtomicTest::increase);
    Thread th2 = new Thread(AtomicTest::increase);
    th1.start();
    th2.start();
    th1.join();
    th2.join();
    System.out.println(counter);
  }

  public static void increase() {
    for (int i = 0; i < 10000; i++) {
      counter++;
    }
  }
}

我們預期它永遠輸出 20000,但實際運行可能輸出任意值。僅僅兩個線程就讓簡單的 ++ 操作不再正確。

當代碼邏輯在多線程環境下運行結果不符合預期時,我們會稱代碼是不是“線程安全”的,有時候也說“有併發問題”。上例中的 increase 函數就不是“線程安全”,也可以說是“線程不安全的”。爲了達到線程安全,我們需要原子操作。

原子是不可分割的

物理上“原子”是“不可分割的粒子”。編程中借用了這個概念,我們說一個操作是“原子的”代表這個操作在執行的過程中是不可分割的。一個操作在真正執行時可能需要執行底層的粒度更細的多個指令,如果這些指令的執行結果表現成一個整體,則認爲操作是原子的。

例如上面的 counter++ 操作是 Java 層面的,在執行時需要多個底層的 Java 字節碼指令來完成,可以理解成下面的僞代碼:

reg0 = counter
reg0 = reg0 + 1
counter = reg0

當有兩個線程同時執行 counter++ 時,JVM 可能會交替執行兩個線程的指令 [2],實際執行的順序可能會是(序號代表實際執行順序):

------- Thread 1 ------+------ Thread 2 --------
1. reg0 = counter (0)  |
                       | 2. reg1 = counter (0)
3. reg0 = reg0 + 1 (1) |
                       | 4. reg1 = reg1 + 1; (1)
5. counter = reg0 (1)  |
                       | 6. counter = reg1 (1)

我們預期結果 counter = 2,但實際結果爲 counter = 1,這是由於 ++ 操作的底層指令在執行時並不是一個整體,而是被另一個線程的指令“分割”了。換言之,++ 操作不是“原子的”。

原子能力最終依賴於底層

實現原子性,意味着多個操作在執行時作爲一個不可分割的整體。通常情況下,編程語言會提供一些原子的能力讓我們實現原子性,將多個操作作爲整體執行。Java 中常見的有 synchronized 代表的鎖、ReentrantLock代表的顯示鎖及 AtomicInteger 代表的原子類等。

而 Java 類庫和 JVM 在實現這些機制時,需要依賴操作系統提供的原子能力。如 synchronized 通常是利用操作系統的mutex 機制實現的,而操作系統的 mutex 實現又依賴 CPU 提供的原子指令,如 x86 提供的 CMPXCHG 指令[3] 。

那麼如果 CPU 不提供 CAS 的原子,JVM 有辦法實現鎖機制嗎?答案是有,但依舊需要依賴其它的原子能力。例如早期的一些互斥鎖(Mutual exclusion)算法[4]不依賴 CAS 指令,但要求對某個變量(寄存器/內存)的讀寫是原子的(通常情況下也是成立的)。

萬惡之源:Shared Mutable State

前文提到了原子性是邏輯作爲一個整體被執行,不被分割。那麼什麼情況下才可能出現被分割呢?要有多線程。多線程就一定破壞原子性嗎?只有在它們 Shared Mutable State(共享可變狀態) 的時候。

這個概念非常重要,也是後續文章中會經常出現的概念。一共三個詞:

  • State(狀態),存儲下來的都是“狀態”,比如存在寄存器、內存的變量;存在文件、數據庫的內容等。
  • Shared(共享),有多個參與者,“同時”訪問某個狀態。如多個線程訪問同一個變量,多個進程訪問同一個數據庫等。
  • Mutable(可變),訪問分爲“讀”和“寫”,可變指的是寫。至少有一個參與者想要寫入新的狀態。

只有同時滿足 “Shared” 和 “Mutable” 才造成併發問題。如果沒有共享,也就不存在操作被分割的問題,原子性是成立的。如果“不可變”,則雖然實際操作可能被分割,但由於操作不改變狀態,操作的結果最終“看起來”[5]也是原子性的。

在一些語言中,爲了保證線程安全,會嘗試打破其中一個。例如 Clojure 中所有的對象都是 Immutable(不可變)的;Java 中其實也鼓勵多用不可變的對象;Rust 中則是嘗試阻止 Share,一個對象只能兩種情況:要麼只有一個引用,它可以是可變的,要麼可以有多個引用,但所有引用都是不可變的。

Java 中的“鎖”也可以認爲是阻止 Share 的機制。

小結

本章探討了原子性,原子性指的是操作的執行作爲一個整體不可分割,它(通常)是我們編碼時預期的行爲。在多線程的環境下,代碼的執行通常不具備原子性,從而導致了併發問題。

編程語言層面提供了一些機制來讓我們實現原子性,從而避免併發問題,達到線程安全。這些機制的實現又依賴更底層提供的原子能力。

而從編碼的角度,併發問題的產生,是由於代碼裏有共享的可變的狀態,爲了達到線程安全,我們需要合理地使用原子機制(如鎖)來阻止狀態的共享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章