[elixir! #0035][譯] 單進程, 仍是多進程? by Saša Jurić

原文git

是將全部東西放在一個進程裏, 仍是, 把咱們所需的 state 中的每一小塊各自放在單獨的進程中, 這是個問題. 在本文中, 我將討論使用和不使用進程. 我還會討論如何將複雜的狀態邏輯與其它關係分開, 例如時間行爲以及跨進程通訊.github

因爲這是一篇長文, 因此在開始以前, 我想先分享一下個人主要觀點:數據庫

  • 使用函數和模塊來分離思惟事物服務器

  • 使用進程來分離運行時事物網絡

  • 不要使用進程(或 Agents)來分離思惟事物併發

這裏的"思惟事物"是指所存在與咱們想法中的東西, 例如訂單, 訂單項, 產品等等. 若是這些概念變得更爲複雜, 就值得將它們分離到不一樣的模塊和函數中, 使得咱們的代碼的各個部分保持精簡和專一.框架

使用進程(例如 agents)來作這些, 是我經常會看到人們犯的錯誤. 這種手段事實上是拋棄了 Elixir 中函數式的部分, 試圖使用進程來模擬對象. 這種實現每每會低於普通 FP 的方法(甚至是 OO 語言中的等價物). 記住, 使用進程是有代價的(內存和消息傳遞). 所以, 應當只在有明確的益處能夠抵消其代價時才使用進程. 管理代碼, 並不在這些益處之中, 因此這不是一個使用進程的好理由.ide

進程是用於處理運行時事物的, 具備可在運行的系統中被觀察的屬性. 例如, 當你想避免某個事物的失敗影響到系統中的其它活動時, 你會須要多個進程. 或者, 你想要使用並行的潛力, 容許多個做業同時運行. 這能夠提高你的性能, 以及擴展的潛能. 還有一些不常見的使用進程的場景, 然而, 分離思惟事物不在此列.函數

一個例子

但咱們該如何管理複雜的 state 呢, 若是不借助 agents 和進程? 讓我來經過一個簡單的域模型來講明, 這是一個二十一點遊戲的簡單版本. 我將爲你展現的代碼here支持了二十一點遊戲中的一個牌局.性能

一個牌局是一個行動的序列, 每次行動屬於一個不一樣的玩家. 從第一個行動的玩家開始. 初始時玩家有兩張牌, 而後能夠行動: 再拿一張牌(一擊), 或者跳過. 若是玩家手上的點數大於21, 那麼就出局了. 不然, 該玩家還能夠繼續行動.

點數是指手中全部牌的值之和, 數字牌(2-10)的值就是它們自身, jack, queen 和 king 的值是 10. ace 的值能夠是 1 或 11.

玩家選擇跳過, 或者出局, 則輪到下一玩家. 當全部玩家都行動完畢, 獲勝者就是未出局的玩家中點數最高者.

爲了保持簡單, 我沒有引入莊家, 下注, 保證, 分拆, 多輪, 玩家加入和離開等概念.

進程邊界

因此, 咱們須要跟蹤不一樣 state 的變化: 牌堆, 每位玩家的行動, 牌局的狀態. 幼稚的策略是使用多個進程. 須要爲每一個玩家的提供一個進程, 牌堆一個進程, 以及驅動整個牌局的 "master" 進程. 我看到人們有時會使用相似的方法, 但我不認爲這是合適的. 由於這個遊戲自己是高度阻塞的. 事件是按順序一件一件發生的: 拿牌, 行動一次或屢次, 結束行動, 下一個玩家. 在一個牌局裏的任什麼時候候都只發生一個事件.

使用多進程來實現單一牌局是弊大於利的. 在多個進程中, 事件是併發的, 因此你必須付出額外的努力來阻塞每一個事件. 你還須要注意進程的終結和清理. 若是你結束了牌局進程, 你還須要結束全部相關的進程. 在出錯時也是同樣: 牌局或者牌堆進程中的異常, 會終結全部東西(由於狀態沒法修復了). 或許單個玩家的異常能夠被隔離, 所以提高了一點容錯性, 但我認爲這是過分關注容錯性了.

我看到, 使用多進程來管理單一牌局, 具備許多潛在的缺點而沒有太多益處. 然而, 多個牌局之間是相互獨立的. 它們有這各自的數據流和狀態, 相互不共享信息. 所以使用單個進程來管理多個牌局是不合適的. 這下降了容錯性(一個牌局裏的錯誤會致使全部崩潰), 並且可能下降性能(沒法利用多核), 或遇到瓶頸(長時間處理某個牌局會使得其它牌局癱瘓). 因此, 不假思索地, 咱們須要將不一樣的牌局放在不一樣的進程裏.

在演講時, 我經常提到, 在一個複雜系統中, 有着巨大的併發潛能, 因此咱們會使用不少進程. 但要從中獲益, 咱們須要在有意義的地方使用進程.

通過思考, 咱們很是肯定, 要使用單個進程來管理單個牌局. 當咱們引入了桌子的概念, 隨着時間推移, 玩家將會發生變化, 那必定會頗有趣.

函數式模型

那麼, 不借助多進程, 咱們該如何分離不一樣的事物呢? 固然時使用函數和模塊. 若是咱們能使用不一樣的函數來拆分邏輯, 給予它們合適的名字, 也許將它們放在合適的模塊中, 咱們就能很好地表現咱們的思想, 而不須要用 agents 來模擬對象.

讓我來向你展示個人方案, 先從最簡單的開始.

牌堆

我想實現的第一個概念是牌堆. 咱們須要一個52張牌的標準牌堆. 咱們須要能洗牌, 還要可以一張一張地拿牌.

這是一個有狀態的概念. 每當咱們取一張牌, 牌堆的 state 就改變了. 儘管如此, 咱們也可使用純函數來實現牌堆.

看代碼. 我決定使用牌的列表來表示牌堆, 每張牌是一個有着面值和花色的map. 我能夠在編譯時生成全部牌:

@cards (
  for suit <- [:spades, :hearts, :diamonds, :clubs],
      rank <- [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, :ace],
    do: %{suit: suit, rank: rank}
)

如今, 咱們能夠添加 shuffle/0 函數來初始化一個洗過的牌堆:

def shuffled(), do:
  Enum.shuffle(@cards)

最後, take/1函數, 從牌堆頂端拿一張牌:

def take([card | rest]), do:
  {:ok, card, rest}
def take([]), do:
  {:error, :empty}

take/1 函數返回 {:ok, card_taken, rest_of_the_deck} 或者是 {:error, :empty}. 這使得客戶端(調用牌堆的用戶)能夠準確地處理每種狀況.

咱們能夠這樣使用它:

deck = Blackjack.Deck.shuffled()

case Blackjack.Deck.take(deck) do
  {:ok, card, transformed_deck} ->
    # do something with the card and the transform deck
  {:error, :empty} ->
    # deck is empty -> do something else
end

這是一個我使用 "函數式抽象" 的例子, 它是在描述這樣一個東西:

  • 一些列相關的函數,

  • 描述性的命名,

  • 沒有side-effects,

  • 能夠被放在單獨的模塊裏

對我來講這就是OO裏的類和對象. 在OO語言中, 我可能須要一個Deck 類和相應的方法, 在這裏我須要一個Deck 模塊的相應的函數. 函數的優勢(雖然並不老是值得的)是隻轉換數據, 而不處理時間邏輯或反作用(跨進程消息傳遞, 數據庫, 網絡請求, 超時, ...).

這些函數是否在一個專用的模塊中並不重要. 這個抽象的代碼很是簡單, 只佔用了一小塊地方. 所以, 我也能夠在客戶端模塊中定義私有的 shuffled_deck/0take_card/1 函數. 當代碼足夠小時我經常會這樣作. 若是事情變得更復雜了, 我會將他們抽離出來.

最重要的一點是牌堆的概念是由純函數構成的. 不須要使用一個 agent 來管理一堆牌.

完整的模塊代碼在這裏.

手牌

一樣的技術能夠被用於管理手牌. 這個抽象會跟蹤手牌的變化, 它會知道如何計算分數, 並判斷狀態(:ok:busted). 這個實現放在 Blackjack.Hand 模塊中.

這個模塊有兩個函數. 咱們使用 new/0 來初始化, 而後使用 deal/2 發一張牌到手中. 這裏是一個結合了手牌和牌堆的例子:

# create a deck
deck = Blackjack.Deck.shuffled()

# create a hand
hand = Blackjack.Hand.new()

# draw one card from the deck
{:ok, card, deck} = Blackjack.Deck.take(deck)

# give the card to the hand
result = Blackjack.Hand.deal(hand, card)

deal/2 函數的結果會是 {hand_status, transformed_hand} , 這裏 hand_status 多是 :ok:busted .

牌局

這個抽象由 Blackjack.Round 模塊支持, 它將全部東西聯繫在了一塊兒. 它有以下職責:

  • 保存牌堆的 state

  • 保存牌局內全部手牌的 state

  • 決定誰是下一個行動的玩家

  • 接收並執行玩家的行動(發牌/跳過)

  • 從牌堆拿牌併發到手牌中

  • 計算出獲勝者, 當全部玩家行動完畢

牌局抽象也使用和牌堆和手牌同樣的函數式方法來實現. 然而, 牌局須要引入一些別的時間邏輯. 例如與玩家的互動, 當回合開始時, 首先要給第一個玩家發出兩張牌, 而後告知其進行行動. 等到該玩家行動後, 牌局才能夠繼續.

讓我驚訝的是, 許多人(包括經驗豐富的erlang/elixir使用者), 會直接在一個 GenServer 或 :gen_statem 中實現牌局的概念. 這使得他們可以同時管理牌局狀態與時間邏輯(例如與玩家的互動).

然而, 我認爲這兩個方面須要分開, 由於他們都有潛在的複雜度. 邏輯方面, 咱們只涉及了單輪, 若是咱們向加入遊戲的其它內容, 例如投注, 分拆或莊家, 那麼狀況只會變得更復雜. 交流方面, 咱們也會須要處理網絡緩慢, 崩潰, 無響應等狀況, 可能會添加劇連, 或一些持久性的功能.

我不想將這兩個複雜的問題結合在一塊兒, 由於它們會變得糾纏不清, 並且處理代碼將變得很是困難. 我想將時間事務移動到其它地方, 只留下一個純淨的二十一點規則模型.

因此我選擇了一種不常見的方法. 我在一個簡單的函數式抽象中實現了一個牌局的概念.

讓我來展現一些代碼. 我須要調用 start/1 來初始化一輪新的牌局:

{instructions, round} = Blackjack.Round.start([:player_1, :player_2])

須要傳入的參數是玩家id 的列表. 它們能夠是任意的元素, 將被用於多種目的:

  • 實例化每一個玩家

  • 跟蹤當前玩家

  • 向玩家發出通知

這個函數返回一個元組. 元組的第一個元素是一個指令列表. 在本例中, 它將是:

[
  {:notify_player, :player_1, {:deal_card, %{rank: 4, suit: :hearts}}},
  {:notify_player, :player_1, {:deal_card, %{rank: 8, suit: :diamonds}}},
  {:notify_player, :player_1, :move}
]

這些指令是在通知客戶端應該執行的操做. 牌局一開始, 首先給一位玩家發兩張牌, 而後告知其開始行動. 因此在這個例子中, 咱們獲得以下指令:

  • 通知 player_1 獲得紅桃4

  • 通知 player_1 獲得方片8

  • 通知 player_1 開始行動

客戶端代碼有責任將這些通知提供給相關玩家. 客戶端代碼能夠說是一個 GenServer, 它將向玩家進程發送消息. 它還將等待玩家進行操做. 這類時間事務將徹底與牌局模塊隔離開.

返回的元組中的第二個元素是牌局state. 須要注意的是, 這個數據是不透明的. 這意味着客戶端不該該讀取 round 變量中的數據. 客戶端須要的一切都由指令列表提供.

咱們讓 player_1 再拿一張牌:

{instructions, round} = Blackjack.Round.move(round, :player_1, :hit)

我須要傳入玩家 id, 這樣牌局抽象才能夠驗證是不是可行動的玩家在行動. 若是我傳入的是錯誤的id, 抽象會給出指示, 通知玩家沒有輪到其操做.

這裏是我獲得的指令:

[
  {:notify_player, :player_1, {:deal_card, %{rank: 10, suit: :spades}}},
  {:notify_player, :player_1, :busted},
  {:notify_player, :player_2, {:deal_card, %{rank: :ace, suit: :spades}}},
  {:notify_player, :player_2, {:deal_card, %{rank: :jack, suit: :spades}}},
  {:notify_player, :player_2, :move}
]

這給列表告訴咱們: player_1 獲得黑桃10. 因爲他以前已有紅桃4 和 方片8, 因此他出局了, 牌局馬上輪到下一個玩家行動. 客戶端被指示通知 player_2 獲得了兩張牌, 並開始行動.

讓咱們做爲 player_2 進行行動:

{instructions, round} = Blackjack.Round.move(round, :player_2, :stand)

# instructions:
[
  {:notify_player, :player_1, {:winners, [:player_2]}}
  {:notify_player, :player_2, {:winners, [:player_2]}}
]

玩家2 選擇跳過, 這樣回合就結束了. 牌局抽象馬上算出了贏家, 並指示咱們通知兩位玩家結果.

讓咱們來看看 Round 模塊是如何良好地創建在 DeckHand 抽象之上的. 下列 Round 模塊中的函數會從牌堆中拿一張牌, 而後給到當前玩家:

defp deal(round) do
  {:ok, card, deck} =
    with {:error, :empty} <- Blackjack.Deck.take(round.deck), do:
      Blackjack.Deck.take(Blackjack.Deck.shuffled())

  {hand_status, hand} = Hand.deal(round.current_hand, card)

  round =
    %Round{round | deck: deck, current_hand: hand}
    |> notify_player(round.current_player_id, {:deal_card, card})

  {hand_status, round}
end

咱們從牌堆中拿一張牌, 若是當前牌堆已用完, 則使用一個新的牌堆. 而後咱們將牌傳給當前玩家, 更新牌局中的玩家和牌堆狀態, 在指令列表中添加關於新牌的指令, 並返回玩家狀態(:ok:busted) 以及新的牌局 state. 沒有引入額外的進程:-)

