在Simon Marlow 所著的《Haskell 中的并行和并发编程》第 133 和 134 页中显示了以下代码:
type Name = String
type PhoneNumber = String
type PhoneBook = Map Name PhoneNumber
newtype PhoneBookState = PhoneBookState (MVar PhoneBook)
lookup :: PhoneBookState -> Name -> IO (Maybe PhoneNumber)
lookup (PhoneBookState m) name = do
book <- takeMVar m
putMVar m book
return (Map.lookup name book)
下一页有一些观察。不过,第二段的最后部分让我有点困惑。以下是该段的前半部分:
在可变包装器中使用不可变数据结构还有其他好处。需要注意的是,在
lookup
操作中,我们只是获取了状态的当前值,然后复杂的操作在/序列Map.lookup
之外进行。这对于并发性来说非常有利,因为这意味着锁只会被持有很短的时间。takeMVar
putMVar
这对我来说相当清楚,因为我理解 会takeMVar
锁定,然后putMVar
立即释放,这样后续对 的并发访问PhoneBookState
就不会再受到任何阻碍。同时,我还发现 可能会发生进一步的变异,所以当 以 为返回值MVar
时,该对可能已经从映射中移除(或者,如果返回,则可能已经将一些对添加到映射中)。lookup
Just smth
(name, smth)
Nothing
(name, smth)
然而,我不明白该段接下来的部分想要表达什么:
这之所以可能,是因为状态的值是不可变的。如果数据结构是可变的,那么我们在操作它时就必须持有锁。⁵
⁵. 另一种选择是使用无锁算法,但这非常复杂,很难做到正确。
这是什么意思?作者指的是将某些 API 暴露给 mutated 的情况Map
吗?除非我们在 中,否则这根本不可能,IO
对吧?或者他指的是对 的内容执行的修改(由释放锁m
后的其他线程执行)会以某种方式影响 的内容?putMVar
book
Marlow 将 Haskell 的不可变数据方法与允许所有数据结构发生变异的语言进行了对比。
在 Java 或 JavaScript 这样的语言中,仅仅锁定保存 的变量是不够的
Map
,因为可能有另一个线程已经获取了Map
并正在修改它。因此,您需要在访问整个结构时锁定它。在 Haskell 中,我们已经知道 是
Map
不可变的。任何想要获取Map
并修改 的操作都必须创建一个新值。因此,我们只需要锁定MVar
保存 的Map
,而不是整个Map
(尽管如果您打算将修改后的映射写回原始映射MVar
,则确实需要为整个操作保持锁定)。关于“无锁”结构的部分在这篇维基百科文章中有更详细的解释。简而言之,创建一棵有序树,多个线程可以修改树的内容而不会出错,这几乎是可能的。但这种结构的定义和证明是深奥的魔法,在正常的开发过程中不应该尝试。
这里有一个完整的示例,或许能说明这个问题。如果我不想写一整套可变映射表的实现来回答这个问题,相信您也能理解。但请考虑以下简化的可变映射表,它只能存储一个键值对。它支持“新建”、“查找”和“替换”操作。(我将其命名为“替换”而不是“插入”,是因为该映射表只能存储一个键值对,因此插入新键值对时,之前的条目会丢失。)
现在,假设我们要使用这个可变数据结构编写一个线程安全的并发电话簿接口。修改后的代码
lookup
与原始代码大致相同:mmapLookup
我们在解锁状态后执行实际操作。new
和 的对应 API 代码replace
大概是这样的:请注意,与 不同
lookup
,我们对 采取了保守的方法replace
,在整个替换操作过程中保持锁定。lookup
然而,这个实现对于/对存在竞争条件replace
。为了明白原因,我们来给 添加一个延迟mmapLookup
:并考虑以下
main
函数,它为“Alice”的电话号码创建电话簿,然后启动两个线程 - 一个用于查找“Alice”,另一个用于用“Bob”的号码替换条目:当此程序运行时,它会在查找 Alice 时错误地检索 Bob 的号码:
显然,问题在于替换操作发生在键的比较和值的检索之间。
如果您修改
lookup
为仅在查找完成后才释放锁:那么就不会出现竞争条件。无论你如何交错操作(也无论你如何精心设置延迟),你要么
Just "555-1234"
会从原始电话簿中检索到 Alice 的号码,要么无法检索到 Alice 的号码,并Nothing
从修改后的电话簿中返回(其中 Alice 的号码已被 Bob 的号码替换)。因此,对于可变的
MMap
,我们必须持有操作的锁mmapLookup
,以便安全地操作数据结构,而不允许任何其他线程在我们执行时修改数据结构lookup
。相反,如果我们使用类似的不可变数据结构:
new
那么,,lookup
和的相应实现replace
将是:至关重要的是,当从 中取出
replace
时,它很可能与中的调用并发访问的相同,它不会改变自身,而是通过调用创建一个新的,而不会影响原始的。这就是不可变数据结构的本质,也是为什么操作可以安全地继续在未修改的 上运行,即使创建新的并将其写回到空的 中也是如此。book
MVar
book
imapLookup
lookup
book
book'
imapReplace
book
imapLookup
book
replace
book'
MVar
即使我们不遗余力地将其重写
imapLookup
为一个IO
操作,也有足够的机会让另一个线程在键和值访问之间运行:我们仍然不会触发竞争条件,因为
book :: IMap k v
被访问imapLookup name book
是不可变的,无论它在其他线程中如何被访问。以下是使用可变结构说明竞争条件的完整示例
MMap
:以及没有竞争条件的不可变版本: