Byteman 讓 i++ 百分百執行緒不安全

FunTester發表於2024-12-03

在我早期的文章當中,我使用過一個外掛 vmlens 實現讓 i++ 展現了百分百的執行緒不安全。在演示示例中,使用了兩個執行緒併發執行 i++,然後就看到了執行緒不安全的全過程。

但是 vmlens 當時是個付費軟體,作者給白嫖使用者兩週的體驗期,雖然我我提了一個 BUG ,也沒得到任何的優待。所以很快進行了簡單的嘗試之後,就放棄探索 vmlens

最近開始研究 Byteman 的官方文件過程中,當我看到了關於多執行緒管理的部分,原來可以控制多個故障的多執行緒同步,突然意識到有可能找到了 vmlens 一樣的套路。如果我們可以控制訪問一個變數的執行緒訪問(讀/寫)順序,那我們應該可以很容易模仿出執行緒不安全的場景。

既然如此,那我將重現一下 i++ 百分百執行緒不安全的遠古神級。

i++ 為什麼不安全

不安全

i++ 是執行緒不安全的,因為它不是一個原子操作。i++ 其實包含了三個步驟:

  1. 讀取變數值:從記憶體中讀取變數 i 的當前值。
  2. 自增操作:將讀取的值加 1。
  3. 寫回變數值:將更新後的值存回記憶體中。

在單執行緒環境下,這個過程不會有問題,但在多執行緒環境中,如果多個執行緒同時執行 i++,可能會發生競態條件。例如,兩個執行緒都讀取了相同的初始值,但都還沒來得及寫回時,導致最終只會增加一次,而不是兩次。

解決方法

  1. 使用同步機制:可以透過使用 synchronized 關鍵字來確保每次只有一個執行緒能夠訪問這個變數進行 i++ 操作。
synchronized(this) {
    i++;
}
  1. 使用原子類:Java 提供了 AtomicInteger 來處理類似的操作,它保證了 i++ 的原子性。
AtomicInteger i = new AtomicInteger(0);
i.incrementAndGet();  // 相當於 i++

這樣可以避免多個執行緒同時修改變數時導致的不一致性。

測試程式碼

下面是我的測試程式碼,邏輯非常簡單。程式碼建立了兩個執行緒,每個執行緒每隔一秒對共享變數 i 進行遞增操作,並輸出當前值。

package com.funtest.temp;


public class FunTester {

    static int i = 0;

    public static void test() {
        i++;
        System.out.println(Thread.currentThread().getName() + "     " + i);
    }

    public static void main(String[] args) {
        for (int j = 0; j < 2; j++) {
            new Thread(() -> {
                while (true) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    test();
                }
            }).start();
        }

    }

}

這個程式碼的邏輯可以簡單梳理為以下幾點:

  1. 靜態變數 i:定義了一個靜態變數 i,初始值為 0。這是所有執行緒共享的變數,用來記錄每次的遞增操作。
  2. test() 方法test() 方法的作用是對 i 進行自增操作,然後輸出當前執行緒的名字和自增後的值。在原始碼中,i++ 是執行緒不安全的,多個執行緒可能會在讀取和寫入 i 時發生衝突。
  3. main() 方法:在 main() 方法中,使用了一個迴圈建立了 兩個執行緒,每個執行緒會進入一個無限迴圈(while (true))。每個執行緒在執行時,都會每隔 1 秒Thread.sleep(1000))呼叫一次 test() 方法,執行自增操作,並輸出執行緒名稱和當前的 i 值。
  4. 多執行緒執行:兩個執行緒同時執行,不斷對 i 進行遞增操作,由於 i++ 不是原子操作,執行緒可能會發生資料競爭,導致遞增結果不正確(輸出值可能不連續或重複)。

總結:

程式碼建立了兩個執行緒,每個執行緒每隔一秒對共享變數 i 進行遞增操作,並輸出當前值。然而,由於 i++ 操作不是執行緒安全的,程式可能出現競態條件,導致輸出結果不符合預期。

Byteman rule 指令碼

下面是我的 Byteman 指令碼的內容:

RULE sync test
CLASS com.funtest.temp.FunTester
METHOD test
HELPER org.chaos_mesh.byteman.helper.FunHelper
AT ENTRY
IF TRUE
DO setThreadName()
ENDRULE


RULE async test
CLASS com.funtest.temp.FunTester
METHOD test
HELPER org.chaos_mesh.byteman.helper.FunHelper
AT WRITE i
IF checkThreadName()
DO System.out.println(Thread.currentThread().getName() + "     持有鎖");
ENDRULE