notify_player 是一個簡單的機制, 它大大下降了本模塊的複雜度. 沒有它, 咱們就須要向其它進程發送一個消息(另外一個GenServer, 或是 Phoenix Channel). 咱們必須以某種方式找到那個進程, 並考慮那個進程沒有運行的狀況. 許多額外的複雜度會混合到牌局的流程中.

多虧了指令機制, Round 模塊得以專一與遊戲的規則. notify_player 函數會保存指令列表. Round 模塊中的函數在返回前會拉取全部積累的指令, 而後依次返回他們, 強制客戶端執行這些指令.

此外, 這些代碼能夠由不一樣的驅動(客戶端)來運行. 在上述例子中, 我在會話中手動操做它. 另外一個例子是 在測試中驅動這些代碼. 這個抽象如今能夠很容易進行測試, 而沒必要擔憂反作用.

進程管理

純模型完成以後, 如今咱們該將注意轉移到進程方面. 如咱們以前提到的, 我會將每一個牌局放在獨立的進程中, 由於牌局之間不交換任何信息. 所以, 將它們分開運行能夠增長效率, 擴展性和容錯性.

牌局服務器

一個牌局由 Blackjack.RoundServer 模塊來管理, 它是一個 GenServer. Agent 也能夠知足需求, 但我不是很熱衷於使用 agnets. 因此我將使用GenServer. 你的喜愛也許不一樣, 固然, 我徹底尊重你的選擇:-)

