懒惰的力量

时间:2022-05-06
本文章向大家介绍懒惰的力量,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

(今天我在旧金山参加了Erlang factory 2015大会,增长了很多见识。参会的总结我过两天再写,很多思想需要时间沉淀。)

前段时间写了篇「永恒不变的魅力」,介绍了immutability,很多读者表示喜欢这样的文章。这篇文章继续走标题党路线,给大家奉上的不是鸡汤,而是正儿八经的技术文章,讲的是Lazy evaluation。

在大家熟悉的编程语言中,调用一个函数,系统会老老实实返回调用的结果。这非常正常且直观 —— 计算机不就该这么运作么?如果你恰巧是个c语言开发者,objdump一下生成的目标文件,可以看到所有的计算过程。

lazy evaluation,顾名思义,就是调用函数,函数并不进行运算,而是返回一个数据结构,这个数据结构说:「嗯,你要调用这个函数,我知道了,你该干嘛干嘛吧。」如果接下来程序需要使用这个函数的返回值,那么计算才真正开始。

听上去似乎没太多好处。别急,考虑一下这样的代码(以Elixir为例):

awesome_list
|> Enum.filter(data_wash_fun)
|> Enum.map(do_some_cool_stuff_fun)
|> Enum.reduce(reduce_fun)

这种pattern的代码在日常生活中会经常遇到:一个数据集,清洗一下,然后map/reduce。这代码效率不高,循环三遍,O(3n)。要想提高效率到O(n),可以写一个for循环,把wash/map/reduce的动作都塞进去。不过,这样的代码非常丑陋,不利于复用,也不利于日后扩展(不符合open/close principle)。这时候,立即计算的劣势就显现出来。如果我们把代码稍作修改:

awesome_list
|> Stream.filter(data_wash_fun)
|> Stream.map(do_some_cool_stuff_fun)
|> Enum.reduce(reduce_fun)

这里,你可以简单认为 StreamEnum 的延迟计算。我们看看调用 Stream.filter,继而调用 Stream.map 都发生了什么:

iex(4)> alist |> Stream.filter(&(&1 > 2))
#Stream<[enum: [1, 2, 3, 4],
 funs: [#Function<39.29647706/1 in Stream.filter/2>]]>
 iex(5)> alist |> Stream.filter(&(&1 > 2)) |> Stream.map(&(&1 / 2))
#Stream<[enum: [1, 2, 3, 4],
 funs: [#Function<39.29647706/1 in Stream.filter/2>,
  #Function<45.29647706/1 in Stream.map/2>]]>

没有任何循环,执行完这两句后,只是把整个计算的细节包装了一下。当上述代码运行到 Enum.reduce 时,才开始真正触发求值。而求值的过程是:从 [1, 2, 3, 4] 里取出 1,依次调用 funs 列表里的函数,得到的返回值,再送给 Enum.reduce 进行计算。

Lazy evaluation在代码干净漂亮的前提下,在这段代码下达到了我们优化的目标:只有一遍循环。

当然这只是其一个显而易见的好处:避免不必要的循环。从更广泛的意义上讲:Lazy evaluation能避免不必要的计算,提高效率。比如说在某些情况下,代码根本没有使用某次计算的返回值,这样就可以节省运算。

Lazy evaluation的另一个极大的好处是很容易并发。既然计算的细节被包裹起来,那么,计算本身还被限定在当前的上下文,或者当前的vCPU完成么?

显然不必。在Elixir里,我们可以这样做:

awesome_list
|> Stream.filter(data_wash_fun)
|> Stream.map(do_some_cool_stuff_fun)
|> Stream.async()  <--------
|> Enum.reduce(reduce_fun)

Stream.async() [1] 之前的 lazy evaluation 都可以放在另一个 process 处理了。每计算完一个值,就会返回给 reduce 处理,假设 reduce 是个耗时的操作,这样就不会block前面的运算。

这代码太美,和之前的描述方式几乎一样,但同步的动作就变成了异步。最爽的是,程序员不用纠结任何细节。如果相同的异步处理要自己实现,可能需要一页纸的代码。

再进一步:

awesome_list
|> Stream.Op1(...)
|> Stream.Op2(...)
|> Stream.async()           # rp1 (rendevous point)
|> Stream.Op3(...)
|> ...
|> Stream.async()           # rp2
|> ...
|> Stream.async()           # rp3
|> Enum.reduce(reduce_fun)  # rp4

看着这段代码,我想起了儿时读过的一个故事。说美国南北战争时期,枪支短缺,政府需要短期内生产几万支枪,可当时最好的毛瑟枪工匠一天只能造一支枪。政府的订单一直没人敢接,后来有个大胆的商人接了。他把枪械生产的过程拆解开,每个人只负责其中一个部件。有些简单的部件,普通人就能胜任,不需要毛瑟枪工匠。故事到这我想用不着继续讲了,大家都知道我想讲什么了。

这里,一条run to complete的流水线被分成了四条子流水线,一个数据现在要经过四道互不影响的工序,才最终处理完毕。嗯,如果某条流水线处理速度慢怎么办?继续上代码(假设运行效率:rp4 = 2倍rp3 = 4倍rp2 = 6倍rp1):

awesome_list
|> Stream.Op1(...)
|> Stream.Op2(...)
|> Stream.async(6)           # rp1
|> Stream.Op3(...)
|> ...
|> Stream.async(4)           # rp2
|> ...
|> Stream.async(2)           # rp3
|> Enum.reduce(reduce_fun)   # rp4

hmm…perfect。这里的数字是个比例,rp4如果是1个process的话,rp3就是2个,以此类推。

有时候我们希望每个process都是毛瑟枪工匠,或者,整个过程的每一步都可以细粒度控制并发,那么,可以使用这些方法:

Stream.farm
Stream.pmap
Stream.chunked_pmap

这就是lazy evaluation的威力,几乎同样的代码,却能够以你需要的方式漂亮地并发跑在多核场景下。

小结一下,lazy evaluation:

  1. 把计算和计算发生的时间decouple,避免了不要要的计算
  2. 把计算和计算发生的空间decouple,提供了并发的可能

回过头来我们再好好想想这里处理问题的思路,发现什么了么?indirection!Computer science最重要的思想:

Every problem could be solved by adding another layer of indrection.

(PS: 本文撰于今天坐Caltrain从SF回SV的路上。来美有近一个季度了,总共坐过两次Caltrain。两周前,同样从SF回SV,在Mountain View附近,一哥们卧轨自杀,我花了3小时,才辗转回到家;今天第二次做坐Caltrain,还是SF回SV,又有一哥们把车扔到了铁轨上,害的列车停了一个半小时,7点半发车,到家10点半。唉,Caltrain,我跟你什么冤什么仇啊)


1. 注:这个方法以及下述 Stream.farmStream.pmap 等还未在Elixir 1.0版本中提供,据Jose Valim说,大概会在Elixir 1.1中实现。