JVM併發程式設計模型覽

sorra發表於2016-11-28

我們知道程式分為同步風格和非同步風格。

  • 可以寫成同步風格用多個執行緒來併發執行。
  • 也可以寫成非同步風格以支援更為靈活的排程。
  • 非同步更適合併發程式設計。

為什麼要非同步

非同步的目的:充分利用計算資源。

同步使執行緒阻塞,導致等待。

非同步是非阻塞的,無需等待。

如果發生了不必要的等待,就會浪費資源,使程式變慢。

比如這樣的程式:

val res1 = get("http://server1")
val res2 = get("http://server2")
compute(res1, res2)

按照同步程式設計風格,一定要先拿到res1,才能開始拿res2。

按照非同步程式設計風格,res1和res2互不依賴,發起對res1的獲取後,不必等待結果,而是馬上發起對res2的獲取,到了compute的時候,才需要阻塞等待兩個資料。

這是一種“順序解耦”。有時候我們並不要求某些操作按順序執行!那麼為什麼要強制其順序呢?非同步風格讓我們能放棄強制,解放資源,減少不必要的等待。

如果非同步操作能並行,程式效能就提升了,如果不能並行,程式效能就沒有提升。在當今的硬體條件下,一般都能並行,所以非同步成為了趨勢。

怎麼個並行法?這要從計算機架構說起了。讓我們把任何有處理能力的硬體看做一個處理單元——CPU顯然是主要的處理單元,I/O裝置也是處理單元,比如說網路卡、記憶體控制器、硬碟控制器。CPU可以向一或多個I/O裝置發出請求,當裝置在準備資料時,CPU可以做其他事情(裝置就緒後會用中斷通知CPU),這時就有n個硬體在並行了!況且CPU本就是多核的,能做平行計算。除此之外,在分散式系統中,能同時調動多臺計算機配合完成任務,也是並行。

因此,讓CPU等待、每次只請求一個I/O裝置、不利用多核、不利用其他空閒的計算機,都是比較浪費的。

下面我們來分析常見的併發程式設計模型。

基本模型

Thread

這是最簡單的模型,建立執行緒來執行一個任務,完畢後銷燬執行緒。當任務數量大時,會建立大量的執行緒。

大家都知道大量的執行緒會降低效能,但是你真的清楚效能開銷在哪裡嗎?我試列舉一下:

  • 建立執行緒

    建立一個執行緒是比較耗時間的。需要請求作業系統、分配棧空間、初始化等工作。

  • 上下文切換

    大家都知道的,作業系統基本概念,不再贅述。值得注意的是,WAITING狀態的執行緒(多見於I/O等待)幾乎不會被排程,因此並不導致過多的上下文切換。

  • CPU cache miss

    大量執行緒頻繁切換,勢必要訪問不同的資料,打亂了空間區域性性,導致CPU cache miss增加,需要經常訪問更慢的記憶體,會明顯影響CPU密集型程式的效能,這點大家恐怕沒想到吧。

  • 記憶體佔用

    執行緒會增加記憶體佔用,執行緒的棧空間通常佔1MB,1000個就是1GB。而且在棧上引用了很多物件,暫時不能回收,你說有多少個GB?

  • 資源佔用

    一些有限的資源,如鎖、資料庫連線、檔案控制程式碼等,當執行緒被掛起或阻塞,就暫時無人可用了,浪費!還有死鎖風險!

那麼分配多少執行緒好呢?

  • 對於I/O密集型程式:一個經驗數值是兩倍於資料庫連線數,例如你有30個資料庫連線,就開60個執行緒;我還有個經驗數值是500以下,超過500就慢一些,如果呼叫棧特別深,這個數值還要下調。
  • 對於CPU密集型程式:我的經驗數值是略多於CPU核心數 (理論上是等於,但你難免有幾個阻塞操作)。除了核心數,還要考慮CPU cache的大小,最好實際測試一下。舉個例子,某司內部的自動重構程式在Intel i7 3630QM CPU上測試,3~4個執行緒效果最好。

