Actor model 的理解與 protoactor-go 的分析

機智的小小帥發表於2022-03-13

Overview

Definition

From wikipedia

The actor model in computer science is a mathematical model of concurrent computation that treats actor as the universal primitive of concurrent computation. In response to a message it receives, an actor can: make local decisions, create more actors, send more messages, and determine how to respond to the next message received.

將 actor 是併發計算中的一個基本原語,actor 接收訊息並對收到的訊息做出響應。

下面說說我個人對 actor 的理解吧,不一定對,有不對的地方歡迎指正!

為什麼 Actor model 可以解決併發問題?

首先想想併發問題是如何產生的,對同一個資料進行同時寫(或者同時讀寫)會產生併發問題。所以主需要破壞“同時”這個條件,併發問題就迎刃而解了。

常用的互斥鎖就是這是這種思路,它保證同一時刻只有一個執行緒可以訪問到資料,在鎖的保護下,多個執行緒對資料的訪問變成有序的了。

senders 給 actor 傳送訊息時,actor 將訊息放在了它的郵箱 (Mail) 中,並從郵箱中一條條取出訊息來處理。同一時刻,不會有兩條訊息被處理,這次訊息的處理順序是有序的,因此自然不會有併發問題。

Actor model 與 CSP

兩者的概念極為相似,都符合生產者消費者模型。

在 CSP 裡面,Producer 往 channel 裡投遞訊息,comsumer 從 channel 裡取出訊息來處理。

在 Actor model 中,sender 往 actor 的 mail 裡投遞資訊,actor 從 mail 裡取出訊息來處理。

但是兩者的關注點不同,CSP 裡面生產者關注的是訊息佇列本身,生產者只負責往訊息佇列裡面投遞訊息,至於這些訊息被誰消費(甚至至可能被多個消費者一起消費),它不 care。

但是 Actor model 中,生產者關注的是特定的消費者,生產者往特定的消費者投遞訊息,訊息只能被之前所指定的消費者消費。

message queue 的缺點

利用 message queue 可以有效的解決併發問題,但是它也有一個很明顯的缺點,那就是呼叫方沒法及時得到處理的結果。

舉個具體的例子:客戶端發起一個請求,後端收到請求後,生成了一個對應的訊息並放到了訊息佇列上,有一個消費者不停地從訊息佇列中取出訊息並進行有序消費。但是訊息者處理完訊息後是無法將處理的結果告訴生產者的。

這個問題一般有兩種解決方法:

  1. 生產者不停對消費者進行輪詢,詢問訊息的結果。
  2. 消費者消費完訊息後,通知生產者。

這兩種方式都會增加系統的複雜度。

由於 Actor model 在我看來也是基於消費佇列的,所以我很好奇它是如何做到將訊息的處理結果實時地告訴 senders 的?

所以找到了 protoactor-go 這個庫來學習學習

protoactor-go 的分析

在 _example 目錄下面有很多關於 protoactor-go 的例子。我們明確目標,直接找到 requestreponse 這個目錄,這裡面的例子將是就是往 actor 物件傳送一個訊息,並得到訊息的處理結果。

首先說一下這個庫裡面的一些抽象

  • Actor: 一個處理訊息的物件

    type Actor interface {
    	Receive(c Context)
    }
    
  • ActorSystem: 多個 Actor 物件在一個 ActorSystem 內,ActorSystem 與 Actor 是一對多的關係

  • PID: 用來標識一個 Actor 物件

    type PID struct {
    	Address string `protobuf:"bytes,1,opt,name=Address,proto3" json:"Address,omitempty"`
    	Id      string `protobuf:"bytes,2,opt,name=Id,proto3" json:"Id,omitempty"`
    
    	p *Process
    }
    

requestreponse/main.go 裡面的程式碼很簡單

type Hello struct{ Who string }

func Receive(context actor.Context) {
	switch msg := context.Message().(type) {
	case Hello:
		context.Respond("Hello " + msg.Who)
	}
}

func main() {
	system := actor.NewActorSystem()
	rootContext := system.Root

	props := actor.PropsFromFunc(Receive)
	pid := rootContext.Spawn(props)

	future := rootContext.RequestFuture(pid, Hello{Who: "Roger"}, -1)
	result, _ := future.Result() // await result
  
	fmt.Println(result)
	_, _ = console.ReadLine()
}
  • line 11 - 12

    	system := actor.NewActorSystem()
    	rootContext := system.Root
    

    新建一個 actor system

  • line 14 - 15

    	props := actor.PropsFromFunc(Receive)
    	pid := rootContext.Spawn(props)
    

    在 actor system 內構造了一個 actor,用 PID 來標識這個 actor,該 actor 收到訊息後的處理邏輯是 Receive()

  • line 17 - 18

    	future := rootContext.RequestFuture(pid, Hello{Who: "Roger"}, -1)
    	result, _ := future.Result() // await result
    

    對之前新建的 actor 傳送一個訊息,得到一個 future 物件,並利用 future.Result() 方法取到結果。

