瞭解Flow -- elixir的平行計算庫

Ljzn發表於2022-05-10
“我們不缺少計算機,缺少的是聰明地使用計算機的方法。”

日常程式設計的時候,我有時候會不自覺的把計算機當成一個人,以對人說話的方式來給計算機佈置任務。然而,計算機和人類的一個主要區別就是,它會一字不差地執行程式,遇到特殊情況時不會做變通。

比如我們想統計一個檔案裡的詞頻,最直觀的方式就是:

File.stream!("path/to/some/file")
|> Enum.flat_map(&String.split(&1, " "))
|> Enum.reduce(%{}, fn word, acc ->
  Map.update(acc, word, 1, & &1 + 1)
end)
|> Enum.to_list()

第一行是使用 File.stream!/1 開啟檔案,它可以讓我們逐行讀取檔案,這一步不會把檔案內容讀取出來。第二行就不得了了,會把檔案的全部內容都讀取到記憶體中。在這裡如果檔案過大,有可能直接就撐爆記憶體了。

File.stream!("path/to/some/file")
|> Stream.flat_map(&String.split(&1, " "))
|> Enum.reduce(%{}, fn word, acc ->
  Map.update(acc, word, 1, & &1 + 1)
end)
|> Enum.to_list()

既然 Enum.flat_map/2 太過暴力,我們就用 Stream.flat_map/2 來替代它,這樣,在第二行依舊不會讀取任何檔案內容。到第三行的 Enum.reduce/3 這裡會開始逐行讀取檔案內容並且使用一個 hash map 來統計詞頻。這樣做基本不會出現記憶體爆炸的情況了。現在的處理器基本都是多核的,我們能不能把多核處理器利用起來呢?

方便起見,我們用下面這個列表表示檔案的每一行(儘管這樣就無法體現出處理大檔案的特點了,但我們只要知道程式不會一下子讀取全部內容到記憶體就行了)

data = [
  "rose are red",
  "violets are blue"
]

第一步,和 Stream 類似,我們生成一個 lazy 的 Flow 資料結構:

opts = [stages: 2, max_demand: 1]

flow = flow
  |> Flow.from_enumerable(opts)

%Flow{
  operations: [],
  options: [stages: 2, max_demand: 1],
  producers: {:enumerables, [["rose are red", "violets are blue"]]},
  window: %Flow.Window.Global{periodically: [], trigger: nil}
}

stages 可以理解為並行的核心數量,本質上是參與並行處理的gen_stage 程式數量。這裡我們設定為2,與雙核機器上的預設配置相同。

接下來的 flat_mapreduce 操作也很上面的非常類似。

flow = flow 
  |> Flow.flat_map(&String.split/1)
  |> Flow.reduce(fn -> %{} end, fn word, acc -> Map.update(acc, word, 1, &(&1 + 1)) end)

%Flow{
  operations: [
    {:reduce, #Function<45.65746770/0 in :erl_eval.expr/5>,
     #Function<43.65746770/2 in :erl_eval.expr/5>},
    {:mapper, :flat_map, [&String.split/1]}
  ],
  options: [stages: 2, max_demand: 1],
  producers: {:enumerables, [["rose are red", "violets are blue"]]},
  window: %Flow.Window.Global{periodically: [], trigger: nil}
}
flow |> Enum.to_list()

[{"are", 1}, {"blue", 1}, {"violets", 1}, {"are", 1}, {"red", 1}, {"rose", 1}]

通過呼叫立即執行類的函式,例如 Enum.to_list/1Flow 終於才開始實際執行。注意到結果裡的 {"are", 1} 出現了兩次,這是為什麼呢?

還記得我們設定的 stages: 2, max_demand: 1 選項嗎,這意味著參與處理任務的 stages 數量是 2,且每個 stage 每次最多處理 1 個事件(event)。這樣設定的結果就是 "rose are red" 和 "violets are blue" 分別交給了不同的 stage 來處理,最後的結果只會簡單地拼合在一起。而要完成最後的合併,會是一個只能單程式執行的操作,這是我們不願看到的。

有沒有辦法在分配事件的時候就避免這個問題呢?如果我們能夠把相同的事件都分配給同一個 stage,就能夠避免最後的合併問題了。使用 hash 來分配事件是極好的,Flow.partition 的作用就是如此。

flow = flow 
  |> Flow.flat_map(&String.split/1)
  |> Flow.partition(opts)
  |> Flow.reduce(fn -> %{} end, fn word, acc -> Map.update(acc, word, 1, &(&1 + 1)) end)

%Flow{
  operations: [
    {:reduce, #Function<45.65746770/0 in :erl_eval.expr/5>,
     #Function<43.65746770/2 in :erl_eval.expr/5>}
  ],
  options: [stages: 2, max_demand: 1],
  producers: {:flows,
   [
     %Flow{
       operations: [{:mapper, :flat_map, [&String.split/1]}],
       options: [stages: 2, max_demand: 1],
       producers: {:enumerables, [["rose are red", "violets are blue"]]},
       window: %Flow.Window.Global{periodically: [], trigger: nil}
     }
   ]},
  window: %Flow.Window.Global{periodically: [], trigger: nil}
}

能看到在 partition 之後原本的 Flow 被巢狀到了新的 Flow 內部,這也是我們需要再次傳入 opts 的原因。在內部的 Flow 執行完畢之後,外部的 Flow 才會接下去執行。這一次,單詞根據 hash 被分配到了不同的 stages。(我們在上面的 Flow 結構中看不到任何關於 hash 的資訊,因為這就是事件分配的預設方式)

flow |> Enum.to_list()

[{"blue", 1}, {"rose", 1}, {"violets", 1}, {"are", 2}, {"red", 1}]

成功地達到了預期,即不再需要額外計算來合併結果。

文中程式碼來自 https://hexdocs.pm/flow/Flow....

相關文章