1. 程式人生 > >附1:多線程並發方案的不足——響應式Spring的道法術器

附1:多線程並發方案的不足——響應式Spring的道法術器

響應式編程

本系列文章索引《響應式Spring的道法術器》
本篇內容是響應式流的附錄。

(以下接響應式流的1.2.1.1節,關於“CPU眼中的時間”的內容。請不要單獨看這一篇內容,否則有些內容可能讓你摸不著頭腦 0..0)

多線程的方式有其不完美之處,而且有些難以駕馭——

一、耗時的上下文切換

CPU先生不太樂意切換進程,每次換進程的時候都需要一個小時,因為每次切換進程的時候,辦公桌上所有的資料(進程上下文)都要重新換掉。但是每個進程都必須雨露均沾地照顧到,要不客戶就不滿意了。

操作系統部很貼心,進程任務裏邊又可以分成一批線程子任務,如果某個線程子任務在等待I/O和網絡的數據,就先放一邊換另一個線程子任務去做,而不是切換整個進程。切換線程任務是一種相對輕量級的操作,因為不同的線程任務的許多數據都是共享的,所以只需要更新線程相關的數據,不用完全重新整理辦公桌。即便如此也需要將近半小時

的切換時間,以便能夠“熟悉”任務內容。

不過畢竟,CPU先生的工作逐漸充實起來了。如圖:

技術分享圖片

註意到圖中CPU的時間條中深褐色的為上下文切換的時間,可以想見,高並發情況下,線程數會非常多,那麽上下文切換對資源的消耗也會變得明顯起來。況且在切換過程中,CPU並未執行任何業務上的或有意義的計算邏輯。

這裏我們沒有關註線程的創建時間,因為應用通常會維護一個線程池。需要新的線程執行任務的時候,就從線程池裏取,用完之後返還線程池。類似的,數據庫連接池也是同樣的道理。

通過這個圖,我們可以得到估算線程池的大小的方法。為了讓CPU能夠恰好跑滿,Java Web服務器的最佳工作線程數符合以下公式:

( 1 + IO阻塞時間 / CPU處理時間 ) * CPU個數

二、煩人的互斥鎖

由於討生活不易,CPU先生練就了“左右互搏術”,對外就說“超線程”,特有逼格。對於有些任務,工作效率幾乎可以翻倍了,不過計算題多的時候,就不太行了,畢竟雖有兩只手卻只有一個腦子啊。而且今時不同往日,現在的CPU早就不再單打獨鬥了,比如CPU先生的辦公室就還有3個CPU同事,4個CPU核心組成一個團隊提供計算服務。

也正因為如此,涉及到線程間共享的數據方面,互相之間合作時不時出現沖突。

作為“貼身秘書”的一級緩存,每個CPU核心都會配置一個。CPU先生的秘書特別有眼力價,能隨時準備好80%的數據給CPU先生使用,這些數據資料都是找內存組的同事要的。要的時候會讓內存組的同事復印一份數據資料,拿來交給CPU先生。CPU先生算好之後的結果,它會再拿給內存組的同事。

有時候兩個執行不同線程任務的CPU幾乎同時讓秘書來內存組要復印的數據資料,各自算好之後,再返回給內存組的同事更新,於是就會有一個結果會覆蓋另一個結果,也就是說先算完的結果等於白算了。如下圖“a)無鎖”所示,線程1中的值已更新,但尚未通知到內存,也就是說線程2拿到的是過時的值,結果顯然就不對了。所以,對於多線程頻繁變化的共享數據,CPU先生會額外留個心眼,讓秘書拿數據資料的時候,順便告訴內存把這個數據先鎖起來。直到算完的數據再拿回給內存的時候,才讓它把鎖解開。上鎖期間,別人不能拿這個數據。其實不光是防別的CPU,CPU先生自己處理的多個線程之間都有可能出現這種沖突的情況,畢竟CPU先生雖然算題快,記性卻非常差。