呼叫 future.Result() 是訊息壓根還沒有被處理完咋辦?

先看看 Result() 方法的實現

// Result waits for the future to resolve
func (f *Future) Result() (interface{}, error) {
	f.wait()
	return f.result, f.err
}

func (f *Future) wait() {
	f.cond.L.Lock()
	for !f.done {
		f.cond.Wait()
	}
	f.cond.L.Unlock()
}

可以看到它裡面用到 sync.Cond,知道 f.done 變成 true,它才會返回。而只有訊息處理完畢後,f.done 才會變成 true。

所以 future.Result() 將會一直阻塞直到訊息被處理完。

actor 怎樣將訊息的處理結果返回到對應的 sender?

其實 actor 並不會把直接把結果送回給 sender,而是將結果儲存在了 Future 物件內,sender 通過呼叫 Future.Result() 方法取到訊息的處理結果。

自 sender 傳送訊息起,訊息時如何流轉的?
RequestFuture()

首先進入到 RequestFuture 方法內

// RequestFuture sends a message to a given PID and returns a Future
func (rc *RootContext) RequestFuture(pid *PID, message interface{}, timeout time.Duration) *Future {
	future := NewFuture(rc.actorSystem, timeout)
	env := &MessageEnvelope{
		Header:  nil,
		Message: message,
		Sender:  future.PID(),
	}
	rc.sendUserMessage(pid, env)
	return future
}
  • line 3

    構建了一個 future 物件 (先不用管 timeout)

  • line 4 - 8

    構建了一個 MessageEnvelope (信封)物件,注意這裡信封的 sender (發件人) 是 future.PID,也就是剛剛構建的 future 物件。

    由於 MessageEnvelope 裡面記錄了 sender 資訊,所以 actor 在處理完訊息後自然可以將結果傳送給 sender

  • line 9

    將信封物件傳送給了目標 actor

  • line 10

    返回 future 物件,呼叫者呼叫 future.Result() 將會陷入阻塞

func (rc *RootContext) sendUserMessage(pid *PID, message interface{}) {
	if rc.senderMiddleware != nil {
		// Request based middleware
		rc.senderMiddleware(rc, pid, WrapEnvelope(message))
	} else {
		// tell based middleware
		pid.sendUserMessage(rc.actorSystem, message)
	}
}

由於我們並沒有為 RootContext 設定 senderMiddleware。所以將會直接呼叫

		pid.sendUserMessage(rc.actorSystem, message)

其中 pid 是我們的 target actor。

// sendUserMessage sends a messages asynchronously to the PID
func (pid *PID) sendUserMessage(actorSystem *ActorSystem, message interface{}) {
	pid.ref(actorSystem).SendUserMessage(pid, message)
}

pid.ref() 將會從 ActorSystem 裡面取出 pid 所對應的 Actor Process

func (pid *PID) ref(actorSystem *ActorSystem) Process {
  // ...
  // 這裡面用了兩個原子操作,還蠻巧妙的
}

Process 是一個介面

// A Process is an interface that defines the base contract for interaction of actors
type Process interface {
	SendUserMessage(pid *PID, message interface{})
	SendSystemMessage(pid *PID, message interface{})
	Stop(pid *PID)
}

ActorProcess 和 futureProcess 都實現了 Process 這個介面,在此處,將會進入 ActorProcess 的實現

func (ref *ActorProcess) SendUserMessage(pid *PID, message interface{}) {
	ref.mailbox.PostUserMessage(message)
}
func (m *defaultMailbox) PostUserMessage(message interface{}) {
	for _, ms := range m.mailboxStats {
		ms.MessagePosted(message)
	}
	m.userMailbox.Push(message)
	atomic.AddInt32(&m.userMessages, 1)
	m.schedule()
}
  • line 2 - 4

    先忽略

  • line 5 - 6

    往 userMailbox 內推入一條訊息,並將 userMessages + 1

  • line 7

    呼叫 schedule()

func (m *defaultMailbox) schedule() {
	if atomic.CompareAndSwapInt32(&m.schedulerStatus, idle, running) {
		m.dispatcher.Schedule(m.processMessages)
	}
}

func (goroutineDispatcher) Schedule(fn func()) {
	go fn()
}

這裡進行了一個 CAS 操作,首先判斷是否是空閒狀態,如果是 idle (空閒狀態) 的話,那就呼叫 Schedule() 方法起一個協程去處理訊息。

協程中執行的就是 processMessages() 方法了

func (m *defaultMailbox) processMessages() {
process:
	m.run()

	// ...
}

func (m *defaultMailbox) run() {
	var msg interface{}

	// ...
	for {
		if msg = m.userMailbox.Pop(); msg != nil {
			atomic.AddInt32(&m.userMessages, -1)
			m.invoker.InvokeUserMessage(msg)
			// ...
		} else {
			return
		}
	}
}

