如何使用Applicative 和Alternative 树来模拟计算的组合(附代码)

92 阅读3分钟

当决定使用哪种语言来解决需要大量并发算法的挑战时,很难不考虑Haskell。它的不可变和持久的数据结构减少了意外复杂性的引入,而GHC运行时促进了数以千计的(绿色)线程的创建,而不必担心内存和性能成本的问题。

Haskell的并发API的缩影是async 包,它提供了高阶函数(如 race, mapConcurrently等),允许我们运行IO 子程序,并在并发执行时以各种方式组合其结果。它还提供了一个类型 Concurrently它允许开发者赋予普通子程序以并发的属性,还提供了ApplicativeAlternative 实例,帮助创建由较小的子程序组成的值。

在这篇博文中,我们将讨论使用 Concurrently类型在组成子程序时的一些缺点。然后我们将展示我们如何通过利用ApplicativeAlternative 类型的结构特性来克服这些缺点;重新塑造和优化子程序树的执行。

而且,如果你现在只是想在你的Haskell代码中获得这些性能上的优势,你可以直接开始使用我们在《Haskell》中引入的新的 Conc数据类型,我们已经在unliftio 0.2.9.0中引入了这个数据类型。

的缺点是Concurrently

开始使用Concurrently 是很容易的。我们可以用Concurrently 构造函数来包装一个IO a 子程序,然后我们可以用map (<$>)、apply (<*>)和alternative (<|>)操作符来组成异步值。一个例子可能是:

myPureFunction :: String -> String -> String -> String
myPureFunction a b c = a ++ " " ++ b ++ " " ++ c

myComputation :: Concurrently String
myComputation =
  myPureFunction
  <$> Concurrently fetchStringFromAPI1
  <*> (    Concurrently fetchStringFromAPI2_Region1
       <|> Concurrently fetchStringFromAPI2_Region2
       <|> Concurrently fetchStringFromAPI2_Region3
       <|> Concurrently fetchStringFromAPI2_Region4)
  <*> Concurrently fetchStringFromAPI3

让我们谈一谈这种方法的缺点。你认为我们需要多少个线程来确保所有这些调用同时执行?试着想出一个数字和一个解释,然后继续阅读。

我猜你希望这段代码能产生六(6)个线程,对吗?我们正在使用的每个IO 子程序都有一个。然而,在ApplicativeAlternative 的现有实现中,Concurrently ,我们将至少产生十(10)个线程。让我们探讨一下这些实例,以便更好地了解发生了什么:

instance Applicative Concurrently where
  pure = Concurrently . return
  Concurrently fs <*> Concurrently as =
    Concurrently $ (\(f, a) -> f a) <$> concurrently fs as

instance Alternative Concurrently where
  Concurrently as <|> Concurrently bs =
    Concurrently $ either id id <$> race as bs

首先,让我们扩展一下我们例子中的替代性调用:

    Concurrently fetchStringFromAPI2_Region1
<|> Concurrently fetchStringFromAPI2_Region2
<|> Concurrently fetchStringFromAPI2_Region3
<|> Concurrently fetchStringFromAPI2_Region4

--- is equivalent to
Concurrently (
  either id id <$>
    race {- 2 threads -}
      fetchStringFromAPI2_Region1
      (either id id <$>
         race {- 2 threads -}
           fetchStringFromAPI2_Region2
           (either id id <$>
              race {- 2 threads -}
                fetchStringFromAPI2_Region3
                fetchStringFromAPI2_Region4))
)

接下来,让我们扩展应用性调用:

    Concurrently (myPureFunction <$> fetchStringFromAPI1)
<*> Concurrently fetchStringFromAPI2
<*> Concurrently fetchStringFromAPI3

--- is equivalent to

Concurrently (
  (\(f, a) -> f a) <$>
    concurrently {- 2 threads -}
      ( (\(f, a) -> f a) <$>
         concurrently {- 2 threads -}
           (myPureFunction <$> fetchStringFromAPI1)
           fetchStringFromAPI2
      )
      fetchStringFromAPI3
)

你可以知道我们总是为每一对子程序生成两个线程。假设我们有7个子程序,我们想通过ApplicativeAlternative 进行组合。使用这种实现方式,我们将至少产生14个新线程,而最多只有8个线程可以完成这项工作。我们每做一次组合,就会产生一个额外的线程来处理记账问题。

另一个需要考虑的缺点是:如果调用中的一个值是一个pure ,会发生什么?鉴于这段代码:

pure foo <|> bar

我们得到了催生一个新的线程(不必要的)来等待foo ,尽管它已经被计算出来了,而且它应该总是赢。正如我们之前提到的,Haskell是并发性的最佳选择,因为它使线程的生成变得很便宜;然而,这些线程并不是免费的,我们应该努力避免多余的线程创建。

引入Conc 类型