爲了啓動進程, 咱們須要調用 start_playing/2 函數. 咱們選擇使用它替代經常使用的 start_link 函數, 由於 start_link 會鏈接到調用者進程. 相反, start_playing 會在監控樹以外啓動牌局, 其進程不會鏈接到調用者.

該函數須要兩個參數: 牌局id, 和玩家列表. 牌局id 是一個惟一的元素, 須要由客戶端來選擇. 服務器進程將使用這個 id 在內置的 Registry 中註冊.

玩家列表中的每一個元素都是一個描述客戶端玩家的 map:

@type player :: %{id: Round.player_id, callback_mod: module, callback_arg: any}

一個玩家由他的 id, 回調模塊和回調參數來描述. id 將被傳遞給牌局抽象. 當抽象指示服務器通知某個玩家, 則服務器會調用 callback_mod.some_function(some_arguments), some_arguments 包括牌局 id, 玩家 id, callback_arg, 通知參數等等.

callback_mod 使得咱們能夠支持不一樣的玩家類型, 例如:

  • 經過HTTP鏈接的玩家

  • 經過自定義的TCP協議鏈接的玩家

  • iex會話中的玩家

  • 自動(機器人)玩家

咱們能夠簡單地在同一個牌局中處理這些玩家. 服務器不用關係這些, 它只須要調用回調模塊中的回調函數, 而後讓實現來處理.