傳統的網路程式是每個會話佔用一個連線、一個執行緒。I/O多路複用(I/O multiplexing:多個會話共用一個連線)是應C10K問題而生的,C10K就是1萬個連線。1萬個連線是很耗系統資源的,何況還有1萬個執行緒。從上文的分析可知,C1K的時候就可以開始運用I/O多路複用了。

Thread Pool

預留一些可反覆使用的執行緒在一個池裡,反覆地接受任務。執行緒數量可能是固定的,也可能是一定範圍內變動的,依所選擇的執行緒池的實現而定。

這個模型是極其常用的,例如Tomcat就是用執行緒池來處理請求的。

注意——儘量不要阻塞任務執行緒;若實在無法避免,多開一些執行緒——每阻塞一個執行緒,執行緒池就少一個可用的執行緒。

Java典型的執行緒池有Executors.newFixedThreadPool Executors.newFixedThreadPool Executors.newFixedThreadPool Executors.newScheduledThreadPool等等,也可以直接new ThreadPoolExecutor(可指定執行緒數的上限和下限)。

Scala沒有增加新的執行緒池種類,但有個blocking方法能告訴執行緒池某個呼叫會阻塞,需要臨時增加1個執行緒。

Future

Future是一個未來將會有值的物件,相當於一個佔位符(提貨憑證!)。

將任務投入執行緒池執行時,可為任務繫結一個Future,憑此Future即可在未來取得任務執行結果。未來是什麼時候呢?要通過檢查Future內部的狀態來獲知——任務完成時會修改這個狀態,將執行結果存進去。

最初的程式碼示例可改寫為:

// 兩個future是並行的
val f1 = Future { get("http://server1") }
val f2 = Future { get("http://server2") }
compute(f1.get(), f2.get())

高階模型

Rx

Rx (Reactive Extensions)是響應式程式設計的一種具體形式。響應式程式設計是一種面向資料流和變化傳播的程式設計模式。

我們知道Java 8提供了Stream型別,代表一個有限或無限的資料流,可應用map, filter, collect等操作。Rx類似於Stream,也是有限或無限的資料流,只不過資料操作可以委託給執行緒池非同步執行。(Rx也像是生產者/消費者模型的延伸,增加了分發和轉換的能力。對資料流進行連線組合,這邊生產,那邊分發和轉換,源源不斷交給消費者。)

以RxJava為例:

Flowable.just("file.txt")
.map(name -> Files.readLines(name))
.subscribe(lines -> System.out.println(lines.size()), Throwable::printStackTrace);

以Reactor為例:

Flux.fromIterable(getSomeLongList())
    .mergeWith(Flux.interval(100))
    .doOnNext(serviceA::someObserver)
    .map(d -> d * 2)
    .take(3)
    .onErrorResumeWith(errorHandler::fallback)
    .doAfterTerminate(serviceM::incrementTerminate)
    .subscribe(System.out::println);

由程式碼可見,對資料流的操作很像是對集合的函式式操作,subscribe就是非同步的forEach,doOnNext就是有返回值的非同步的forEach。

主流實現有RxJava、Reactor、Akka Streams,API各有不同。但是它們都在靠攏Reactive Streams規範,想必會變得越來越相似。

async-await

async-await是一種特殊語法,能自動把同步風格程式碼轉換成非同步風格程式碼。正確運用,就能使程式碼在阻塞時自動讓出控制權。

C#內建的async-await是最完整的實現。Scala通過Async庫提供這個語法,程式碼大概是這樣:

val future = async {
  println("Begin blocking")
  await {
    async {Thread.sleep(1000)}
  }
  println("End blocking")    
}

程式碼會被自動轉換成多種future的組合形式。無需特意處理,能並行的部分都會自動並行。

Fiber

Fiber是協程的仿製品。一般多執行緒是搶佔式排程,你一個任務跑得好好的突然把你暫停;協程是協作式的,你一個任務阻塞或完成時要主動讓出控制權,讓排程器換入另一個任務。

async-await自動把程式碼轉換成可自動讓出控制權的形式,已經有協程的雛形了。Fiber更加智慧,連async-await語法都不用了,只要把程式碼寫在Fiber裡面,就像寫在Thread裡面一樣,自動非同步化了。

