Akka 系列(四):Akka 中的共享記憶體模型

ScalaCool發表於2017-05-03

本文由 GodPan 發表在 ScalaCool 團隊部落格。

通過前幾篇的學習,相信大家對Akka應該有所瞭解了,都說解決併發哪家強,JVM上面找Akka,那麼Akka到底在解決併發問題上幫我們做了什麼呢?

共享記憶體

眾所周知,在處理併發問題上面,最核心的一部分就是如何處理共享記憶體,很多時候我們都需要花費很多時間和精力在共享記憶體上,那麼在學習Akka對共享記憶體是如何管理之前,我們先來看看Java中是怎麼處理這個問題的。

Java共享記憶體

相信對Java併發有所瞭解的同學都應該知道在Java5推出JSR 133後,Java對記憶體管理有了更高標準的規範了,這使我們開發併發程式也有更好的標準了,不會有一些模糊的定義導致的無法確定的錯誤。

首先來看看一下Java記憶體模型的簡單構圖:

Akka 系列(四):Akka 中的共享記憶體模型
Java Memory

從圖中我們可以看到我們執行緒都有自己的一個工作記憶體,這就好比快取記憶體,它是對主記憶體部分資料的拷貝,執行緒對自己工作記憶體的操作速度遠遠快於對主記憶體的操作,但這也往往會引起共享變數不一致的問題,比如以下一個場景:

int a = 0;
public void setA() {
  a = a + 1;
}複製程式碼

上面是一個很簡單的例子,a是一個全域性變數,然後我們有一個方法去修改這個值,每次增加一,假如我們用100個執行緒去執行這段程式碼,那a最終的結果會是多少呢?
100?顯然不一定,它可能是80,90,或者其他數,這就造成共享變數不一致的問題,那麼為什麼會導致這個問題呢,就是我們上面所說的,執行緒去修改a的時候可能就只是修改了自己工作記憶體中a的副本,但並沒有將a的值及時的重新整理到主記憶體中,這便會導致其他執行緒可能讀到未被修改a的值,最終出現變數不一致問題。

那麼Java中是怎麼處理這種問題,如何保證共享變數的一致性的呢?

同步機制

大體上Java中有3類同步機制,但它們所解決的問題並不相同,我們先來看一看這三種機制:

  • final關鍵詞
  • volatile關鍵詞
  • synchronized關鍵詞(這裡代表了所有類似監視鎖的機制)
1.final關鍵詞

寫過Java程式的同學對這個關鍵詞應該再熟悉不過了,其基本含義就是不可變,不可變變數,比如:

final int a = 10;
final String b = "hello";複製程式碼

不可變的含義在於當你對這些變數或者物件賦初值後,不能再重新去賦值,但對於物件來說,我們不能修改的是它的引用,但是物件內的內容還是可以修改的。下面是一個簡單的例子:

final User u = new User(1,"a");
u.id = 2; //可以修改
u = new User(2,"b"); //不可修改複製程式碼

所以在利用final關鍵詞用來保證共享變數的一致性時一定要了解清楚自己的需求,選擇合適的方法,另外final變數必須在定義或者構建物件的時候進行初始化,不然會報錯。

2.volatile關鍵詞

很多同學在遇到共享變數不一致的問題後,都會說我在宣告變數前加一個volatile就好了,但事實真是這樣嘛?答案顯然不是。那我們來看看volatile到底為我們做了什麼。

前面我們說過每個執行緒都有自己的工作記憶體,很多時候執行緒去修改一個變數的值只是修改了自己工作記憶體中副本的值,這便會導致主記憶體的值並不是最新的,其他執行緒讀取到的變數便會出現問題。volatile幫我們解決了這個問題,它有兩個特點:

  • 執行緒每次都會去主記憶體中讀取變數
  • 執行緒每次修改變數後的值都會及時更新到主記憶體中去

舉個例子:

volatile int a = 0;
public void setA() {
  a = a + 1;
}複製程式碼

現線上程在執行這段程式碼時,都會強制去主記憶體中讀取變數的值,修改後也會馬上更新到主記憶體中去,但是這真的能解決共享變數不一致的問題嘛,其實不然,比如我們有這麼一個場景:兩個執行緒同時讀取了主記憶體中變數最新的值,這是我們兩個執行緒都去執行修改操作,最後結果會是什麼呢?這裡就留給大家自己去思考了,其實也很簡單的。