回調模塊中必須實現的函數以下:

@callback deal_card(RoundServer.callback_arg, Round.player_id,
  Blackjack.Deck.card) :: any
@callback move(RoundServer.callback_arg, Round.player_id) :: any
@callback busted(RoundServer.callback_arg, Round.player_id) :: any
@callback winners(RoundServer.callback_arg, Round.player_id, [Round.player_id])
  :: any
@callback unauthorized_move(RoundServer.callback_arg, Round.player_id) :: any

這種機制使得玩家沒法管理其在 server 進程中的狀態. 這樣作是有意的, 可以使玩家運行在牌局進程以外. 這有助於咱們保持牌局的獨立. 若是玩家崩潰或者斷開鏈接, 則牌局服務器仍然保持運行狀態, 而且能夠處理這些異常. 例如, 若是玩家沒有在給定的時間內行動, 則讓該玩家出局.

這種設計的另外一個優勢是方便測試. 能夠經過從每一個回調中向本身發送消息來實現對通知行爲的測試. 在測試中能夠調用 RoundServer.move/3 來模擬玩家的行動, 而後確認或否決特定的消息.

發送通知

當 server 進程接收到 Round 模塊返回的指令列表後, 它會遍歷並分發它們.

指令將會由獨立的進程來發送. 這是一個咱們能夠從併發中獲益的例子. 發送消息和管理牌局狀態是兩個獨立的任務. 通知玩家的邏輯可能會受到網絡鏈接緩慢或斷開的影響, 因此應當獨立於牌局進程. 此外, 向不一樣的玩家發送通知也應當使用額外的進程. 同時, 咱們須要保證每一個玩家受到的消息的順序, 因此咱們爲每一個玩家提供一個專門的通知進程.