async-await只能暫存當前作用域(轉換成閉包),Fiber則能暫存整個執行棧(每個作用域只是一個棧幀)。當然了,運用巢狀的async-await也能暫存整個執行棧,我更贊同如此,因為能更好地控制記憶體佔用。

JVM上主流的實現是Quasar,通過java-agent改寫位元組碼來實現,在需要讓出控制權時丟擲異常打斷控制流(不必擔心異常方面的效能開銷),儲存執行棧,然後換入另一個任務。

Java示例:

new Fiber<V>() {
  @Override
  protected V run() throws SuspendExecution, InterruptedException {
    // your code
  }
}.start();

Kotlin示例:

fiber @Suspendable {
  // your code
}

程式碼中呼叫的任何會阻塞的方法都要標記@Suspendable,讓Quasar知道調這個方法時要暫停當前Fiber並執行另一個Fiber,同時用另外的執行緒池執行會阻塞的方法。

Actor

起源於電信領域的Erlang的程式設計模型。actor是任務處理單元:每個actor只處理一個任務,每個任務同時只有一個actor處理(如果有大任務,就要分解成小任務),actor之間用訊息來通訊。

在Erlang中,每個actor是一個輕量級程式,有獨立的記憶體空間(所以通訊只能靠訊息),因此有獨立的垃圾回收,不會stop the world。

actor可以發了訊息就不管了(tell),這是典型的非同步;也可以發了訊息等回應(ask),返回值是一個Future,實際上是建立了一個新的actor在悄悄等待回應,仍然是非同步。

actor可以透明地分佈在不同機器上,訊息可以發給本機的actor,也可以發給遠端的actor。

JVM上唯一成熟的實現是Akka,JVM不能給每個actor獨立的記憶體,垃圾回收仍可能stop the world。

actor顯然是一個物件,擁有狀態和行為。 actor也可被視為一個閉包,擁有函式和上下文(整個物件的狀態都是上下文)。 actor每次能接收並處理一個訊息,處理中可以傳送訊息給自己或另一個actor,然後掛起或結束。 為什麼要傳送訊息給自己呢?因為正在處理訊息時是不能掛起的,只能在“一個訊息之後,下一個訊息之前”的間隙中掛起。 假設你收到一個A訊息,執行前半段業務邏輯,要做一次I/O再執行後半段業務邏輯。做I/O時應當結束當前處理,當IO完成時給自己發一個B訊息,下次再讓你在處理B訊息時完成剩餘業務邏輯。前後邏輯要分開寫,共享變數要宣告為actor的物件欄位。 虛擬碼如下:

class MyActor extends BasicActor {
  var halfDoneResult: XXX = None

  def receive(): Receive = {
    case A => {
      halfDoneResult = 前半段邏輯()
      doIO(halfDoneResult).onComplete {
        self ! B()
      }
    }
    case B => 後半段邏輯(halfDoneResult)
  }
}

當actor的狀態要徹底改變時,可以用become操作徹底改變actor的行為。從物件導向程式設計的設計模式來看,這是state pattern,從函數語言程式設計來看,這是把一個函式變換成另一個函式。

由此可見,actor模型就是把函式表示成了更容易控制的物件,以便於滿足一些併發或分散式方面的架構約束。

這段邏輯假如改寫成async-await或fiber,虛擬碼如下所示,簡單多了:

def logicInAsync() = async {
  val halfDoneResult = 前半段邏輯()
  await { doIO(halfDoneResult) }
  後半段邏輯(halfDoneResult)
}

def logicInFiber() = fiber {
  val halfDoneResult = 前半段邏輯()
  doIO(halfDoneResult)
  後半段邏輯(halfDoneResult)
}

Actor與分散式架構

可以看出,相比於async-await或Fiber,actor就是一種狀態機,是較為底層、不易用的程式設計模型。但是actor附帶了成熟的分散式能力。

我感覺actor很像非同步版的EJB。EJB中有stateless session bean和stateful session bean,actor也可按stateless和stateful來分類。

