在 Node,js 当中, 使用在业务逻辑里可能需要控制大量 IO 操作,
于是出现了 async when 这样专门控制异步操作的库,
以及出现了 Promise 规范, 看网上的介绍, Promise 和 Monad 有不小的关联
http://www.ituring.com.cn/article/50561
那么, 我想知道, Haskell 当中是怎样用 Monad 控制复杂的 IO 操作的?
比如这样一些例子:
- 读取多个文件, 合并显示结果
- 读取多个文件, 过滤掉空的文件, 返回结果
- 读取多个文件, 兼容部分操作报错
- 读取多个文件, 返回最先读取的两个
- 读取多个文件, 返回限定时间内完成的几个
- 读取多个文件, 再按文件当中固定固定语法读取相关的文件
实际上 Haskell 是把所有相关东西都扔到了
IO
里面,没有特别精细地控制(STM
是个特例)。Haskell 的线程模型是类似 Go 的用户态轻量级线程,通过
forkIO
创建一个新的执行流。对新线程来说,它本身是个IO ()
,对外面的父线程来说,这个forkIO
也仅仅是产生一个IO ThreadId
。于是诞生了一个问题,这两个线程之前是没有同步和通信的。于是 GHC 又提供了MVar
,TVar
等原语来同步线程、发送数据。其他的库基本都是基于这套
IO
monad 上的原语实现的。这套原语足够灵活也足够熟悉,所以大部分情况下写并发代码和在其他语言里写出的代码差不多,尤其是 Go。tl;dr: Concurrent Haskell 本身提供的是一个较低级的、只有
IO
的库。一个常见的 pattern 是我们
forkIO
出子任务后需要等待这个结果反馈给父进程。于是可以考虑封装这个 pattern:上面这段代码非常直观简单,不过实际工程要考虑异步异常等细节,这里暂且忽略。从用户的角度观察一下,不难发现
Async a
代表一个 “未来某个时刻可能获知的a
类型的值”。下面有两种角度可以去进一步研究
Async a
。Async a
构造出更复杂的计算。Async
的代数性质。这两种视角其实是一致的,这里介绍一下多数人不太熟悉的第二种。(所谓的代数,就是找到一些算符和一些等式,类似于 a(b+c)=ab+a*c 这样的。)
假设我们有两个
async
,可以做什么?理应有这些操作:我们发现这非常类似于
Bool
的and
和or
。受到它们的启发,我们可以定义在 Async 上定义半群了!如果引入immediate :: a -> Async a
和never :: Async a
,我们还可以把半群扩展成幺半群。这与加法、乘法完全一致,我们甚至还有分配律:不过,只有这些操作还不够,有很多操作是无法表达的。比如说,“顺序”:先取得 Async a 的 a,再用这个值计算后得到 Async b。
观察这个类型,我相信你已经看出来这就是 Monad 的 bind 操作了 :-) 你可能会问:Async 是不是还缺一个 return?其实我们已经在上面定义了
immediate
,它 exactly 就是 return。那么 Async 是不是 monad 呢?我们还需要验证 3 条 monad laws:andThen (immediate a) h === h a
andThen m immediate === m
andThen (andThen m g) h === andThen m (\x -> andThen (g x) h)
运用我们对 Async 的直觉,这三条 law 一定是成立的。注意,到现在为止我们还没写一行代码!如果上面这三条 monad laws 不成立才是咄咄怪事。
到此为止,我们推导出了涵义是 “表示未来某个时刻才能拿到的值” 的类型构造器
Async :: * -> *
一定是 Functor, Applicative, Monad。尽管上面的推导是基于所谓的 “有栈协程” 的,但如果可以想到 “Monad 是描述计算的”,就会意识到 “无栈协程” 也可以用这种方式描述,只要满足我们的大前提:“表示未来某个时刻才能拿到的值”。现在把上面的理论套到 JavaScript 上,
Promise
就是上面的Async
(确切地说应该是Async (Either Error a)
,并且a
是被擦没了),then
就是andThen
。和 Monad 的关系已然呼之欲出了。PS:我们还可以把 C#、Rust 等的 async 函数(事实上是无栈协程)放到同一个框架下考虑,如 Rust 的
Future<T>
完全与上面的Async
一致。不过它们使用 delimited continuation 的角度去看更方便。PSS:我们对
Async
到底何时执行没有进行任何约束。从用户角度来看,只能保证如果wait
返回,那么结果一定已经到手了。所以上面的解读与常见语言、框架在执行策略上的不同没有矛盾。有栈协程语言(如 Haskell)常常是在Async a
构造开始时就立刻进行计算,而无栈协程语言(如 Rust)可能需要 poll 一下才会开始计算。