這在 Blackjack.PlayerNotifier模塊中, 由一個負責向某個玩家發送消息的GenServer進程來實現. 當咱們調用 start_playing/2 函數來啓動牌局時, 同時啓動了一個小型監控樹, 它管理着這個牌局中屬於每一個玩家的通知進程.

每當牌局 server 行動一次, 就會從牌局抽象中獲得一個指令列表. 而後, server 將每一個指令轉發到相應的通知服務器, 該服務器將讀取指令並調用相應的 MFA 以通知玩家.

所以, 若是咱們須要通知多個玩家, 咱們會分開作(也許是並行的). 所以, 消息的總順序不會被保留. 思考如下指令序列:

[
  {:notify_player, :player_1, {:deal_card, %{rank: 10, suit: :spades}}},
  {:notify_player, :player_1, :busted},
  {:notify_player, :player_2, {:deal_card, %{rank: :ace, suit: :spades}}},
  {:notify_player, :player_2, {:deal_card, %{rank: :jack, suit: :spades}}},
  {:notify_player, :player_2, :move}
]

有可能出現這種狀況: 在player_1得知其出局的消息以前, player_2 先收到了消息. 但這是可接受的, 由於他們是兩個不一樣玩家. 每一個玩家的消息順序固然是原來的.

在開始下一部分以前, 我想再次指出: 因爲牌局模塊的設計和函數式特性, 消息通知部分的全部複雜度都被隔離在了規則模型以外, 一樣, 消息通知部分也不用關心規則邏輯.

