小编典典

在Go中同时访问带有“范围”的地图

go

Go博客中的“ Go maps in action ”条目指出:

地图不能安全地并发使用:未定义同时读取和写入地图时会发生什么情况。如果您需要从并发执行的goroutine中读取和写入映射,则必须通过某种同步机制来调解访问。保护地图的一种常见方法是使用sync.RWMutex。

但是,一种常见的访问地图的方法是使用range关键字遍历它们。出于并发访问的目的,不清楚在一个range循环内执行是“读”还是该循环的“转换”阶段。例如,以下代码可能会或可能不会运行“在地图上没有并发读/写”规则,具体取决于操作的特定语义/实现range

 var testMap map[int]int
 testMapLock := make(chan bool, 1)
 testMapLock <- true
 testMapSequence := 0

 func WriteTestMap(k, v int) {
    <-testMapLock
    testMap[k] = v
    testMapSequence++
    testMapLock<-true
 }

 func IterateMapKeys(iteratorChannel chan int) error {
    <-testMapLock
    defer func() { 
       testMapLock <- true
    }
    mySeq := testMapSequence
    for k, _ := range testMap {
       testMapLock <- true
       iteratorChannel <- k
       <-testMapLock
       if mySeq != testMapSequence {
           close(iteratorChannel)
           return errors.New("concurrent modification")
       }
    }
    return nil
 }

这里的想法是,range当第二个函数正在等待使用者获取下一个值时,“迭代器”处于打开状态,并且此时未阻止编写器。但是,决不会在单个迭代器中的两次读取位于写入的任何一侧-
这是一个“快速失败”迭代器,借用了Java术语。

但是,语言规范或其他文档中是否有任何内容表明这样做是否合法?我可以看到它沿任何一种方式前进,并且上面引用的文档尚不清楚究竟是什么构成“读”。该文档在for/
range语句的并发方面似乎完全安静。

(请注意,这个问题与的货币有关for/range,但不是与货币的重复:具有范围的Golang并发地图访问 -用例是完全不同的,我在这里询问有关“范围”关键字的精确锁定要求!)


阅读 287

收藏
2020-07-02

共1个答案

小编典典

您正在使用for带有range表达式的语句。引用规范:对于语句:

范围表达式在开始循环之前先进行一次评估
,但有一个例外:如果范围表达式是一个数组或一个指向数组的指针,并且最多存在一个迭代变量,则仅评估范围表达式的长度;否则,不执行任何操作。如果该长度是常数,则根据定义,范围表达式本身将不会被求值。

我们在地图上进行范围调整,因此也不例外:范围表达式在开始循环之前仅被评估一次。范围表达式只是一个map变量testMap

for k, _ := range testMap {}

映射值不包括键值对,它仅 指向
包含键值对的数据结构。为什么这很重要?由于映射值仅被评估一次,并且如果以后添加了对,则在循环之前评估一次的映射值将成为仍然指向包含这些新对的数据结构的映射。这与对切片(也将被评估一次)的范围形成对比,切片也只是指向包含元素的后备数组的标头;但是如果在迭代过程中将元素添加到切片中,
_即使_如果那不会导致分配并复制到新的后备数组,则它们将不包含在迭代中(因为slice头还包含已评估的长度)。将元素追加到切片可能会产生新的切片值,但是将对添加到地图不会导致新的地图值。

现在开始迭代:

for k, v := range testMap {
    t1 := time.Now()
    someFunction()
    t2 := time.Now()
}

在进入块之前,t1 := time.Now()在行kv变量保存迭代值之前,它们 已经 从映射中 读出
(否则它们无法保存值)。问:你认为地图是由读for ... range语句 之间 t1t2?在什么情况下会发生这种情况?我们这里有
一个 正在执行的goroutine someFunc()。为了能够通过该语句 访问 地图for,这可能需要 另一个
goroutine或将要 暂停 someFunc()。显然,这些都没有发生。(for ...range结构不是多够程的怪物。)不管有多少次迭代有, someFunc()被执行时,地图不被访问的for声明

因此,要回答您的一个问题:for在执行迭代时,不会在块内访问该映射,但是在为下一个迭代设置kv值(已分配)时会访问该映射。这意味着在地图上
进行 以下迭代 对于并发访问安全的

var (
    testMap         = make(map[int]int)
    testMapLock     = &sync.RWMutex{}
)

func IterateMapKeys(iteratorChannel chan int) error {
    testMapLock.RLock()
    defer testMapLock.RUnlock()
    for k, v := range testMap {
        testMapLock.RUnlock()
        someFunc()
        testMapLock.RLock()
        if someCond {
            return someErr
        }
    }
    return nil
}

