1. 程式人生 > >聊聊高併發(二)結合例項說說執行緒封閉和背後的設計思想

聊聊高併發(二)結合例項說說執行緒封閉和背後的設計思想

高併發問題拋去架構層面的問題,落實到程式碼層面就是多執行緒的問題。多執行緒的問題主要是執行緒安全的問題(其他還有活躍性問題,效能問題等)。

那什麼是執行緒安全?下面這個定義來自《Java併發程式設計實戰》,這本書強烈推薦,是幾個Java語言的作者合寫的,都是併發程式設計方面的大神。

執行緒安全指的是:當多個執行緒訪問某個類時,這個類始終都能表現出正確的行為。

正確指的是“所見即所知”,程式執行的結果和你所預想的結果一致。

理解執行緒安全的概念很重要,所謂執行緒安全問題,就是處理物件狀態的問題。如果要處理的物件是無狀態的(不變性),或者可以避免多個執行緒共享的(執行緒封閉),那麼我們可以放心,這個物件可能是執行緒安全的。當無法避免,必須要共享這個物件狀態給多執行緒訪問時,這時候才用到執行緒同步的一系列技術。

這個理解放大到架構層面,我們來設計業務層程式碼時,業務層最好做到無狀態,這樣就業務層就具備了可伸縮性,可以通過橫向擴充套件平滑應對高併發。

所以我們處理執行緒安全可以有幾個層次:

1. 能否做成無狀態的不變物件。無狀態是最安全的。

2. 能否執行緒封閉

3. 採用何種同步技術

我理解為能夠“逃避”多執行緒問題,能逃則逃,實在不行了再來處理。

瞭解了執行緒封閉的背景,來說說執行緒封閉的具體技術和思路

1. 棧封閉

2. ThreadLocal

3. 程式控制執行緒封閉

棧封閉說白了就是多使用區域性變數。理解Java執行時模型的同學都知道區域性變數的引用是保持線上程棧中的,只對當前執行緒可見,其他執行緒不可見。所以區域性變數是執行緒安全的。

ThreadLocal機制本質上是程式控制執行緒封閉,只不過是Java本身幫忙處理了。來看Java的Thread類和ThreadLocal類

1. Thread執行緒類維護了一個ThreadLocalMap的例項變數

2. ThreadLocalMap就是一個Map結構

3. ThreadLocal的set方法取到當前執行緒,拿到當前執行緒的threadLocalMap物件,然後把ThreadLocal物件作為key,把要放入的值作為value,放到Map

4. ThreadLocal的get方法取到當前執行緒,拿到當前執行緒的threadLocalMap物件,然後把ThreadLocal物件作為key,拿到對應的value.

  1. public class Thread implements Runnable {

  2. ThreadLocal.ThreadLocalMap threadLocals = null;

  3. }

  4. public class ThreadLocal<T> {

  5. public T get() {

  6.         Thread t = Thread.currentThread();

  7.         ThreadLocalMap map = getMap(t);

  8.         if (map != null) {

  9.             ThreadLocalMap.Entry e = map.getEntry(this);

  10.             if (e != null)

  11.                 return (T)e.value;

  12.         }

  13.         return setInitialValue();

  14.     }

  15. ThreadLocalMap getMap(Thread t) {

  16.         return t.threadLocals;

  17.     }

  18. public void set(T value) {

  19.         Thread t = Thread.currentThread();

  20.         ThreadLocalMap map = getMap(t);

  21.         if (map != null)

  22.             map.set(this, value);

  23.         else

  24.             createMap(t, value);

  25.     }

  26. }

ThreadLocal的設計很簡單,就是給執行緒物件設定了一個內部的Map,可以放置一些資料。JVM從底層保證了Thread物件之間不會看到對方的資料。

使用ThreadLocal前提是給每個ThreadLocal儲存一個單獨的物件,這個物件不能是在多個ThreadLocal共享的,否則這個物件也是執行緒不安全的。

Structs2就用了ThreadLocal來儲存每個請求的資料,用了執行緒封閉的思想。但是ThreadLocal的缺點也顯而易見,必須儲存多個副本,採用空間換取效率。

程式控制執行緒封閉,這個不是一種具體的技術,而是一種設計思路,從設計上把處理一個物件狀態的程式碼都放到一個執行緒中去,從而避免執行緒安全的問題

有很多這樣的例項,Netty5的EventLoop就採用這樣的設計,我們的遊戲後臺處理使用者請求是也採用了這種設計。

具體的思路是這樣的:

1. 把和使用者狀態相關的程式碼放到一個佇列中去,由一個執行緒處理

2. 考慮是否隔離使用者之間的狀態,即一個使用者使用一個佇列,還是多個使用者使用一個佇列

拿Netty舉例,EventLoop被設計成了一個執行緒的執行緒池。我們知道執行緒池的組成是工作執行緒 + 任務佇列。EventLoop的工作執行緒只有一個。

使用者請求過來後被隨機放到一個EventLoop去,也就是放到EventLoop執行緒池的任務佇列,由一個執行緒來處理。並且處理使用者請求的程式碼都使用Pipeline職責鏈封裝好了,一個Pipeline交給一個執行緒來處理,從而保證了跟同一個使用者的狀態被封閉到了一個執行緒中去。

這裡有個問題也顯而易見,就是如果把多個使用者都放到一個佇列,交給一個執行緒處理,那麼前一個使用者的處理速度會影響到後一個使用者被處理的時間。

我們的遊戲伺服器的設計採用了一個使用者一個任務佇列的方式,處理任務的程式碼被做成了Runnable,這樣多個Runnable可以交給一個執行緒池執行,從而多個使用者可以同時被處理,而同一個使用者的狀態處理被封閉到了唯一的一個任務佇列中,互不干擾

但是也有問題,即執行緒池內的工作執行緒和任務佇列是有界的,所以單個執行緒處理的時間必須要快,否則大量請求被積壓在任務佇列來不及處理,一旦任務佇列也滿了,那麼後續的請求都進不來了。

如果使用無界的任務佇列,所有請求能進來,但是問題是高併發情況下大量請求過來,會把系統記憶體撐爆,倒置OOM。

所以一個常用的設計思路如下:

1. 採用有界的任務佇列和不限個數的工作執行緒,這樣可以平滑地處理高併發,不至於記憶體被撐爆

2. 單個執行緒請求時間必須要快,儘量不超過100ms

3. 如果單個執行緒處理的時間由於任務太大必須耗時,那麼把任務拆個小任務來多次執行

4. 拆成小任務還是慢,那麼把同步操作變成非同步操作,即方法執行後立即返回,不要等待結果。由另一個執行緒非同步地處理執行緒,比如採用單獨的執行緒定時檢查處理狀態,或者採用非同步回撥的方式