【问题标题】:Asynchronous code runs slower than synchronous version in haskell异步代码在 haskell 中运行速度比同步版本慢
【发布时间】:2026-01-26 01:00:02
【问题描述】:

对以下内容进行基准测试:

#!/usr/bin/env stack
-- stack --resolver lts-16.2 script --package async --package criterion

import           Control.Concurrent.Async (async, replicateConcurrently_)
import           Control.Monad            (replicateM_, void)
import           Criterion.Main

main :: IO ()
main = defaultMain [
    bgroup "tests" [ bench "sync" $ nfIO syncTest
                   , bench "async" $ nfIO asyncTest
                   ]
    ]

syncTest :: IO ()
syncTest = replicateM_ 100000 dummy

asyncTest :: IO ()
asyncTest = replicateConcurrently_ 100000 dummy

dummy :: IO Int
dummy = return $ fib 10000000000

fib :: Int -> Int
fib 0 = 1
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)

给我这个:

% ./applicative-v-monad.hs
benchmarking tests/sync
time                 2.120 ms   (2.075 ms .. 2.160 ms)
                     0.997 R²   (0.994 R² .. 0.999 R²)
mean                 2.040 ms   (2.023 ms .. 2.073 ms)
std dev              77.37 μs   (54.96 μs .. 122.8 μs)
variance introduced by outliers: 23% (moderately inflated)

benchmarking tests/async
time                 475.3 ms   (310.7 ms .. 642.8 ms)
                     0.984 R²   (0.943 R² .. 1.000 R²)
mean                 527.2 ms   (497.9 ms .. 570.9 ms)
std dev              41.30 ms   (4.833 ms .. 52.83 ms)
variance introduced by outliers: 21% (moderately inflated)

很明显 asyncTest 运行时间比 syncTest 长。

我原以为并发运行昂贵的操作会比按顺序运行更快。我的推理有问题吗?

【问题讨论】:

  • 没有时间写出正确的答案,但如果正在评估 fib 10000000000,它将需要 2^10000000000 步,而不是几毫秒。你的测试都没有强迫它。

标签: haskell concurrency benchmarking io-monad criterion


【解决方案1】:

这个基准有一些问题。

首先是懒惰

正如@David Fletcher 指出的那样,您并没有强制计算 fib。解决这个问题通常很简单:

dummy :: IO Int
dummy = return $! fib 10000000000

这足以让我们等待永恒。接下来我们应该做的是将其降低到更易于管理的水平:

dummy :: IO Int
dummy = return $! fib 35

这通常就足够了,但是 ghc 太聪明了,它会看到这个计算非常纯粹,并且会将 100000 次迭代的循环优化为单个计算并返回相同的结果 100000 次,所以实际上它会计算这个谎言只有一次。相反,让 fib 取决于迭代次数:

xs :: [Int]
xs = [1..35]

syncTest :: IO ()
syncTest = mapM_ dummy xs

asyncTest :: IO ()
asyncTest = mapConcurrently_ dummy xs

dummy :: Int -> IO Int
dummy n = return $! fib n

下一个问题是编译

stack script 将在没有线程环境的情况下运行经过迭代的代码。因此,您的代码将运行缓慢且按顺序运行。我们通过手动编译和一些标志来修复它:

$ stack exec --resolver lts-16.2 --package async --package criterion -- ghc -threaded -O2 -rtsopts -with-rtsopts=-N bench-async.hs
$ stack exec --resolver lts-16.2 -- ./bench-async

当然,对于一个完整的堆栈项目,所有这些标志都进入一个 cabal 文件,然后运行 ​​stack bench 将完成剩下的工作。

最后但并非最不重要。线程太多。

在问题中你有asyncTest = replicateConcurrently_ 100000 dummy。除非迭代次数非常少,否则您不想为此使用async,因为生成至少 100000 个线程不是免费的,最好为此类工作使用工作窃取调度程序加载。为此我专门写了一个库:scheduler

这是一个如何使用它的示例:

import qualified Control.Scheduler as S

main :: IO ()
main = defaultMain [
    bgroup "tests" [ bench "sync" $ whnfIO syncTest
                   , bench "async" $ nfIO asyncTest
                   , bench "scheduler" $ nfIO schedulerTest
                   ]
    ]
schedulerTest :: IO ()
schedulerTest = S.traverseConcurrently_ S.Par dummy xs

现在这将为我们提供更合理的数字:

benchmarking tests/sync
time                 246.7 ms   (210.6 ms .. 269.0 ms)
                     0.989 R²   (0.951 R² .. 1.000 R²)
mean                 266.4 ms   (256.4 ms .. 286.0 ms)
std dev              21.60 ms   (457.3 μs .. 26.92 ms)
variance introduced by outliers: 18% (moderately inflated)

benchmarking tests/async
time                 135.4 ms   (127.8 ms .. 147.9 ms)
                     0.992 R²   (0.980 R² .. 1.000 R²)
mean                 134.8 ms   (129.7 ms .. 138.0 ms)
std dev              6.578 ms   (3.605 ms .. 9.807 ms)
variance introduced by outliers: 11% (moderately inflated)

benchmarking tests/scheduler
time                 109.0 ms   (96.83 ms .. 120.3 ms)
                     0.989 R²   (0.956 R² .. 1.000 R²)
mean                 111.5 ms   (108.0 ms .. 120.2 ms)
std dev              7.574 ms   (2.496 ms .. 11.85 ms)
variance introduced by outliers: 12% (moderately inflated)

【讨论】:

  • 我会对此表示赞成,但删除你说已知最大的斐波那契只有 465 位的部分,因为那是错误的。如果你愿意,你可以做一个和你的硬盘一样大的。
  • @DavidFletcher 我删除了评论,因为它确实是错误的。我想我在脑海中混合了一些东西,然后不假思索地发表了评论。感谢您指出。
  • 您可以使用 35 + (n `rem` 2) 之类的方法来减小对计数的依赖。
  • @dfeuer 确定。但是,您知道,这纯粹是演示性的,这使得看到实际并行化的效果变得不那么重要了。