在 run() 裡面會利用一個 for 迴圈不停從 userMailbox 中取訊息,並呼叫 InvokeUserMessage() 去處理訊息

func (ctx *actorContext) InvokeUserMessage(md interface{}) {
	// ...
	ctx.processMessage(md)
	// ...	
}

func (ctx *actorContext) processMessage(m interface{}) {
	// ...
	ctx.messageOrEnvelope = m
	ctx.defaultReceive()
	ctx.messageOrEnvelope = nil // release message
}
  • line 9

    將 m (需要處理的 message)放在了 actorContext.messageOrEnvelope 裡面

  • line 10

    呼叫 defaultReceive() 處理 message

  • line 11

    將 actorContext.messageOrEnvelope 置為 nil,代表此條訊息處理完了

有一個值得注意的地方是,在 processMessage() 裡面,對 messageOrEnvelope 的賦值是沒有使用 mutex 或者原子操作的。因此只有一個協程執行 processMessage() 這個方法,因此對 messageOrEnvelope 的訪問時安全的。

PS: 個人感覺在 Actor model 最主要的臨界區是從 actor 的 mailbox 記憶體入/取出訊息,一旦取出訊息,後面對訊息的處理就是一馬平川了

func (ctx *actorContext) defaultReceive() {
	// ...
	ctx.actor.Receive(Context(ctx))
}

func Receive(context actor.Context) {
	switch msg := context.Message().(type) {
	case Hello:
		context.Respond("Hello " + msg.Who)
	}
}

defaultReceive() 最終呼叫到了在 main.go 中宣告的 Receive() 方法。

Respond()

接下來主要看看

context.Respond("Hello " + msg.Who)

這裡面所做的工作

func (ctx *actorContext) Respond(response interface{}) {
	// ...
	ctx.Send(ctx.Sender(), response)
}

ctx.Sender() 用於從 message 中獲取 sender 資訊

func (ctx *actorContext) Sender() *PID {
	return UnwrapEnvelopeSender(ctx.messageOrEnvelope)
}

func UnwrapEnvelopeSender(message interface{}) *PID {
	if env, ok := message.(*MessageEnvelope); ok {
		return env.Sender
	}
	return nil
}

接下來的步驟就和之前差不多了,都是往指定的 PID 傳送訊息

func (ctx *actorContext) Send(pid *PID, message interface{}) {
	ctx.sendUserMessage(pid, message)
}

func (ctx *actorContext) sendUserMessage(pid *PID, message interface{}) {
  // ...
	pid.sendUserMessage(ctx.actorSystem, message)
}

func (pid *PID) sendUserMessage(actorSystem *ActorSystem, message interface{}) {
	pid.ref(actorSystem).SendUserMessage(pid, message)
}

唯一不同的是 pid.ref(actorSystem) 得到的將是一個 futureProcess ,之前得到的是 ActorProcess

func (ref *futureProcess) SendUserMessage(pid *PID, message interface{}) {
  // ...
	_, msg, _ := UnwrapEnvelope(message)
	// ...
  ref.result = msg
	ref.Stop(pid)
}

func UnwrapEnvelope(message interface{}) (ReadonlyMessageHeader, interface{}, *PID) {
	if env, ok := message.(*MessageEnvelope); ok {
		return env.Header, env.Message, env.Sender
	}
	return nil, message, nil
}

func (ref *futureProcess) Stop(pid *PID) {
	ref.cond.L.Lock()
	if ref.done {
		ref.cond.L.Unlock()
		return
	}

	ref.done = true
	// ...
	ref.cond.L.Unlock()
	ref.cond.Signal()
}

如上,futureProcess.SendUserMessage() 先 msg 存入 result 中,將 done 置為 true,並喚醒在 cond 上沉睡的協程 (也就是呼叫 ·future.Result() 的協程)

啟發

從 protoactor-go 中獲得的啟發
  1. Future 物件

    Future 這個抽象在非同步程式設計的世界裡面挺常見的,C++ 裡面也是有 Future 物件的。

    想要獲取非同步操作的結果時,可以先讓非同步操作返回一個 Future 物件,並 await Future 完成即可。

    仔細一想,go 裡面的 channel 不就是一個天然的 Future 物件?

  2. 將 Future 抽象為了一個 Actor Process

    這一個抽象我感覺挺巧妙的,將給 Future 賦值這個操作巧妙低轉換成了給 Future Actor 發訊息

看原始碼的啟發
  1. 帶著問題看原始碼,要想帶著問題,前提是你需要能夠提出問題,這就要求你原始碼的功能提前就得有一些瞭解
  2. 抓住主幹

其他疑惑 (TODO)

  1. protoactor-go 是怎樣處理垃圾訊息 (沒有 actor 可以處理的訊息) 的 (dead letter)
  2. protoactor-go 是怎樣處理跨程式呼叫的?
  3. protoactor-go 是如何處理 userMessage 和 sysMessage 的優先順序?

相關文章