21 點

至此, 咱們完成了 :blackjack 應用(Blackjack 模塊).啓動該應用時, 將啓動幾個本地註冊的進程: 一個內部註冊表實例(用於註冊牌局 和 通知服務器), 以及一個用於管理每一個牌局的子進程樹的 :simple_one_for_one 監控.

如今, 這個應用是一個能夠管理多個牌局的基本的 blackjack 服務. 該服務是通用的, 不依賴特定的接口. 你能夠將它和phoenix, cowboy, ranch(純TCP)等等任何符合你的意圖的東西結合使用. 你只需實現回調模塊, 啓動客戶端進程, 而後啓動牌局服務器.

你能夠在 Demo 模塊中看到一個例子, 它實現了 一個簡單的自動玩家, 一個 GenServer 驅動的通知回調, 以及一個 啓動五個玩家的開局邏輯 :

$ iex -S mix
iex(1)> Demo.run

player_1: 4 of spades
player_1: 3 of hearts
player_1: thinking ...
player_1: hit
player_1: 8 of spades
player_1: thinking ...
player_1: stand

player_2: 10 of diamonds
player_2: 3 of spades
player_2: thinking ...
player_2: hit
player_2: 3 of diamonds
player_2: thinking ...
player_2: hit
player_2: king of spades
player_2: busted

...

這是當有五個五人牌局時的監控樹:

圖片描述

總結

咱們能夠在一個進程中管理複雜的 state 嗎? 固然能夠! 簡單的函數抽象, 例如牌堆和手牌, 使得咱們能夠分離複雜的牌局中的事務, 而不須要存儲在agents 中.

這並不意味着咱們要保守地使用進程. 當使用進程能帶來一些明顯的益處時, 就用吧. 在獨立的進程中運行單個牌局, 能夠提升系統的可擴展性, 容錯性和總體性能. 一樣, 也適用於通知進程. 它們是不一樣的運行時事務, 因此不須要在相同的運行時上下文中運行.

若是時間, 規則邏輯很複雜, 請考慮分離它們. 我採用的方法容許我實現更多的運行時行爲(併發通知), 而不會致使業務流程複雜化. 這種分離也使得我能夠方便地對兩個方面進行擴展. 添加對 莊家, 分池, 定金和其它業務概念的支持, 不會對運行時方面形成顯著影響. 一樣, 對網絡, 重連, 玩家崩潰, 或者超時的支持, 不會須要規則邏輯進行修改.

最後, 值得記住的一點. 由於咱們計劃將這些代碼運行在某種 Web 服務器上, 因此有一些決定是爲了支持這種狀況. 尤爲是牌局 server 的實現, 它爲每一個玩家提供一個回調模塊, 容許咱們鏈接各類不一樣種類的客戶端. 這使得 blackjack 服務不受限於特定的庫和框架(固然, 標準庫和OTP除外), 而且是徹底靈活的.

Copyright 2017, Saša Jurić. This article is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.
The article was first published on The Erlangelist site
The source of the article can be found here.

相關文章
相關標籤/搜索