動機

之前看完linux kernel的同步方式,對同步有新的理解,可以重新面對這個話題了

原子性、可見性

  • 原子性: 全都完成,不然就是根本沒做
    • race condition
    • primitive有可能不是atomic
      • java的long與double就不是
        • 大部分arch可以讓32bit的資料atomic
        • 但long與double不是阿
        • 就可能要分兩次,這樣就出事了
      • C++的atomic類別
        • C++支援struct,但是struct的大小不一定
        • 如果有align那好說,但是如果沒有compiler就要自己動手腳(好麻煩)
      • Word Tearing
        • 更新相鄰的資料時,會影響到隔壁!?
        • 像boolean在java是1byte!
          • 這與arch能處理的最小單位有關,大部分arch是8bits
          • 所以要更新就要一次跑1byte
        • 可以同時跑2個thread做java的BitSet(可以想成byte[])set
          • 可能只更新其中一位或是兩位都更新
          • 因為是做bitwise,所以是一次更新1byte
  • 可見性: 在後面的指令能看到前面的影響
    • reorder與亂序執行
      • 不同arch的reorder規則
        • 強(只准store-load): x86, sparc-tso
        • 弱(LL,LS,SS,SL都可): powerpc, ia64, arm
      • 資料相依
        • write-read, write-write, read-write
        • 這在任何arch都不會被reorder,但僅限single thread的scope
          • smp與multi-thread之間沒有 (所以需要後面的規則)
    • cache
  • 順序性: 前面跑完才能跑後面
    • reorder是會保證single thread中的code與reorder前跑出來的結果是一樣的

java memory model

happen-before(hb)

在放在A指令之前的指令所做出的改變,A指令一定看的到

  • 同一個scope中具有順序性
    • 在同一thread中,前一條指令對後一條指令是happen-before
    • 前一個synchronized的解鎖,對後一個synchronized的加鎖是happen-before
  • 先寫後讀 (先啟動後執行)
    • 一個volatile的寫,對後續任何volatile的讀是happen-before
    • thread的start,對該thread的所有指令是happen-before
    • thread的interrupt,對所有檢查interrupt的指令是happen-before
    • thread的join,對所有檢查thread存活的指令是happen-before
    • obj初始化,對obj的finalize是happen-before
  • A hb B,且B hb C,則A hb C

causality (因果關係)

下面是無中生有(out-of-thin-air)的其中一種例子 foo被reorder成foo2,就happen-before而言,這沒錯,但執行下去就出事

// x == y == 0
void foo(){
  r1=x;
  if(r1 != 0)
    y=42;
}

/*
void foo2(){
  y=42;
  r1=x;
  if(r1 != 0)
    y=0;
}
*/

void bar(){
  r2 = y;
  if(r2 != 0)
     x=42;
}
// r1==r2==42?

為了防止這種事發生,才有causality檢查

根據

  • 控制流
  • 資料相依

去看執行結果會不會有奇怪的東西

關於如何檢查,我看了好久還是不太懂,在ref有範例,等PLT的功力回來再說吧

實際上的做法是

  1. 保持原子性
  • volatile (++是複合動作,要注意)
    • 原本volatile只是確保取值都從mem拿,確保可見性
    • 但java memory model保證volatile讀寫比較不會被reorder,進而保證了原子性(只有一寫對一個以上的讀才有!!)
      • 但沒有mutual exclusion,所以如果很多thread一起攻擊的話就會出事,可以看下面的code
  • synchronized
  1. 上barrier與禁止reorder,保持可見性與順序性

java的volatile

一個加一個減,照理來說要是0

public class Main {
    public static volatile int race = 0;
    private static final int INCREASE_COUNT = 10000;
    
    public static void main (String[] args) {
        Thread[] threads = new Thread[2];
        System.out.println(System.currentTimeMillis());
        threads[0] = new Thread(new Runnable() {
                @Override
                public void run () {
                    for (int i = 0; i < INCREASE_COUNT; i++) {
                        race++;
                    }
                }
            });
        threads[1] = new Thread(new Runnable() {
                @Override
                public void run () {
                    for (int i = 0; i < INCREASE_COUNT; i++) {
                        race--;
                    }
                }
            });
        threads[0].start();
        threads[1].start();
        while (Thread.activeCount() > 1) {
             Thread.yield();
        }
        System.out.println(System.currentTimeMillis());
        System.out.println(race);
    }
}

volatile的happen-before是寫之後可以被所有讀看到,但是剛剛是一個讀前面有一堆寫 要處理這件事就是加個條件,在不符合條件時就不做事,確保只有一個寫

public class Main {
    public static volatile int race = 0;
    private static final int INCREASE_COUNT = 10000000;
    
    public static void main (String[] args) {
        Thread[] threads = new Thread[2];
        System.out.println(System.currentTimeMillis());
        threads[0] = new Thread(new Runnable() {
                @Override
                public void run () {
                    for (int i = 0; i < INCREASE_COUNT; i++) {
                        if (race <= 0) {
                            race++;
                        } else {
                            i--;
                        }
                    }
                }
            });
        threads[1] = new Thread(new Runnable() {
                @Override
                public void run () {
                    for (int i = 0; i < INCREASE_COUNT; i++) {
                        if (race > 0) {
                            race--;
                        } else {
                            i--;
                        }
                    }
                }
            });
        threads[0].start();
        threads[1].start();
        while (Thread.activeCount() > 1) {
             Thread.yield();
        }
        System.out.println(System.currentTimeMillis());
        System.out.println(race);
    }
}

