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

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

(如下接響應式流的1.2.1.1節,關於「CPU眼中的時間」的內容。請不要單獨看這一篇內容,不然有些內容可能讓你摸不着頭腦 0..0)react

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

1、耗時的上下文切換github

CPU先生不太樂意切換進程,每次換進程的時候都須要一個小時,由於每次切換進程的時候,辦公桌上全部的資料(進程上下文)都要從新換掉。可是每一個進程都必須雨露均沾地照顧到,要不客戶就不滿意了。算法

操做系統部很貼心,進程任務裏邊又能夠分紅一批線程子任務,若是某個線程子任務在等待I/O和網絡的數據,就先放一邊換另外一個線程子任務去作,而不是切換整個進程。切換線程任務是一種相對輕量級的操做,由於不一樣的線程任務的許多數據都是共享的,因此只須要更新線程相關的數據,不用徹底從新整理辦公桌。即使如此也須要將近半小時的切換時間,以便可以「熟悉」任務內容。數據庫

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

CPU堵塞-多線程

注意到圖中CPU的時間條中深褐色的爲上下文切換的時間,能夠想見,高併發狀況下,線程數會很是多,那麼上下文切換對資源的消耗也會變得明顯起來。何況在切換過程當中,CPU並未執行任何業務上的或有意義的計算邏輯。服務器

這裏咱們沒有關注線程的建立時間,由於應用一般會維護一個線程池。須要新的線程執行任務的時候,就從線程池裏取,用完以後返還線程池。相似的,數據庫鏈接池也是一樣的道理。網絡

經過這個圖,咱們能夠獲得估算線程池的大小的方法。爲了讓CPU可以剛好跑滿,Java Web服務器的最佳工做線程數符合如下公式:多線程

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

2、煩人的互斥鎖

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

也正由於如此,涉及到線程間共享的數據方面,互相之間合做時不時出現衝突。

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

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

如此,下圖「b)加鎖」(怎麼讀起來感受這麼順嘴0_0)這種方式就可以保證數據的一致性了。

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

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

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

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

3、樂觀不起來的樂觀鎖

CPU先生與其餘衆CPU一合計,不是頻繁改動的數據就不加鎖了,你們在往回更新數據的時候先看看有沒有被人動過不就得了。以下圖,

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

執行線程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的註釋打開,再次跑測試,就會出現久久都沒法執行結束的狀況。

4、莫名躺槍的指令重排

對於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先生:怪我咯?~

5、委屈的內存

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

內存組則有些委屈了,多線程也有它們的很大功勞,CPU的「貼身祕書」只是保管一小部分臨時數據,絕大多數的數據仍是堆在內存組。多一個線程就得須要爲這個線程準備一起「工做內存」,雖然劃撥給內存組的空間愈來愈大,可是若是動不動就開成百上千個線程的話,面對堆積如山的數據還老是不太夠。

6、多線程並不是銀彈

搞IT的若是不套用一句「XXX並不是銀彈」老是顯得逼格不夠,因此我也鄭重其事地說一句「多線程並不是銀彈」。以上概括下來:

  • 高併發環境下,多線程的切換會消耗CPU資源;
  • 應對高併發環境的多線程開發相對比較難,而且有些問題難以在測試環境發現或重現;
  • 高併發環境下,更多的線程意味着更多的內存佔用。
相關文章
相關標籤/搜索