那麼volatile在什麼場景下能保證執行緒安全,按照官方來說,有以下兩個條件:

  • 對變數的寫操作不依賴於當前值
  • 該變數沒有包含在具有其他變數的不變式中

多的方面這裡我就不展開了,推薦兩篇我覺得寫的還不錯的文章:volatile的使用及其原理volatile的適用場景

3.synchronized關鍵詞

很多同學在學習Java併發過程中最先接觸的就是synchronized關鍵詞了,它確實能解決我們上述的併發問題,那它到時如何幫我們保證共享變數的一致性的呢?

簡而言之的說,執行緒在訪問請求用synchronized關鍵詞修飾的方法,程式碼塊都會要求獲得一個監視器鎖,當執行緒獲得了監視器鎖後,它才有許可權去執行相應的方法或程式碼塊,並在執行結束後釋放監視器鎖,這便能保證共享記憶體的一致性了,因為本文主要是講Akka的共享記憶體,過多的篇幅就不展開了,這裡推薦一篇解析synchronized原理很不錯的文章,有興趣的同學可以去看看:Synchronized及其實現原理

Akka共享記憶體

Akka中的共享記憶體是基於Actor模型的,Actor模型提倡的是:通過通訊來實現共享記憶體,而不是用共享記憶體來實現通訊,這點是跟Java解決共享記憶體最大的區別,舉個例子:
在Java中我們要去操作共享記憶體中資料時,每個執行緒都需要不斷的獲取共享記憶體的監視器鎖,然後將操作後的資料暴露給其他執行緒訪問使用,用共享記憶體來實現各個執行緒之間的通訊,而在Akka中我們可以將共享可變的變數作為一個Actor內部的狀態,利用Actor模型本身序列處理訊息的機制來保證變數的一致性。

當然要使用Akka中的機制也必須滿足一下兩條原則:

  • 訊息的傳送必須先於訊息的接收
  • 同一個Actor對一條訊息的處理先於下一條訊息處理

第二個原則很好理解,就是上面我們說的Actor內部是序列處理訊息,那我們來看看第一個原則,為什麼要保證訊息的傳送先於訊息的接收,是為了防止我們在建立訊息的時候發生了不確定的錯誤,接收者將可能接收到不正確的訊息,導致發生奇怪的異常,主要表現為訊息物件未初始化完整時,若沒有這條規則保證,Actor收到的訊息便會不完整。

通過前面的學習我們知道Actor是一種比執行緒更輕量級,抽象程度更高的一種結構,它幫我們規避了我們自己去操作執行緒,那麼Akka底層到底是怎麼幫我們去保證共享記憶體的一致性的呢?

一個Actor它可能會有很多執行緒同時向它傳送訊息,之前我們也說到Actor本身是序列處理的訊息的,那它是如何保障這種機制的呢?

Mailbox

Mailbox在Actor模型是一個很重要的概念,我們都知道向一個Actor傳送的訊息首先都會被儲存到它所對應的Mailbox中,那麼我們先來看看MailBox的定義結構(本文所引用的程式碼都在akka.dispatch.Mailbox.scala中,有興趣的同學也可以去研究一下):

private[akka] abstract class Mailbox(val messageQueue: MessageQueue)
  extends ForkJoinTask[Unit] with SystemMessageQueue with Runnable {}複製程式碼

很清晰Mailbox內部維護了一個messageQueue這樣的訊息佇列,並繼承了Scala自身定義的ForkJoinTask任務執行類和我們很熟悉的Runnable介面,由此可以看出,Mailbox底層還是利用Java中的執行緒進行處理的。那麼我們先來看看它的run方法:

override final def run(): Unit = {
    try {
      if (!isClosed) { //Volatile read, needed here
        processAllSystemMessages() //First, deal with any system messages
        processMailbox() //Then deal with messages
      }
    } finally {
      setAsIdle() //Volatile write, needed here
      dispatcher.registerForExecution(this, false, false)
    }
  }複製程式碼

為了配合理解,我們這裡先來看一下定義:

@inline
  final def currentStatus: Mailbox.Status = Unsafe.instance.getIntVolatile(this, AbstractMailbox.mailboxStatusOffset)

@inline
  final def isClosed: Boolean = currentStatus == Closed複製程式碼

這裡我們可以看出Mailbox本身會維護一個狀態Mailbox.Status,是一個Int變數,而且是可變的,並且用到volatile來保證了它的可見性:

@volatile
  protected var _statusDoNotCallMeDirectly: Status = _ //0 by default複製程式碼

