“我们不缺少计算机,缺少的是聪明地使用计算机的方法。”
日常编程的时候,我有时候会不自觉的把计算机当成一个人,以对人说话的方式来给计算机布置任务。然而,计算机和人类的一个主要区别就是,它会一字不差地执行程序,遇到特殊情况时不会做变通。
比如我们想统计一个文件里的词频,最直观的方式就是:
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_map
和 reduce
操作也很上面的非常类似。
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/1
,Flow
终于才开始实际执行。注意到结果里的 {"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....