這個 Byteman 指令碼的作用是透過對類 com.funtest.temp.FunTestertest 方法進行增強,藉助 Byteman 的規則動態監控和修改執行緒執行時的行為。主要目標是透過 FunHelper 來記錄和監控執行緒的名字,並在訪問共享變數 i 時輸出執行緒持有鎖的資訊。下面逐步解釋每個規則的含義:

第一條規則:sync test

  • RULE 名稱sync test,給這條規則命名為 sync test
  • CLASS:目標類是 com.funtest.temp.FunTester,這條規則作用於該類。
  • METHOD:這條規則針對 test() 方法,表示要攔截這個方法。
  • HELPER:指定了一個輔助類 org.chaos_mesh.byteman.helper.FunHelper,其中定義了一些輔助方法來支援規則邏輯。
  • AT ENTRY:該規則觸發的時機是在 test() 方法的入口處,也就是方法一開始執行時。
  • IF TRUE:條件始終為 TRUE,意味著無條件執行。
  • DO setThreadName():在 test() 方法執行時,呼叫 FunHelper 中的 setThreadName() 方法。這通常是用於記錄或設定當前執行緒的名稱。

test() 方法執行時,無論什麼情況下,都會呼叫 setThreadName(),可能用於記錄每個執行緒的名稱,以便後續跟蹤哪個執行緒正在執行。

第二條規則:async test

  • RULE 名稱async test,命名為 async test
  • CLASS:同樣作用於類 com.funtest.temp.FunTester
  • METHOD:針對 test() 方法。
  • HELPER:依然是 org.chaos_mesh.byteman.helper.FunHelper,同樣使用輔助類來提供額外功能。
  • AT WRITE i:表示該規則在變數 i 被寫入時觸發。也就是說,當 i 的值發生改變時,規則會被執行(對應於 i++ 時的寫操作)。
  • IF checkThreadName():該規則只有在輔助類中的 checkThreadName() 返回 true 時才會觸發。這個方法可能會根據執行緒名稱來判斷當前執行緒是否符合某種條件。
  • DO System.out.println(Thread.currentThread().getName() + " 持有鎖"):如果條件為 true,則會列印當前執行緒的名字,並顯示 "持有鎖",表示當前執行緒正在執行對共享變數 i 的修改操作。

test() 方法中的共享變數 i 被寫入時(即 i++ 發生時),Byteman 會檢查當前執行緒的名字。如果執行緒名字滿足 checkThreadName() 的條件,就會輸出該執行緒已經持有鎖的資訊。這可以用於除錯或監控,檢視哪個執行緒正在修改共享變數 i,避免競態條件。

思路

透過這兩個指令碼,我們就可以在 i++ 賦值的過程中,第一個執行緒等待第二個執行緒進來,就能模式兩個執行緒同時完成 i+ 計算,然後在分別開始執行賦值過程。我自己的思路就是在賦值之前做一個阻塞的設定,當一個執行緒到達,必須等另外一個執行緒過去,然後自己再執行。這個設計基本上可以保障後來的程序先賦值,因為我再等待的方法中加上了神蹟 Thread.sleep(10);

實踐效果

下面是注入 Byteman 指令碼前後,控制檯輸出日誌變化情況:

Thread-3     7
Thread-3     8
Thread-2     9
Thread-3     10
Thread-2     11
setThreadName  Thread-2
setThreadName  Thread-2
Thread-3     持有鎖
Thread-3     12
setThreadName  Thread-3
Thread-2     持有鎖
Thread-2     12
setThreadName  Thread-2
Thread-3     持有鎖
Thread-3     13
setThreadName  Thread-3
Thread-2     持有鎖
Thread-2     13
setThreadName  Thread-2

可以看出,注入前,看著似乎是執行緒安全的,但是注入之後,每個執行緒輸出的值都是一樣的,百分百執行緒不安全了。

關於 FunHelper

這裡實現比較簡單,而且粗糙,目前各種實踐中積累一些好的設計和場景。打算從 Byteman 原始碼中再汲取一些營養。後面等我感覺程式碼成熟了,再來分享一篇文章。有興趣的可以加好友一起交流一下 Byteman 相關技術話題。

如果覺得我的文章對您有用,請隨意打賞。您的支援將鼓勵我繼續創作!
打賞支援
暫無回覆。

相關文章