現在我們在回去看上面的程式碼,run方法的執行過程,首先它會去讀取MailBox此時的狀態,因為是一個Volatile read,所以能保證讀取到的是最新的值,然後它會先處理任何的系統訊息,這部分不需要我們太過關心,之後便是執行我們傳送的訊息,這裡我們需要詳細看一下processMailbox()的實現:


@tailrec private final def processMailbox(
    left:       Int  = java.lang.Math.max(dispatcher.throughput, 1),
    deadlineNs: Long = if (dispatcher.isThroughputDeadlineTimeDefined == true) System.nanoTime + dispatcher.throughputDeadlineTime.toNanos else 0L): Unit =
    if (shouldProcessMessage) {
      val next = dequeue()  //去出下一條訊息
      if (next ne null) {
        if (Mailbox.debug) println(actor.self + " processing message " + next)
        actor invoke next
        if (Thread.interrupted())
          throw new InterruptedException("Interrupted while processing actor messages")
        processAllSystemMessages()
        if ((left > 1) && ((dispatcher.isThroughputDeadlineTimeDefined == false) || (System.nanoTime - deadlineNs) < 0))
          processMailbox(left - 1, deadlineNs) //遞迴處理下一條訊息
      }
    }複製程式碼

從上述程式碼中我們可以清晰的看到,當滿足訊息處理的情況下就會進行訊息處理,從訊息佇列列取出下一條訊息就是上面的dequeue(),然後將訊息發給具體的Actor進行處理,接下去又是處理系統訊息,然後判斷是否還有滿足情況需要下一條訊息,若有則再次進行處理,可以看成一個遞迴操作,@tailrec也說明了這一點,它表示的是讓編譯器進行尾遞迴優化。

現在我們來看一下一條訊息從傳送到最終處理在Akka中到底是怎麼執行的,下面的內容是我通過閱讀Akka原始碼加自身理解得出的,這裡先畫了一張流程圖:

Akka 系列(四):Akka 中的共享記憶體模型
Actor process

訊息的大致流程我都在圖中給出,還有一些細節,必須序列化訊息,獲取狀態等就沒有具體說明了,有興趣的同學可以自己去閱讀以下Akka的原始碼,個人覺得Akka的原始碼閱讀性還是很好的,比如:

  • 基本沒有方法超過20行
  • 不會有過多的註釋,但關鍵部分會給出,更能加深自己的理解

當然也有一些困擾,我們在不瞭解各個類,介面之間的關係時,閱讀體驗就會變得很糟糕,當然我用IDEA很快就解決了這個問題。

我們這裡來看看關鍵的部分:Actor是如何保證序列處理訊息的?

上圖中有一根判定,是否已有執行緒在執行任務?我們來看看這個判定的具體邏輯:

@tailrec
  final def setAsScheduled(): Boolean = {  //是否有執行緒正在排程執行該MailBox的任務
    val s = currentStatus
    /*
     * Only try to add Scheduled bit if pure Open/Suspended, not Closed or with
     * Scheduled bit already set.
     */
    if ((s & shouldScheduleMask) != Open) false
    else updateStatus(s, s | Scheduled) || setAsScheduled()
  }複製程式碼

從註釋和程式碼的邏輯上我們可以看出當已有執行緒在執行返回false,若沒有則去更改狀態為以排程,直到被其他執行緒搶佔或者更改成功,其中updateStatus()是執行緒安全的,我們可以看一下它的實現,是一個CAS操作:

@inline
  protected final def updateStatus(oldStatus: Status, newStatus: Status): Boolean =
    Unsafe.instance.compareAndSwapInt(this, AbstractMailbox.mailboxStatusOffset, oldStatus, newStatus)複製程式碼

到這裡我們應該可以大致清楚Actor內部是如何保證共享記憶體的一致性了,Actor接收訊息是多執行緒的,但處理訊息是單執行緒的,利用MailBox中的Status來保障這一機制。

總結

通過上面的內容我們可以總結出以下幾點:

  • Akka並不是說用了什麼特殊魔法來保證併發的,底層使用的還是Java和JVM的同步機制
  • Akka並沒有使用任何的鎖機制,這就避免了死鎖的可能性
  • Akka併發執行的處理並沒有使用執行緒切換,不僅提高了執行緒的使用效率,也大大減少了執行緒切換消耗
  • Akka為我們提供了更高層次的併發抽象模型,讓我們不必關心底層的實現,只需著重實現業務邏輯就行,遵循它的規範,讓框架幫我們處理一切難點吧

相關文章