如此,下圖“b)加鎖”(怎麽讀起來感覺這麽順嘴0_0)這種方式就能夠保證數據的一致性了。

技術分享圖片

這種方式叫做互斥鎖。CPU先生粗略算了一下,每次加鎖或解鎖,大概會花費它1分鐘左右的時間,但是可能被加鎖的數據就是為了花幾秒算個自增。這鎖能起到作用還好,更糟心的是,許多時候,線程可能沒有很多,撞到同一份數據資料的概率其實很低,但是以防萬一,還不得不加鎖,白白增加工作時間。

數據被加鎖的時候讓CPU先生很煩躁,誰知道它啥時候才會解鎖,只能讓“貼身秘書”時不時去看看了,鎖解開之前這個線程就被阻塞住了。

更過分的是CPU先生遇到的一次死鎖事件,至今令它心有余悸。那天它讓“貼身秘書”找內存要數據,並讓內存把數據鎖起來。拿到手才發現數據有A、B兩部分,它們只拿到了A。CPU先生讓秘書去要B,問了好多次,一直都被鎖著。後來才知道,另外一個CPU的秘書幾乎同時拿到並鎖了B,也一直在等A。WTF!

三、樂觀不起來的樂觀鎖

CPU先生與其他眾CPU一合計,不是頻繁改動的數據就不加鎖了,大家在往回更新數據的時候先看看有沒有被人動過不就得了。如下圖,

技術分享圖片

執行線程2的時候取走的是i==1,算完回來要更新的時候,發現i==2了,那剛才算的不作數,從新取值再算一遍。這種“比較並交換(Compare-and-Swap,CAS)”的指令是原子的,“現場檢查現場更新”,不會給其他線程以可乘之機。

樂觀情況下,如果線程不多,互相沖突的幾率不大的話,很少導致阻塞情況的出現,既確保了數據一致性,又保證了性能。

但樂觀鎖也有其局限性,在高並發環境下,如果樂觀鎖所保護的計算邏輯執行時間稍微長一些,可能會陷入一直被別人更新的狀態,往往性能還不如悲觀鎖。所以高並發且數據競爭激烈的情況下,樂觀鎖出場率並不高。

如果在高並發且某些變量容易被頻繁改動的情況下,CAS比較失敗並重新計算的概率就高了。我們不妨做個實驗,擴展一個具有CAS算法的AtomicInteger

MyAtomicInteger.java

public class MyAtomicInteger extends AtomicInteger {
    private AtomicLong failureCount = new AtomicLong(0);

    public long getFailureCount() {
        return failureCount.get();
    }

    /**
     * 從以下兩個方法 inc 和 dec 可以看出 Atomic* 的原子性的實現原理:
     * 這是一種樂觀鎖,每次修改值都會【先比較再賦值】,這個操作在CPU層面是原子的,從而保證了其原子性。
     * 如果比較發現值已經被其他線程變了,那麽就返回 false,然後重新嘗試。
     */
    public void inc() {
        Integer value;
        do {
            value = get();
            failureCount.getAndIncrement();
            //try {
            //    TimeUnit.MILLISECONDS.sleep(2);
            //} catch (InterruptedException e) {
            //    e.printStackTrace();
            //}
        } while (!compareAndSet(value, value + 1));
    }

    public void dec() {
        Integer value;
        do {
            value = get();
            failureCount.getAndIncrement();
            //try {
            //    TimeUnit.MILLISECONDS.sleep(2);
            //} catch (InterruptedException e) {
            //    e.printStackTrace();
            //}
        } while (!compareAndSet(value, value - 1));
    }
}

測試:

