閱讀653 返回首頁    go 阿裏雲 go 技術社區[雲棲]


銀行存取款模型的線程同步問題

  關於線程同步,網上也有很多資料,不過不同的人理解也不大一樣,最近在研究這個問題的時候回想起大學課本上的一個經典模型,即銀行存取款模型,通過這個模型,我個人感覺解釋起來還是比較清楚的。本文結合自己的思考對該模型進行一個簡單的模擬,闡述一下我對線程同步的理解。

場景模擬

  接下來使用java對該問題進行模擬。在研究這個問題時會忽略掉現實係統中的很多其他屬性,通過一個最簡單的餘額問題來看線程同步,這裏首先創建三個類。

1.卡類,同時卡類提供三個方法,獲取餘額、存款以及取款。

public class Card {

    /*餘額初始化*/
    private double balance;
    public Card(double balance){
        this.balance = balance;
    }

    /*獲取餘額方法*/
    public double Get_balance(){
        return this.balance;
    }

    /*存款方法*/
    public void deposit(double count) throws InterruptedException{
        System.out.println("存錢線程:存入金額=" + count);
        double now = balance + count;
        balance = now;
        System.out.println("存錢線程:當前金額=" + balance);
    }

    /*取款方法*/
    public void withdraw(double count) throws InterruptedException{
        System.out.println("取錢線程:取出金額=" + count);
        double now = balance - count;
        balance = now;
        System.out.println("取錢線程:當前金額=" + balance);
    }
}

然後是兩個線程類,用於模擬並發操作所引入的餘額問題。

2.存款線程類,存入金額100。

public class DepositThread extends Thread{
    private Card card;
    public DepositThread(Card card){
        this.card = card;
    }
    @Override
    public void run(){
        try {
            card.deposit(100);
        }
        catch(Exception e){System.out.println(e.toString());}
    }
}

3.取款線程類,取出金額50。

public class WithdrawThread extends Thread{
    private Card card;
    public WithdrawThread(Card card){
        this.card = card;
    }
    @Override
    public void run(){
        try {
            card.withdraw(50);
        }
        catch(Exception e){
            System.out.println(e.toString());
        }
    }
}

  現在先進行一個測試,讓存款線程先進行存錢操作,然後取款線程進行取款,最後驗證餘額與邏輯是否符合。

測試代碼如下:

public class CardTest{
    public static void main(String[] args) throws InterruptedException{
        Card card = new Card(100);
        System.out.println("操作前餘額:" + card.Get_balance());
        DepositThread depositThread = new DepositThread(card);
        WithdrawThread withdrawThread = new WithdrawThread(card);
        depositThread.start();
        withdrawThread.start();
        Thread.sleep(2000);
        System.out.println("最終餘額:" + card.Get_balance());
    }
}

運行後輸出如下結果:
result1

  現在大致的看一下,初始餘額為100,然後存款線程存入100,接下來取款線程取走50,那麼最後餘額為150。這麼看來,貌似沒問題?

數據不一致問題

  事實上,存取款過程是需要消耗時間的,隻要一個線程在操作餘額期間受到其他線程的幹擾,就可能出現數據不一致問題。這裏我們修改存取款方法的代碼如下。

存款方法:

    public void deposit(double count) throws InterruptedException{
        System.out.println("存錢線程:存入金額=" + count);
        double now = balance + count;
        Thread.sleep(100);  //存錢的操作用時0.1s
        balance = now;
        System.out.println("存錢線程:當前金額=" + balance);
    }

取款方法:

    public void withdraw(double count) throws InterruptedException{
        System.out.println("取錢線程:取出金額=" + count);
        double now = balance - count;
        Thread.sleep(200);  //取錢的操作用時0.2s
        balance = now;
        System.out.println("取錢線程:當前金額=" + balance);
    }
}

然後再運行一遍測試程序:
result2

  現在,我們發現最終餘額變成了50,這很顯然是個完全不符合預期的錯誤結果。那麼,如何來解釋這個現象呢?
lock1
  從上圖可以看到,出現數據不一致的原因在於多個線程並發訪問了同一個對象,破壞了不可分割的操作,這裏的這個共同訪問對象就是餘額。其實我們所謂預期的‘正確’結果,就是希望先進行存款,然後再進行取款,或者反之。

原子操作與鎖

  上麵提到‘不可分割的操作’,這種操作就是原子操作。是因為實際上多線程編程的情境下,很多敏感數據不允許被同時訪問,因此對於這種針對敏感數據的操作,需要進行線程訪問的協調與控製,這就是所謂的線程同步(協同步調)訪問技術。線程同步控製的結果,就是把每次對敏感數據的操作變成原子操作,從而讓執行順序按照我們預期的過程進行。
  上述情境下,存款與取款應當是兩個原子操作,我們必須保證先進行且完成存款操作再進行取款操作,才能保證最終數據的一致性,才能得到我們認為是‘正確’的結果。

下麵我們通過鎖來實現線程同步訪問控製,修改Card類的代碼如下。

public class Card {

    private double balance;
    private Object lock = new Object(); //鎖

...省略其它代碼

    /*存款*/
    public void deposit(double count) throws InterruptedException{
        System.out.println("存錢線程:存入金額=" + count);
        synchronized (lock) {
            double now = balance + count;
            Thread.sleep(100);//存錢的操作用時0.1s
            balance = now;
        }
        System.out.println("存錢線程:當前金額=" + balance);
    }

    /*取款*/
    public void withdraw(double count) throws InterruptedException{
        System.out.println("取錢線程:取出金額=" + count);
        synchronized (lock) {
            double now = balance - count;
            Thread.sleep(200);//取錢的操作用時0.2s
            balance = now;
        }
        System.out.println("取錢線程:當前金額=" + balance);
    }
}

運行結果如下:
result3

  這段代碼中,通過synchronized 關鍵字保證lock對象隻能同時被一個線程訪問,要想操作餘額,那麼必須先獲取lock對象的訪問許可,因此就保證了餘額不會被多個線程同時修改,而最終的結果也完全符合我們的預期。這個lock對象就可以形象的理解成鎖,整個執行過程大致如下圖所示,
lock2

最後更新:2017-08-20 18:08:45

  上一篇:go  [MySQL 5.7 metadata lock] 測試
  下一篇:go  數據庫和集合(MongoDB 文檔翻譯和解讀)