请注意,解锁IterateMapKeys()(必须)应作为延迟的语句进行,因为在原始代码中,您可能会返回“early”并出现错误,在这种情况下您没有解锁,这意味着地图保持锁定状态!(在此处建模if someCond {...})。

还要注意,这种类型的锁定仅在并发访问的情况下才能确保锁定。 它不会阻止并发goroutine修改(例如添加新对)映射。
修改(如果使用写锁适当地保护)将是安全的,并且循环可以继续,但是不能保证for循环将在新对上进行迭代:

如果在迭代过程中删除尚未到达的映射条目,则不会生成相应的迭代值。如果映射条目是在迭代过程中创建的,则该条目可能在迭代过程中产生或可以被跳过。对于创建的每个条目以及从一个迭代到下一个迭代,选择可能有所不同。

写锁定保护的修改可能如下所示:

func WriteTestMap(k, v int) {
    testMapLock.Lock()
    defer testMapLock.Unlock()
    testMap[k] = v
}

现在,如果您在的代码块中释放了读取锁,则for并发的goroutine可以自由地获取写入锁并对地图进行修改。在您的代码中:

testMapLock <- true
iteratorChannel <- k
<-testMapLock

在上发送kiteratorChannel,并发goroutine可能会修改地图。这不仅是一种“不幸”的情况,在通道上发送值通常是“阻塞”操作,如果通道的缓冲区已满,则必须准备好接收另一个goroutine才能使发送操作继续进行。在通道上发送值是运行时甚至在同一OS线程上运行其他goroutine的良好调度点,更不用说是否有多个OS线程,其中一个OS线程可能已经在“等待”顺序进行写锁定进行地图修改。

总结一下最后一部分:释放for块内的读取锁就像对别人大喊大叫:“来吧,如果您敢,请立即修改地图!” 因此,在您的代码中mySeq != testMapSequence很可能会遇到这种情况。请参见以下可运行示例进行演示(它是您的示例的变体):

package main

import (
    "fmt"
    "math/rand"
    "sync"
)

var (
    testMap         = make(map[int]int)
    testMapLock     = &sync.RWMutex{}
    testMapSequence int
)

func main() {
    go func() {
        for {
            k := rand.Intn(10000)
            WriteTestMap(k, 1)
        }
    }()

    ic := make(chan int)
    go func() {
        for _ = range ic {
        }
    }()

    for {
        if err := IterateMapKeys(ic); err != nil {
            fmt.Println(err)
        }
    }
}

func WriteTestMap(k, v int) {
    testMapLock.Lock()
    defer testMapLock.Unlock()
    testMap[k] = v
    testMapSequence++
}

func IterateMapKeys(iteratorChannel chan int) error {
    testMapLock.RLock()
    defer testMapLock.RUnlock()
    mySeq := testMapSequence
    for k, _ := range testMap {
        testMapLock.RUnlock()
        iteratorChannel <- k
        testMapLock.RLock()
        if mySeq != testMapSequence {
            //close(iteratorChannel)
            return fmt.Errorf("concurrent modification %d", testMapSequence)
        }
    }
    return nil
}

输出示例:

concurrent modification 24
concurrent modification 41
concurrent modification 463
concurrent modification 477
concurrent modification 482
concurrent modification 496
concurrent modification 508
concurrent modification 521
concurrent modification 525
concurrent modification 535
concurrent modification 541
concurrent modification 555
concurrent modification 561
concurrent modification 565
concurrent modification 570
concurrent modification 577
concurrent modification 591
concurrent modification 593

我们经常遇到并发修改!

您要避免这种并发修改吗?解决方案非常简单:请勿释放中的读取锁for。另外,运行您的应用程序并-race选择检测竞争状况的选项:go run -race testmap.go

最后的想法

语言规范清楚地允许您 在同一goroutine 范围内修改地图,这就是前面的引用所涉及的内容(
“如果在迭代过程中删除了尚未到达的地图条目…。在迭代过程中创建…”
)。允许在同一goroutine中修改地图,这是安全的,但未定义迭代器逻辑如何处理地图。

如果在另一个goroutine中修改了映射,如果您使用适当的同步,则Go Memory
Model会
保证带有的goroutine for ...range将遵守所有修改,并且迭代器逻辑将看到它,就好像“自己的” goroutine将对其进行修改一样–如前所述,这是允许的。

2020-07-02