为了解决上面提到的问题,我们在unliftio 包中实现了一个新的类型,叫做Conc 。它的目的与Concurrently 相同,但它提供了一些额外的保证:

  • 所有的ApplicativeAlternative 组合都将只有一个记账线程。
  • 如果我们在ApplicativeAlternative 组合中调用pure ,我们将不会产生一个新的线程。
  • 我们将对琐碎的情况进行代码优化。例如,在评估一个单一的Conc 值(而不是Conc 值的组合)时,不产生一个线程。
  • 我们可以对超过IO 的子程序进行组合。任何实现MonadUnliftIO 的单体类型都可以接受。
  • 子线程总是以未屏蔽的状态启动,而不是父线程的继承状态。

Conc 类型的定义如下:

data Conc m a where
  Action :: m a -> Conc m a
  Apply  :: Conc m (v -> a) -> Conc m v -> Conc m a
  LiftA2 :: (x -> y -> a) -> Conc m x -> Conc m y -> Conc m a
  Pure   :: a -> Conc m a
  Alt    :: Conc m a -> Conc m a -> Conc m a
  Empty  :: Conc m a

instance MonadUnliftIO m => Applicative (Conc m) where
  pure   = Pure
  (<*>)  = Apply
  (*>)   = Then
  liftA2 = LiftA2

instance MonadUnliftIO m => Alternative (Conc m) where
  (<|>) = Alt

如果你熟悉Free 类型,这将看起来非常熟悉。我们将把我们的并发计算表示为数据,这样我们以后就可以按照我们认为合适的方式进行转换或评估。在这种情况下,我们的第一个例子看起来会像下面这样:

myComputation :: Conc String
myComputation =
  myPureFunction
  <$> conc fetchStringFromAPI1
  <*> (    conc fetchStringFromAPI2_Region1
       <|> conc fetchStringFromAPI2_Region2
       <|> conc fetchStringFromAPI2_Region3
       <|> conc fetchStringFromAPI2_Region4)

--- is equivalent to

Apply (myPureFunction <$> fetchStringFromAPI1)
      (Alt (Action fetchStringFromAPI2_Region1)
           (Alt (Action fetchStringFromAPI2_Region2)
                (Alt (Action fetchStringFromAPI2_Region3)
                     (Action fetchStringFromAPI2_Region4))))

你可能注意到我们保留了Concurrently 实现的树形结构。然而,考虑到我们正在处理一个纯数据结构,我们可以将我们的Conc 值修改为更容易评估的东西。事实上,由于有了Applicative 接口,我们不需要评估任何IO 子程序来做转换(神奇!)。

我们有额外的(内部)类型,可以将我们所有的替代物和应用性的值扁平化:

data Flat a
  = FlatApp !(FlatApp a)
  | FlatAlt !(FlatApp a) !(FlatApp a) ![FlatApp a]

data FlatApp a where
  FlatPure   :: a -> FlatApp a
  FlatAction :: IO a -> FlatApp a
  FlatApply  :: Flat (v -> a) -> Flat v -> FlatApp a
  FlatLiftA2 :: (x -> y -> a) -> Flat x -> Flat y -> FlatApp a

这些类型等同于我们的Conc 类型,但它们与Conc 有一些区别:

  • Flat 类型将通过Applicative 创建的Conc 值与通过 创建的值分开。Alternative
  • FlatAlt 构造函数将一个Alternative 树扁平化为一个列表(帮助我们一次性生成所有的树,并方便使用一个记账线程)。
    • 请注意,我们将其表示为一个 "至少两个 "列表,与semigroups 包中的非空列表的表示类似。
  • FlatFlatApp 记录在其单体上下文上不是多态的,因为它们直接依赖于IO 。我们可以通过MonadUnliftIO 约束将我们的Conc m a 类型中的m 参数转化为IO

我们的博客文章的第一个例子,当被扁平化后,看起来会像下面这样:

FlatApp
  (FlatApply
    (FlatApp (FlatAction (myPureFunction <$> fetchStringFromAPI1)))
    (FlatAlt (FlatAction fetchStringFromAPI2_Region1)
             (FlatAction fetchStringFromAPI2_Region2)
             [ FlatAction fetchStringFromAPI2_Region3             , FlatAction fetchStringFromAPI2_Regoin4 ]))

使用一个 flatten函数将一个Conc 的值转换成一个Flat 的值,我们就可以在以后以对我们的使用情况最有利的方式评估并发的子程序树。

性能

因此,鉴于Conc API减少了通过Alternative 创建的线程数量,我们的实现应该是最有效的,对吗?可悲的是,这并不全是桃色的。为了确保我们得到在Alternative 组合上完成的第一个线程的结果,我们利用了STM API。当我们想从多个并发的线程中收集数值时,这种方法非常有效。遗憾的是,STM单体在合成大量读数时不能很好地扩展,如果你要合成数以万计的Conc 值,这种方法就会被禁止。

考虑到这一限制,我们只在涉及到Alternative 函数时才使用STM ;否则,我们依靠MVars,通过Applicative 进行多线程结果合成。我们可以毫不费力地做到这一点,因为我们可以即时改变由Conc 创建的子程序树的评估器。

结论

我们展示了如何使用ApplicativeAlternative 树来模拟计算的组合,然后,利用这个API;我们把这个计算树转化为更易于并发执行的东西。我们还利用了这种子程序作为数据的方法,将评估器从MVar 改为STM 组合。