@Test
public void testCustomizeAtomic() throws InterruptedException {
    final MyAtomicInteger myAtomicInteger = new MyAtomicInteger();
    // 執行自增和自減操作的線程各10個,每個線程操作10000次
    Thread[] incs = new Thread[10];
    Thread[] decs = new Thread[10];
    for (int i = 0; i < incs.length; i++) {
        incs[i] = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                myAtomicInteger.inc();
            }
        });
        incs[i].start();
        decs[i] = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                myAtomicInteger.dec();
            }
        });
        decs[i].start();
    }

    for (int i = 0; i < 10; i++) {
        incs[i].join();
        decs[i].join();
    }

    System.out.println(myAtomicInteger.get() + " with " + myAtomicInteger.getFailureCount() + " failed tries.");
}

我電腦上跑出的結果是:

0 with 223501 failed tries.

總共20萬次操作,失敗了22萬多次。

高並發情況下,如果計算時間比較長,那麽就容易陷入總是被別人更新的狀態,而導致性能急劇下降。比如上例,如果把MyAtomicInteger.java中sleep的註釋打開,再次跑測試,就會出現久久都無法執行結束的情況。

四、莫名躺槍的指令重排

對於CPU先生來說,沒有多線程的日子挺美好的。有了多線程之後,總是會莫名踩坑,比如一次接到的任務是有兩個線程子任務:

// 一個線程執行:
a = 1;
x = b;

// 另一個線程執行:
b = 1;
y = a;

a, b, x, y 的初始值都是0。

CPU先生的註意力只在當前線程,而從來記不住切換過來之前的線程做了什麽,做到哪了。客戶也知道CPU先生記性不好,而且執行到半截可能會切到另一個線程去,對於可能出現的結果也有心理準備:

  1. x==0 && y==1:可能的執行順序比如:a = 1; x = b; b = 1; y = a
  2. x==1 && y==0:可能的執行順序比如:b = 1; y = a; a = 1; x = b
  3. x==1 && y==1:可能的執行順序比如:a = 1; b = 1; x = b; y = a

無外乎這三種結果嘛。結果CPU先生給算出了 x==0 && y==0!出現這種結果的原因只能是 x = b; y = a; 是在 a = 1; b = 1;這兩句之前執行的。Buy why?

事實上CPU和編譯器對於程序語句的執行順序還有會做一些優化的。比如第一個線程的兩句程序,相互之間並無任何依賴關系,對於當前線程來說,調整執行順序並不會影響邏輯結果。比如執行第一句的時候a的結果CPU先生和“秘書”一級緩存都沒有,這時候會跟二級緩存甚至內存要,但是CPU先生閑著難受,等待a的值的工夫就先把x = b執行了。第二個線程也有可能出現同樣的情況,從而導致了第四種結果的出現。

對於沒有依賴關系的執行語句,編譯期和CPU會酌情進行指令重排,以便優化執行效率。這種優化在單線程下是沒問題的,但是多線程下就需要在開發程序的時候采取一些措施來避免這種情況了。

CPU先生:怪我咯?~

五、委屈的內存

多線程的處理方式對於解決客戶的高並發需求確實很給力,雖然許多線程是處於等待數據狀態,但總有一些線程能讓CPU先生的工作飽和起來。客戶對計算組CPU們吃苦耐勞的工作態度贊賞有加。

內存組則有些委屈了,多線程也有它們的很大功勞,CPU的“貼身秘書”只是保管一小部分臨時數據,絕大多數的數據還是堆在內存組。多一個線程就得需要為這個線程準備一塊兒“工作內存”,雖然劃撥給內存組的空間越來越大,但是如果動不動就開成百上千個線程的話,面對堆積如山的數據還總是不太夠。

六、多線程並非銀彈

搞IT的如果不套用一句“XXX並非銀彈”總是顯得逼格不夠,所以我也鄭重其事地說一句“多線程並非銀彈”。以上歸納下來:

  • 高並發環境下,多線程的切換會消耗CPU資源;
  • 應對高並發環境的多線程開發相對比較難,並且有些問題難以在測試環境發現或重現;
  • 高並發環境下,更多的線程意味著更多的內存占用。

附1:多線程並發方案的不足——響應式Spring的道法術器