所以如果真的需要就是用AtomicInteger,同時我用jdoodle去跑明顯這個比較快 因為不是每次的loop都會做事,可能有不做事的時候,導致時間的浪費

import java.util.concurrent.atomic.AtomicInteger;
public class Main {
    public static AtomicInteger race = new AtomicInteger();
    private static final int INCREASE_COUNT = 10000000;
    
    public static void main (String[] args) {
        Thread[] threads = new Thread[2];
        System.out.println(System.currentTimeMillis());
        threads[0] = new Thread(new Runnable() {
                @Override
                public void run () {
                    for (int i = 0; i < INCREASE_COUNT; i++) {
                        race.incrementAndGet();
                    }
                }
            });
        threads[1] = new Thread(new Runnable() {
                @Override
                public void run () {
                    for (int i = 0; i < INCREASE_COUNT; i++) {
                        race.decrementAndGet();
                    }
                }
            });
        threads[0].start();
        threads[1].start();
        while (Thread.activeCount() > 1) {
             Thread.yield();
        }
        System.out.println(System.currentTimeMillis());
        System.out.println(race);
    }
}

建到一半的物件

Double-Checked Locking

用在multi-thread生singlton,但是會出事

class Foo {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized(this) {
                if (helper == null)
                    helper = new Helper();
            }
        }
        return helper;
    }
}

在設定helper的值時,可以reorder成

  1. 先給ref
  2. 之後init物件

但拿到ref就會讓if過,最後有人會拿到建到一半的物件

其中一個解法是volatile,因為不會reorder

class Foo {
    private volatile Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized(this) {
                if (helper == null)
                    helper = new Helper();
            }
        }
        return helper;
    }
}

另一個解法是static的field,class初始化完static要存在可以利用這個特點

private static class LazyFooHolder {
  public static Foo foo = new Foo();
}

public static Foo getInstance() {
  return LazyFooHolder.foo;
}

final field

java memory model保證任何access final的thread會看到freeze後的資料

中間是藉由禁reorder(freeze不能在設值前發生,access不能再freeze前發生)

但有趣的是,如果把this傳出去就不一定了

Obj1 obj = new Obj1();
final_field = 42;
ptr1 = this;
//freeze final_field
ptr2 = this;

如果有thread看到ptr1,就直接去用,這樣就無法保證一定看的到final_field,因為設定ptr的指令可能被reorder,最後ptr1拿到的就是建到一半的物件(連final都還沒跑完)

cpp memory model

基本就是基於java memory model,但多了可以控制atomic的memory barrier可以寬到哪邊去

介紹memory barrier

就是控制reorder不要讓指令跑過barrier

而指令可以分成Load或Store,所以有四種case

  • LoadStore
  • LoadLoad
    • LoadLoad + LoadStore組成Acquire semantics
    • java的volatile的寫與cpp的memory_order_acquire
  • StoreStore
    • StoreStore + LoadStore組成Release semantics
    • java的volatile的讀與cpp的memory_order_release
  • StoreLoad
    • 只有這個是強與弱memory model都可以reorder的,所以call這個一定會有事發生

barrier有cpu版與compiler版,效力自然是cpu比較大

memory_order

  • memory_order_relaxed
    • 就是沒有barrier,只剩下原子性
  • memory_order_acquire
    • 在這點之後的所有write與自己的read不會超過這裡
    • 可以想成是第一個write,然後後面有很多其他write與自己的read
    • 更形象一點可以想成上蓋
    • 對應到java的volatile寫
  • memory_order_consume
    • 與memory_order_acquire很像
    • 但是只限制與目前變數有關的write不超過這邊
  • memory_order_release
    • 在這點之後的所有write與自己的read不會超過這裡
    • 可以想成是最後一個read,然後前面有很多其他write與自己的read
    • 更形象一點可以想成下底
    • 對應到java的volatile讀
  • memory_order_acq_rel
    • acquire + release
    • 這感覺就很像楚河漢界,上半部與下半部不會混在一起
  • memory_order_seq_cst
    • 就是java的volatile,照順序來

臨時追加: ABA問題

主要是在lock-free才會提到,因為會看修改前與修改後沒有內容是不是沒被動過,但是如果只是看起來沒被動過?

ABA問題有三個條件

  1. 重複讀某變數,同時只用此變數做condtion
  2. 每次讀與寫都沒有同步
  3. 被多個thread修改,值有可能變回去以前的某一個值

如何處理

加入一個變數,只能單調遞增 在做cond時除了看原本的變數,這個單調遞增的變數也要看

轉帳問題

1. 小琳在 ATM 1 转账 100 块钱给小李;
2. 由于ATM 1 出现了网络拥塞的原因卡住了,这时候小琳跑到旁边的 ATM 2 再次操作转账;(有兩次轉帳)
3. ATM 2 没让小琳失望,执行了 CAS(100,0),很痛快地完成了转账,此时小琳的账户余额为 0;(第一次轉帳)
4. 小王这时候又给小琳账上转了 100,此时小琳账上余额为 100;
5. 这时候 ATM 1 网络恢复,继续执行 CAS(100,0),居然执行成功了,小琳账户上余额又变为了 0;(第二次轉帳)
6. 这时候小王微信跟小琳说转了 100 过去,是否收到呢?小琳去查了下账,摇了摇头,那么问题来了,钱去了哪呢?

Ref

The Java Memory Model(這個有因果關係檢查的範例) Java Memory Model Pragmatics(人性化很多,適合第一次看) JSR-133 Review(中文的,解釋得很好) 对优化说不 - Linux中的Barrier ABA problem