PayPal的支付系統就是基於Akka的,還為此編寫並開源了一個Squbs框架。業務邏輯仍是用actor實現,Squbs只增加了整合和運維方面的支援(這個也重要)。然而我對此技術路線(業務邏輯基於actor)持審慎態度,接下來就分類說明我的意見:

無狀態的分散式架構

我認為,此架構只需要三種通訊模型:訊息佇列、同步RPC、非同步RPC。

  • 訊息佇列:非同步的,只管傳送訊息,不等待返回結果(即使需要知道結果,讓consumer向sender回發一個訊息即可,會非同步觸發sender這邊的回撥)。訊息可能觸發遠端的一個任務,也可能觸發更多訊息的發出,也可能什麼都不觸發。
  • 同步RPC:同步的,向遠端結點傳送訊息,保持當前的執行棧,同步等待回覆。執行棧一直佔著執行緒。簡單易懂而廣泛流行的模型。
  • 非同步RPC:非同步的,向遠端結點傳送訊息,保持當前的執行棧,非同步等待回覆。執行棧可暫時被換出執行緒,收到回覆時再切回。

訊息佇列、同步RPC都不需要Akka出場,自有各種MQ、RPC框架來解決。至於非同步RPC,GRPC是一個跨語言的RPC框架,也可建造一個基於WebSocket協議的RPC框架。如果無需跨語言,也可讓Akka出場,但不是直接基於Akka程式設計——而是在Akka之上構建一個RPC層。如果功力較高,可直接基於Netty構建RPC層。

actor進行“請求-響應”往返通訊時,在收到響應之前,請求端的actor要掛起、暫存在記憶體中。協程進行這種通訊時,則是請求端的執行棧要掛起、暫存在記憶體中。

有狀態的分散式架構

這是actor的龍興之地, 也是最合適的用武之地。

以即時聊天(IM)為例,用actor怎麼實現呢?

  • 如果每個actor對應一個人,1萬人只需要1萬個actor,1萬個連線。使用者A對使用者B說話時,actor A收到訊息,轉發給actor B,由actor B傳送給使用者B,反之亦然。
  • 如果每個actor對應一個會話,最多需要1億(1萬×1萬)個actor,連線數不到1億(同一臺伺服器與某個使用者的連線可供相關會話共用),但也過多了。

因此選擇第一種實現:每個actor對應一個人,actor要記得它對應哪個人、訊息往來情況如何,這就是“狀態”!如果10萬使用者線上,就要10萬連線(這與IO多路複用無關,對吧?),單機顯然hold不住,需要多機。如果用actor A和actor B不在同一臺機器,就要遠端通訊了。對基於Akka的程式來說,本地通訊或遠端通訊是透明的,贊!

其實不用actor也能實現,一切狀態和關係都能用資料結構來表達,只不過actor可能更方便一些。

總而言之,Akka模仿Erlang,精心設計了業務無關的actor的概念,然而越是精心設計的業務無關的概念越有可能不符合多變的業務需求:)。如果問我用不用actor,我只能說,看情況吧。也希望有哪位英雄能介紹一兩個非actor不可的場景。

再與RPC對比

現在,假設有一個微服務架構,在眾多服務中有A、B、C三個服務,呼叫順序是A->B->C。RPC只能以A->B->C的方向請求,再以C->B->A的方向響應;actor則能讓C直接傳送響應給A。但如果C要直接回復A,就要與A建立連線,使網路拓撲和依賴管理都變複雜了——如非必要,勿增複雜。

為了避免,利用MQ來傳送響應?MQ就像一個聊天服務,讓分佈各處的服務能彼此聊天。IM、actor、MQ,一切都聯絡起來了,有沒有感受到妙不可言的意境?

但是壓力集中到了MQ的broker,網路也多了一跳(publisher->broker->consumer),對效能有所影響。

結語

本文介紹、點評了JVM上多種常見的併發模型,並試圖建立模型之間的聯絡,最後以分散式架構為例加以分析。

那麼應用程式要怎麼寫呢?看文件吧,各種庫或框架都希望有人來用,滿足它們吧!

相關文章