了解Flow -- elixir的并行计算库

“我们不缺少计算机,缺少的是聪明地使用计算机的方法。”

日常编程的时候,我有时候会不自觉的把计算机当成一个人,以对人说话的方式来给计算机布置任务。然而,计算机和人类的一个主要区别就是,它会一字不差地执行程序,遇到特殊情况时不会做变通。

比如我们想统计一个文件里的词频,最直观的方式就是:

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.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....

你可能感兴趣的:(elixir)