Actor 模型介紹
在高併發環境中,爲了保證多個進程同時訪問一個對象時的數據安全,我們通常採用兩種策略,共享數據和消息傳遞,
使用共享數據方式的併發編程面臨的最大的一個問題就是數據條件競爭(data race)。處理各種鎖的問題是讓人十分頭痛的一件事,鎖限制了併發性, 調用者線程阻塞帶來的浪費,用的不好,還容易造成死鎖。
和共享數據方式相比,消息傳遞機制最大的優點就是不會產生數據競爭狀態(data race),除此之外,還有如下一些優點:
-
事件模型驅動–Actor之間的通信是異步的,即使Actor在發送消息後也無需阻塞或者等待就能夠處理其他事情
-
強隔離性–Actor中的方法不能由外部直接調用,所有的一切都通過消息傳遞進行的,從而避免了Actor之間的數據共享,想要觀察到另一個Actor的狀態變化只能通過消息傳遞進行詢問
-
位置透明–無論Actor地址是在本地還是在遠程機上對於代碼來說都是一樣的
-
輕量性–Actor是非常輕量的計算單機,單個Actor僅包含一個 actorId 和 channel 對象,只需少量內存就能達到高併發
本代碼Actor模型主要基於swoole協程的channel來實現,進程間通過協程版 unix domain socket 進行通信,當然Actor不僅僅侷限於單個節點上,也可以作爲分佈式集羣運行。
github地址: https://github.com/caohao-php/ycsocket (已經去掉Actor,改成了全協程方案)
本框架 Actor 模型借鑑自EasySwoole 框架的Actor模塊。 github網址: https://github.com/easy-swoole/actor ,做了些修改後融入本框架
基本原理
Actor模型=數據+行爲+消息
Actor模型內部的狀態由它自己維護即它內部數據只能由它自己修改(通過消息傳遞來進行狀態修改),Actor由狀態(state)、行爲(Behavior)和郵箱(mailBox)三部分組成
-
狀態(state):Actor中的狀態指的是Actor對象的變量信息,狀態由Actor自己管理,避免了併發環境下的鎖和內存原子性等問題
-
行爲(Behavior):行爲指定的是Actor中計算邏輯,通過Actor接收到消息來改變Actor的狀態
-
郵箱(mailBox):郵箱是Actor和Actor之間的通信橋樑,郵箱內部通過FIFO消息隊列來存儲發送方Actor消息,接受方Actor從郵箱隊列中獲取消息
Actor的基礎就是消息傳遞
git源碼剖析
我們框架中的示例代碼,是一個多人競技的遊戲服務器,代碼中有3個 Actor : RoomLogic 、 PkLogic 、 GameLogic,分別用於存儲所有房間的遊戲大廳、單個房間邏輯、房間內每個玩家的遊戲邏輯,還有一個非 Actor 類 AiLogic ,用於處理AI玩家邏輯,代碼都存在於 application/logic 目錄中。
Actor的註冊:
//Application.php
function register_actor() {
Actor::getInstance()->register(RoomLogic::class, 1);
Actor::getInstance()->register(PkLogic::class, 1);
Actor::getInstance()->register(GameLogic::class, 1);
}
每一個Actor對於其他的Actor來說都是封閉的,他們擁有自己的變量,依附於一個特殊的進程ActorProcess,不同進程的Actor通過協程版unix domain socket 通訊,訪問請求會被寫入Actor信箱(channel),也就是說Actor本身是進程安全的。
每個Actor擁有一個唯一的id,所有進程對Actor的訪問,都是通過該id來確定Actor的進程位置,然後發送消息來訪問Actor,所有Actor在使用之前都需要註冊,註冊主要是初始化Actor名稱、進程數、啓動回調函數、銷燬回調函數、定時任務等信息。
在註冊完成之後,我們將爲每個Actor都創建對應的依附進程。並將進程掛到 swoole 服務器下。
$ws = new swoole_server("0.0.0.0", 9508, SWOOLE_PROCESS, SWOOLE_SOCK_TCP | SWOOLE_SSL);
Actor::getInstance()->attachToServer($ws);
Actor的創建與信箱的監聽
Actor本質上是一個類,所有Actor都繼承自ActorBean,該父類保存每個Actor的唯一編號actorId,和一些操作這些Actor對象的方法,比如創建Actor對象的new靜態方法。
//ActorBean.php
class ActorBean {
protected $actorId;
public static function new(...$args);
public static function getBean(string $actorId);
public function exist();
public function bean();
function onDestroy(...$arg);
function getThis();
function setActorId($actorId);
function getActorId();
}
//RoomLogic.php 單例
class RoomLogic extends ActorBean {
private static $instance;
public function __construct() {
}
public static function getInstance() {
if (!isset(self::$instance)) {
global $roomActorId; //通過一個全局變量在共享內存中存儲 RoomLogic 的 ActorId
$actorIdArray = $roomActorId->get("RoomActorId");
if (empty($actorIdArray['id'])) {
self::$instance = RoomLogic::new();
$roomActorId->set("RoomActorId", ['id' => self::$instance->getActorId()]);
} else {
self::$instance = RoomLogic::getBean($actorIdArray['id']);
}
}
return self::$instance;
}
...
}
PkLogic::new方法會通過協程版 unix domain socket 發送新建請求到ActorProcess,該進程會通過工廠類(ActorFactory)創建真實的Actor對象,即RoomLogic、 PkLogic、 GameLogic對象,同時在 startUp 函數裏,創建一個信箱(channel),並創建一個協程來監聽信箱,一旦有請求,將會開啓一個協程處理消息,該消息必定會被順序依次處理,但是切記在處理邏輯中不要出現阻塞IO,否則效率會非常低下,
如果消息是銷燬Actor,工廠會刪除真實的Actor對象,並在ActorProcess進程裏銷燬該工廠。還有就是通過 call_user_func 調用真實Actor對象的成員函數。
class ActorFactory
{
public function __construct($actorClass, $actorId, $args) {
$this->realActor = new $actorClass(...$args);
$this->realActor->setActorId($actorId);
$this->actorId = $actorId;
}
function __startUp() {
$this->channel = new Channel(64);
$this->listen();
}
private function listen() {
go(function () {
while (!$this->hasDoExit) {
$array = $this->channel->pop();
go(function ()use($array) {
$this->handlerMsg($array);
});
}
});
}
private function handlerMsg(array $array) {
$msg = $array['msg'];
if ($msg == 'destroy') {
$reply = $this->exitHandler($array['arg']);
} else {
try {
$reply = call_user_func([$this->realActor, $array['func']], ...$array['arg']);
} catch (\Throwable $throwable) {
$reply = $throwable;
}
}
if ($array['reply']) {
$conn = $array['connection'];
$string = Protocol::pack(ActorFactory::pack($reply));
for ($written = 0; $written < strlen($string); $written += $fwrite) {
$fwrite = $conn->send(substr($string, $written));
if ($fwrite === false) {
break;
}
}
$conn->close();
}
}
}
Actor 行爲
PkLogic::new 方法返回的並不是真實的Actor對象,而是一個ActorClient,我們可以通過ActorClient來實現遠程順序調用真實Actor成員函數的目的,當然,這裏的遠程是指的跨進程,從業務進程到ActorProcess,如果擴展到分佈式集羣環境下,這裏可以是集羣中節點。
class RoomLogic extends ActorBean {
private $joiningRoom;
public function joinRoom($userid, ... ) {
$this->joiningRoom['pkLogic'] = PkLogic::new();
$this->joiningRoom['pkLogic']->joinUser($userid);
$this->joiningRoom['id'] = $this->joiningRoom['pkLogic']->getActorId();
}
}
class PkLogic extends ActorBean {
private $gameLogics = array();
public function __construct() {
}
public function joinUser($uid) {
$this->gameLogics[$uid] = GameLogic::new($this->actorId, $uid);
$this->gameLogics[$uid]->createGame();
return count($this->gameLogics);
}
}
上面創建通過 PkLogic::new 創建Actor對象後,調用joinUser方法,由於 PkLogic::new() 返回的是 ActorClient 對象,然後ActorClient並沒有 joinUser 方法,那麼他會調用 __call 魔術方法,該魔術方法會將請求通過 unixsocket 傳到 ActorProcess 進程,並在該進程被 push 到ActorFactory 的信箱(channel),ActorFactory 的監聽協程會從信箱 pop 數據,並實現真正的函數調用,並返回結果。
class ActorClient
{
private $tempDir;
private $actorName;
private $actorId;
private $actorProcessNum;
function __construct(ActorConfig $config, string $tempDir) {
$this->tempDir = $tempDir;
$this->actorName = $config->getActorName();
$this->actorProcessNum = $config->getActorProcessNum();
}
function new($timeout, $arg);
function exist(string $actorId, $timeout = 3.0);
function destroy(...$arg);
function __call($func, $args) {
$processIndex = self::actorIdToProcessIndex($this->actorId);
$command = new Command();
$command->setCommand('call');
$command->setArg([
'id' => $this->actorId,
'func'=> $func,
'arg'=> $args
]);
return UnixClient::sendAndRecv($command, 3.0, $this->generateSocketByProcessIndex($processIndex));
}
private function generateSocketByProcessIndex($processIndex):string {
return $this->tempDir."/ActorProcess.".SERVER_NAME.".{$this->actorName}.{$processIndex}.sock";
}
public static function actorIdToProcessIndex(string $actorId):int {
return intval(substr($actorId, 0, strpos($actorId, "0")));
}
}
Actor的銷燬
ActorClient有個 destroy 方法,用於銷燬Actor。
class ActorClient {
private $actorId;
function destroy(...$arg) {
$processIndex = self::actorIdToProcessIndex($this->actorId);
$command = new Command();
$command->setCommand('destroy');
$command->setArg([
'id' => $this->actorId,
'arg' => $arg
]);
return UnixClient::sendAndRecv($command, 3.0, $this->generateSocketByProcessIndex($processIndex));
}
}
class ActorFactory {
private function exitHandler($arg) {
$reply = null;
try {
//清理定時器
foreach ($this->tickList as $tickId) {
swoole_timer_clear($tickId);
}
$this->hasDoExit = true;
$this->channel->close();
$reply = $this->realActor->onDestroy(...$arg);
if ($reply === null) {
$reply = true;
}
} catch (\Throwable $throwable) {
$reply = $throwable;
}
return $reply;
}
}
Actor的銷燬也是將消息destroy消息發送到ActorProcess,然後由工廠類做一些清理工作,最後刪除真實的Actor對象,在delele之前,會調用真實Actor的onDestroy方法,這個函數在父類ActorBean是一個空函數,用戶可以重寫該函數以便加入自己的清理邏輯,例如下面的PkLogic在onDestroy方法裏面,將銷燬GameLogic對象來清理房間內所有玩家的遊戲數據。
class RoomLogic extends ActorBean {
private $playingRooms;
public function exitRoom($pkid) {
$this->playingRooms[$pkid]['pkLogic']->destroy();
unset($this->playingRooms[$pkid]);
}
}
class PkLogic extends ActorBean {
private $gameLogics = array();
function onDestroy() {
foreach($this->gameLogics as $gameLogics) {
$gameLogics->destroy();
}
}
}