第二十四章:并发和多核编程
在撰写此书时,CPU 架构的景观正以几十年来最快的速度发生变化。
定义并发和并行¶
一个并发程序需要同时处理多个互不相关的任务。考虑一下游戏服务器的例子:典型做法是将数十个组件组合起来,其中的每一个都与外部有复杂交互。可能其中某个组件负责多个用户间聊天;另外一些负责处理玩家的输入,并且将更新后的状态返回给客户端;同时还有其他程序执行物理计算。
并发程序的正确运转并不需要多核,尽管多核可以提高执行效率和响应速度。
相比之下,一个并行程序仅解决一个单独的问题。假设一个金融模型尝试计算并预测下一分钟某支股票的价格波动。如果想在某个交易所列出的所有股票上执行这个模型,例如计算一下那些股票应该买入或卖出,我们希望在五百个核上可以比仅有一个核的时候跑得更快。这表明,并行程序通常不需要通过多核来保证正确性。
另一个有效区分并行和并发的点在于他们如何与外部世界交互。由定义,并发程序连续不断的处理网络协议和数据库之类的东西。典型的并行程序可能更专注:其接收流入的数据,咀嚼一会儿(间或有点 I/O),然后将需要返回的数据流吐出来。
许多传统编程语言进一步模糊了并发和并行之间已经难以辨认的边界,这些语言强制程序员使用相同的基础设施投监这两种程序。
本章将涉及在单个操作系统进程内进行并发和并行编程。
用线程进行并发编程¶
作为并发编程的基础,大多数语言提供了创建多个多线程的方法。 Haskell
也不例外,尽管使用 Haskell
进行线程编程看起来和其他语言有些不同。
In Haskell, a thread is an IO action that executes independently from other threads. To create a thread, we import the Control.Concurrent module and use the forkIO functionHaskell
中,线程是互相独立的 IO
动作。为创建线程,需要导入 Control.Concurrent
模块并使用其中的 forkIO
函数
ghci> :m +Control.Concurrent
ghci> :t forkIO
forkIO :: IO () -> IO ThreadId
ghci> :m +System.Directory
ghci> forkIO (writeFile "xyzzy" "seo craic nua!") >> doesFileExist "xyzzy"
True
新线程几乎立即开始执行,创建它的线程同时继续向下执行。新线程将在它的 IO
动作结束后停止执行。
线程的不确定性¶
GHC 的运行时组件并不按特定顺序执行多个线程。所以,上面的例子中,文件 xyzzy 的创建时间在初始线程检查其是否存在之前或之后都有可能。如果删除 xyzzy 并且再执行一次,我们可能得到完全相反的结果。
隐藏延迟¶
假设我们要将一个大文件压缩并写入磁盘,但是希望快速处理用户输入以使他们感觉程序是立即响应的。如果使用 forkIO
来开启一个单独的线程去写文件,这样就可以同时做这两件事。
-- file: ch24/Compressor.hs
import Control.Concurrent (forkIO)
import Control.Exception (handle)
import Control.Monad (forever)
import qualified Data.ByteString.Lazy as L
import System.Console.Readline (readline)
-- http://hackage.haskell.org/ 上的 zlib 包提供了压缩功能
import Codec.Compression.GZip (compress)
main = do
maybeLine <- readline "Enter a file to compress> "
case maybeLine of
Nothing -> return () -- 用户输入了 EOF
Just "" -> return () -- 不输入名字按 “想要退出” 处理
Just name -> do
handle
(print :: (SomeException->IO ()))
$ do
content <- L.readFile name
forkIO (compressFile name content)
return ()
main
where compressFile path = L.writeFile (path ++ ".gz") . compress
因为使用了惰性的 ByteString
I/O ,主线程中做仅仅是打开文件。真正读取文件内容发生在子线程中。
当用户输入的文件名并不存在时将发生异常, handle (print :: (SomeException-> IO ()))
是一个低成本的打印错误信息的方式。
线程间的简单通信¶
在两个线程之间共享信息最简单的方法是,让它们使用同一个变量。上面文件压缩的例子中, main
线程与子线程共享了文件名和文件内容。 Haskell
的数据默认是不可变的,所以这样共享不会有问题,两个线程都无法修改另一个线程中的文件名和文件内容。
线程经常需要和其他线程进行活跃的通信。例如, GHC
没有提供查看其他线程是否还在执行、执行完毕、或者崩溃的方法 [54] 。可是,其提供了同步变量类型, MVar
,我们可以通过它自己实现上述功能。
MVar
的行为类似一个单元素的箱子:其可以为满或空。将一些东西扔进箱子,使其填满,或者从中拿出一些东西,使其变空。
ghci> :t putMVar
putMVar :: MVar a -> a -> IO ()
ghci> :t takeMVar
takeMVar :: MVar a -> IO a
尝试将一个值放入非空的 MVar
,将会导致线程休眠直到其他线程从其中拿走一个值使其变空。类似的,如果尝试从一个空的 MVar
取出一个值,线程也将休眠,直到其他线程向其中放入一个值。
-- file: ch24/MVarExample.hs
import Control.Concurrent
communicate = do
m <- newEmptyMVar
forkIO $ do
v <- takeMVar m
putStrLn ("received " ++ show v)
putStrLn "sending"
putMVar m "wake up!"
newEmptyMVar
函数的作用从其名字一目了然。要创建一个初始状态非空的 MVar
,需要使用 newMVar
。
ghci> :t newEmptyMVar
newEmptyMVar :: IO (MVar a)
ghci> :t newMVar
newMVar :: a -> IO (MVar a)
在 ghci
运行一下上面例子。
ghci> :load MVarExample
[1 of 1] Compiling Main ( MVarExample.hs, interpreted )
Ok, modules loaded: Main.
ghci> communicate
sending
rece
如果有使用传统编程语言编写并发程序的经验,你会想到 MVar
有助于实现两个熟悉的效果。
- 从一个线程向另一个线程发送消息,例如:一个提醒。
- 对线程间共享的可变数据提供互斥。在数据没有被任何线程使用时,将其放入
MVar
,某线程需要读取或改变它时,将其临时从中取出。
主线程等待其他线程¶
GHC 的运行时系统对主线程的控制与其他线程不同。主线程结束时,运行时系统认为整个程序已经跑完了。其他没有执行完毕的线程,会被强制终止。
所以,如果线程执行时间非常长,且必须不被杀死,必须对主线程做特殊安排,以使得主线程在其他线程完成前都不退出。让我们来开发一个小库实现这一点。
-- file: ch24/NiceFork.hs
import Control.Concurrent
import Control.Exception (Exception, try)
import qualified Data.Map as M
data ThreadStatus = Running
| Finished -- 正常退出
| Threw Exception -- 被未捕获的异常终结
deriving (Eq, Show)
-- | 创建一个新线程管理器
newManager :: IO ThreadManager
-- | 创建一个被管理的线程
forkManaged :: ThreadManager -> IO () -> IO ThreadId
-- | 立即返回一个被管理线程的状态
getStatus :: ThreadManager -> ThreadId -> IO (Maybe ThreadStatus)
-- | 阻塞,直到某个特定的被管理线程终结
waitFor :: ThreadManager -> ThreadId -> IO (Maybe ThreadStatus)
-- | 阻塞,直到所有被管理线程终结
waitAll :: ThreadManager -> IO ()
我们使用一个常见的方法来实现 ThreadManager
的类型抽象:将其包裹进一个 newtype
,并防止使用者直接创建这个类型的值。在模块的导出声明中,我们列出了一个创建线程管理器的 IO 动作,但是并不直接导出类型构造器。
-- file: ch24/NiceFork.hs
module NiceFork
(
ThreadManager
, newManager
, forkManaged
, getStatus
, waitFor
, waitAll
) where
ThreadManager
的实现中维护了一个线程 ID 到线程状态的 map 。我们将此作为线程 map 。
-- file: ch24/NiceFork.hs
newtype ThreadManager =
Mgr (MVar (M.Map ThreadId (MVar ThreadStatus)))
deriving (Eq)
newManager = Mgr `fmap` newMVar M.empty
此处使用了两层 MVar
。首先将 Map
保存在 MVar 中。这将允许通过使用新版本替换来“改变” map 中的值。同样确保了每个使用这个 Map
的线程可以看到一致的内容。
对每个被管理的线程,都维护一个对应的 MVar
。这种 MVar
从空状态开始,表示这个线程正在执行。当线程被杀死或者发生未处理异常导致退出时,我们将此类信息写入这个 MVar
。
为了创建一个线程并观察它的状态,必须做一点簿记。
-- file: ch24/NiceFork.hs
forkManaged (Mgr mgr) body =
modifyMVar mgr $ \m -> do
state <- newEmptyMVar
tid <- forkIO $ do
result <- try body
putMVar state (either Threw (const Finished) result)
return (M.insert tid state m, tid)
安全的修改 MVar¶
forkManaged
中使用的 modifyMVar
函数很实用:它将 takeMVar
和 putMVar
安全的组合在一起。
ghci> :t modifyMVar
modifyMVar :: MVar a -> (a -> IO (a, b)) -> IO b
其从一个 MVar
中取出一个值,并传入一个函数。这个函数生成一个新的值,且返回一个结果。如果函数抛出一个异常, modifyMVar
会将初始值重新放回 MVar
,否则其会写入新值。它还会返回另一个返回值。
使用 modifyMVar
而非手动使用 takeMVar
和 putMVar
管理 MVar
, 可以避免两类并发场景下的问题。
- 忘记将一个值放回
MVar
。有的线程会一直等待MVar
中被放回一个值,如果一致没有等到,就将导致死锁。- 没有考虑可能出现的异常,扰乱了某端代码的控制流。这可能导致一个本应执行的
putMVar
没有执行,进而导致死锁。
因为这些美妙的安全特性,尽可能的使用 modifyMVar
是明智的选择。
安全资源管理:一个相对简单的好主意。¶
modifyMVar
遵循的模式适用很多场景。下面是这些模式:
- 获得一份资源。
- 将资源传入一个将处理它函数。
- 始终释放资源,即使函数抛出异常。如果发生异常,重新抛出异常,以便使其被程序捕获。
除了安全性,这个方法还有其他好处:可以是代码更简短且容易理解。正如前面的 forkManaged
, Hakell
的简洁语法和匿名函数使得这种风格的代码看起来一点都不刺眼。
下面是 modifyMVar
的定义,从中可以了解这个模式的细节:
-- file: ch24/ModifyMVar.hs
import Control.Concurrent (MVar, putMVar, takeMVar)
import Control.Exception (block, catch, throw, unblock)
import Prelude hiding (catch) -- use Control.Exception's version
modifyMVar :: MVar a -> (a -> IO (a,b)) -> IO b
modifyMVar m io =
block $ do
a <- takeMVar m
(b,r) <- unblock (io a) `catch` \e ->
putMVar m a >> throw e
putMVar m b
return r
这种模式很容易用于你的特定需求,无论是处理网络连接,数据库句柄,或者被 C
库函数管理的数据。
查看线程状态¶
我们编写的 getStatus
函数用于获取某个线程的当前状态。若某线程已经不被管理(或者未被管理),它返回 Nothing
。
-- file: ch24/NiceFork.hs
getStatus (Mgr mgr) tid =
modifyMVar mgr $ \m ->
case M.lookup tid m of
Nothing -> return (m, Nothing)
Just st -> tryTakeMVar st >>= \mst -> case mst of
Nothing -> return (m, Just Running)
Just sth -> return (M.delete tid m, Just sth)
若线程仍在运行,它返回 Just Running
。 否则,它指出将线程为何被终止,并停止管理这个线程。
若 tryTakeMVar
函数发现 MVar 为空,它将立即返回 Nothing
而非阻塞等待。
ghci> :t tryTakeMVar
tryTakeMVar :: MVar a -> IO (Maybe a)
否则,它将从 MVar 取到一个值。
waitFor
函数的行为较简单,其会阻塞等待给定线程终止,而非立即返回。
-- file: ch24/NiceFork.hs
waitFor (Mgr mgr) tid = do
maybeDone <- modifyMVar mgr $ \m ->
return $ case M.updateLookupWithKey (\_ _ -> Nothing) tid m of
(Nothing, _) -> (m, Nothing)
(done, m') -> (m', done)
case maybeDone of
Nothing -> return Nothing
Just st -> Just `fmap` takeMVar st
首先读取保存线程状态的 MVar
,若其存在。 Map
类型的 updateLookupWithKey
函数很有用:它将查找某个值与更新或移除组合起来。
ghci> :m +Data.Map
ghci> :t updateLookupWithKey
updateLookupWithKey :: (Ord k) =>
(k -> a -> Maybe a) -> k -> Map k a -> (Maybe a, Map k a)
在此处,我们希望若保存线程状态的 MVar
存在,则将其从 Map 中移除,这样线线程管理器将不在管理这个线程。若从其中取到了值,则从中取出线程的退出状态,并将其返回。
我们的最后一个实用函数简单的等待所有当前被管理的线程完成,且忽略他们的退出状态。
-- file: ch24/NiceFork.hs
waitAll (Mgr mgr) = modifyMVar mgr elems >>= mapM_ takeMVar
where elems m = return (M.empty, M.elems m)
编写更紧凑的代码¶
我们在上面定义的 waitFor
函数有点不完善,因为或多或少执行了重复的模式分析:在 modifyMVar
内部的回调函数,以及处理其返回值时。
当然,我们可以用一个函数消除这种重复。这是 Control.Monad
模块中的 join 函数。
ghci> :m +Control.Monad
ghci> :t join
join :: (Monad m) => m (m a) -> m a
这是个有趣的主意:可以创建一个 monadic 函数或纯代码中的 action ,然后一直带着它直到最终某处有个 monad 可以使用它。一旦我们了解这种写法适用的场景,就可以更灵活的编写代码。
-- file: ch24/NiceFork.hs
waitFor2 (Mgr mgr) tid =
join . modifyMVar mgr $ \m ->
return $ case M.updateLookupWithKey (\_ _ -> Nothing) tid m of
(Nothing, _) -> (m, return Nothing)
(Just st, m') -> (m', Just `fmap` takeMVar st)
使用频道通信¶
对于线程间的一次性通信, MVar
已经足够好了。另一个类型, Chan
提供了单向通信频道。此处有一个使用它的简单例子。
-- file: ch24/Chan.hs
import Control.Concurrent
import Control.Concurrent.Chan
chanExample = do
ch <- newChan
forkIO $ do
writeChan ch "hello world"
writeChan ch "now i quit"
readChan ch >>= print
readChan ch >>= print
若一个 Chan
未空, readChan
将一直阻塞,直到读到一个值。 writeChan
函数从不阻塞:它会立即将一个值写入 Chan
。
注意事项¶
MVar 和 Chan 是非严格的¶
正如大多数 Haskell
容器类型, MVar
和 Char
都是非严格的:从不对其内容求值。我们提到它,并非因为这是一个问题,而是因为这通常是一个盲点:人们倾向于假设这些类型是严格的,这大概是因为它们被用在 IO monad
中。
正如其他容器类型,误认为 MVar
和 Chan
是严格的会导致空间和性能的泄漏。考虑一下这个很可能发生的情况:
我们分离一个线程以在另一个核上执行一些开销较大的计算
-- file: ch24/Expensive.hs
import Control.Concurrent
notQuiteRight = do
mv <- newEmptyMVar
forkIO $ expensiveComputation_stricter mv
someOtherActivity
result <- takeMVar mv
print result
它看上去做了一些事情并将结果存入 MVar
。
-- file: ch24/Expensive.hs
expensiveComputation mv = do
let a = "this is "
b = "not really "
c = "all that expensive"
putMVar mv (a ++ b ++ c)
当我们在父线程中从 MVar
获取结果并尝试用它做些事情时,我们的线程开始疯狂的计算,因为我们从未强制指定在其他线程中的计算真正发生。
照旧,一旦我们知道了有个潜在问题,解决方案就很简单:未分离的线程添加严格性,以确保计算确实发生。这个严格性最好加在一个位置,以避免我们忘记添加过它。
-- file: ch24/ModifyMVarStrict.hs
{-# LANGUAGE BangPatterns #-}
import Control.Concurrent (MVar, putMVar, takeMVar)
import Control.Exception (block, catch, throw, unblock)
import Prelude hiding (catch) -- 使用 Control.Exception's 中的 catch 而非 Prelude 中的。
modifyMVar_strict :: MVar a -> (a -> IO a) -> IO ()
modifyMVar_strict m io = block $ do
a <- takeMVar m
!b <- unblock (io a) `catch` \e ->
putMVar m a >> throw e
putMVar m b
Note
查看 Hackage
始终是值得的。
在 Hackage
包数据库,你将发现一个库,strict-concurrency
,它提供了严格版本的 MVar
和 Chan
类型
上面代码中的 !
模式用起来很简单,但是并不总是足以确保我们的数据已经被求值。更完整的方法,请查看下面的段落“从求值中分离算法”。
Chan 是无边界的¶
因为 writeChan
总是立即成功,所以在使用 Chan
时有潜在风险。若对某个 Chan
的写入多于其读取, Chan
将用不检查的方法增长:对未读消息的读取将远远落后于其增长。
共享状态的并发仍不容易¶
尽管 Haskell 拥有与其他语言不同的基础设施用于线程间共享数据,它仍需克服相同的基本问题:编写正确的并发程序极端困难。真的,一些其他语言中的并发编程陷阱也会在 Haskell
中出现。其中为人熟知的两个是死锁和饥饿。
死锁¶
死锁的情况下,两个或多个线程永远卡在争抢共享资源的访问权上。制造多线程程序死锁的一个经典方法是不按顺序加锁。这种类型的 bug 很常见,它有个名字:锁顺序倒置。 Haskell
没有提供锁, 但 MVar
类型可能会有顺序倒置问题。这有一个简单例子:
-- file: ch24/LockHierarchy.hs
import Control.Concurrent
nestedModification outer inner = do
modifyMVar_ outer $ \x -> do
yield -- 强制当前线程让出 CPU
modifyMVar_ inner $ \y -> return (y + 1)
return (x + 1)
putStrLn "done"
main = do
a <- newMVar 1
b <- newMVar 2
forkIO $ nestedModification a b
forkIO $ nestedModification b a
在 ghci 中运行这段程序,它通常会(但不总是)不打印任何信息,表明两个线程已经卡住了。
容易看出 nestedModification
函数的问题。在第一个线程中,我们先取出 MVar a
,接着取出 b
。在第二个线程中,先取出 b
然后取出 a
,若第一个线程成功取出了 a
然后要取出 b
,这是两个线程都会阻塞:每个线程都尝试获取一个 MVar
,而这个 MVar
已经被另一个线程取空了,所以二者都不能完成整个流程。
无论何种语言,通常解决倒序问题的方法是申请资源时一直遵循一致的顺序。因为这需要人工遵循编码规范,在实践中很容易遗忘。
更麻烦的是,这种倒序问题在实际代码中很难被发现。获取 MVar
的动作经常跨越不同文件中的不同函数,这使得通过观察源码检查时更加棘手。更糟糕的是,这类问题通常是间歇性的,这使得它们难于重现,更不要说隔离和修复了。
饥饿¶
并发软件通常可能会导致饥饿问题,某个线程霸占了共享资源,阻止其他线程使用。很容易想象这是如何发生的:一个线程调用 modifyMVar
执行一个 100 毫秒的代码段,稍后另外一个线程对同一个 MVar
调用 modifyMVar
执行一个 1 毫秒的代码段。第二个线程在第一个线程完成前将无法执行。
MVar
类型的非严格性质使会导致或恶化饥饿的问题。若我们将一个求值开销很大的 thunk
写入一个 MVar
,在一个看上去开销较小的线程中取出并求值,这个线程的执行开销马上会变大。所以我们在 “MVar 和 Chan 是非严格的” 一章中特地给出了一些建议。
没希望了吗?¶
幸运的是,我们已经提及的并发 API
并不是故事的全部。最近加入 Haskell 中的一个设施,软件事务内存,使用起来更加容易和安全。我们将在第 28 章,软件事务内存中介绍。
练习¶
Chan
类型是使用MVar
实现的。使用MVar
来开发一个有边界的Chan
库。- 你开发的 newBoundedChanfunction 接受一个
Int
参数,限制单独BoundedChan
中的未读消息数量。 - 达到限制是, 调用
writeBoundedChanfunction
要被阻塞,知道某个读取者使用readBoundedChan
函数消费掉队列中的一个值。 - 尽管我们已经提到过 Hackage 库中的
strict-concurrency
包,试着自己开发一个,作为内置MVar
类型的包装。按照经典的Haskell
实践,使你的库类型安全,让用户不会混淆严格和非严格的MVar
。
在 GHC 中使用多核¶
默认情况下, GHC
生成的程序只使用一个核,甚至在编写并发代码时也是如此。要使用多核,我们必须明确指定。当生成可执行程序时,要在链接阶段指定这一点。
- “non-threaded” 运行时库在一个操作系统线程中运行所有
Haskell
线程。这个运行时在创建线程和通过 MVar 传递数据时很高效。- “threaded” 库使用多个操作系统线程运行
Haskell
线程。它在创建线程和使用MVar
时具有更高的开销。
若我们在向编译器传递 -threadedoption
参数,它将使用 threaded
运行时库链接我们的程序。在编译库和源码文件时无需指定 -threaded
,只是在最终生成可执行文件时需要指定。
即使为程序指定了 threaded
运行时,默认情况下它仍将只使用一个核运行。必须明确告诉运行时使用多少个核。
运行时选项¶
运行程序时可以向 GHC 的运行时系统传递命令行参数。在将控制权交给我们的代码前,运行时扫描程序的参数,看是否有命令行选项 +RTS
。其后跟随的所有选项都被运行时解释,直到特殊的选项 -RTS
,这些选项都是提供给运行时系统的,不为我们的程序。运行时会对我们的代码隐藏所有这些选项。当我们使用 System.Environment
模块的 getArgsfunction
来获得我们的命令行参数是,我们不会在其中获得运行时选项。
threaded
运行时接受参数 -N
[55] 。 其接受一个参数,指定了 GHC
的运行时系统将使用的核数。这个选项对输入很挑剔: -N
和参数之间必须没有空格。 -N4
可被接受, -N 4
则不被接受。
找出 Haskell 可以使用多少核¶
GHC.Conc
模块输出一个变量, numCapabilities
,它会告诉我们运行时系统被 -NRTS
选项指定了多少核。
-- file: ch24/NumCapabilities.hs
import GHC.Conc (numCapabilities)
import System.Environment (getArgs)
main = do
args <- getArgs
putStrLn $ "command line arguments: " ++ show args
putStrLn $ "number of cores: " ++ show numCapabilitie
若编译上面的程序,我们可以看到运行时系统的选项对于程序来说是不可见的,但是它可以看其运行在多少核上。
$ ghc -c NumCapabilities.hs
$ ghc -threaded -o NumCapabilities NumCapabilities.o $ ./NumCapabilities +RTS -N4 -RTS foo
command line arguments: ["foo"]
number of cores: 4
选择正确的运行时¶
选择正确的运行时需要花点心思。 threaded
运行时可以使用多核,但是也有相应的代价:线程间共享数据的成本比 non-threaded
运行时更大。
目前为止, GHC 的 6.8.3 版本使用的垃圾收集器是单线程的:它执行时暂停其他所有线程,而且它是在单核上执行。这限制了我们在使用多核的时候希望看到的性能改进[56]_。
很多真实世界中的并发程序中,一个单独的线程多数时间实在等待一个网络请求或响应。这些情况下,若以一个单独的 Haskell
程序为数万并发客户端提供服务,使用低开销的 non-threaded
运行时很可能是合适的。例如,与其用 4 个核跑 threaded 运行时的单个服务器程序,可能同时跑 4 个 non-threaded 运行时的相同服务器程序性能更好。
我们的目的并不是阻止你使用 threaded
运行时。相对于 non-threaded
运行时它并没有特别大的开销:相对于其他编程语言,线程依旧惊人的轻量。我们仅是希望说明 threaded
运行时并不是在所有场景都是最佳选择。
Haskell 并行编程¶
现在让我们来关注一下并行编程。对很多计算密集型问题,可以通过分解问题,并在多个核上求值来更快的计算出结果。多核计算机已经普及,甚至在最新的笔记本上都有,但是很少有程序可以利用这一优势。
大部分原因是因为传统观念认为并行编程非常困难。在一门典型的编程语言中,我们将用处理并发程序相同的库和设施处理并发程序。这是我们的注意力集中在处理一些熟悉的问题比如死锁、竞争条件、饥饿和陡峭的复杂性。
但是我们可以确定,使用 Haskell
的并发特性开发并行代码时,有许多更简单的方法。在一个普通的 Haskell 函数上稍加变化,就可以并行求值。