不使用锁并发地读取函数指针是否安全?

12

假设我有这个:

go func() {
    for range time.Tick(1 * time.Millisecond) {
        a, b = b, a
    }
}()

而在别处:

i := a // <-- Is this safe?
对于这个问题,与原始的 ab 相比,i 的值是无关紧要的。唯一的问题是读取 a 是否安全。也就是说,a 可能是 nil,部分赋值,无效,未定义,...... 除了一个有效值以外的任何东西吗?
我已经尝试过使其失败,但到目前为止它总是成功的(在我的 Mac 上)。
我没有能够找到除了在The Go Memory Model文档中的这句话之外的任何具体信息:

Reads and writes of values larger than a single machine word behave as multiple machine-word-sized operations in an unspecified order.

这是否意味着单个机器字写入实际上是原子的?如果是这样,那么在 Go 中函数指针写入是单个机器字操作吗? 更新: 这里是正确同步的解决方案

我认为是这样的。看一下这个链接:(https://play.golang.org/p/b-fyvCiR7b)指针的大小始终为4字节。在32位处理器中,字长为32位(4字节)。显然,在64位处理器中,字长为8字节。因此,基于此和您从文档中发布的代码片段,我认为它是安全的。 - Amir Keibi
@AmirKeibi 需要注意的重点是,文档中的 “保证” 并不是 表示一个“单机器字操作” 是原子的。它仅表示大于一个机器字的操作是无序的。实际上,无论读写是否是单机器字操作,都没有任何保证(也没有Go可以保证的方式)该操作是原子的。这取决于硬件,从Go的角度来看是未定义的,因此需要同步。 - nicerobot
1
我认为问题标题有点误导人。并发读取是安全的,但只要涉及至少一个写操作,一切都会崩溃。 - Deleplace
3个回答

25

当至少有一个goroutine进行写操作时,从多个goroutine对任何变量进行不同步、并发访问是Go内存模型中的未定义行为。

未定义就是它所说的:未定义。你的程序可能会正常工作,也可能会出现错误。这可能导致丢失Go运行时提供的内存和类型安全(请参见下面的示例)。甚至可能会导致程序崩溃。或者甚至可能导致地球爆炸(这种可能性非常小,甚至可能小于1e-40,但仍然存在...)。

在你的情况下,这个未定义意味着,是的,i可能是nil、部分赋值、无效、未定义等,除了ab之外的任何其他结果。这个列表只是所有可能结果的一小部分。

不要认为某些数据竞争是(或可能是)良性或无害的。如果不加注意,它们可能成为最糟糕的事情的源头。

由于你的代码在一个goroutine中写入变量a,在另一个goroutine中读取它(试图将其值分配给另一个变量i),这是一种数据竞争,因此不安全。无论你的测试结果是否“正确”,都没有关系。有人可能以你的代码为起点,扩展/构建它,并由于最初“无害”的数据竞争而导致灾难。
作为相关问题,请阅读Golang映射对并发读/写操作有多安全?Go语言中的不正确同步
强烈建议阅读Dmitry Vyukov的博客文章:良性数据竞争:可能会出现什么问题?

以下是一个非常有趣的博客文章,其中展示了一个例子,通过有意诱导数据竞争来破坏Go的内存安全性: Golang data races to break memory safety


2
通常情况下,非常好的表述。我还建议OP阅读这篇文章以更好地掌握底层硬件问题。总结一下,对于OP来说,存在两个问题“在”他们在文本编辑器中编写代码时:1)编译器可以生成对变量的内存位置进行“奇怪”操作的机器代码;2)多CPU和/或-core硬件存在缓存一致性问题:当一个CPU从内存中读取一个值时,它不一定是从另一个CPU写入的同一位置读取的。 - kostix
2
哦,还有第三个问题:3)现代CPU在广泛使用的硬件平台上通常执行内存访问的重新排序。只有当程序员除了语言的内存模型所保证的内容之外还有期望时,所有这三个问题才会“破坏事情”,所以请不要有这些期望。;-) - kostix
2
@nicerobot 这就是我的观点。你没有保证i的值是有效的,这就是“未定义”的含义。你的程序甚至可能会崩溃,我想你不会认为这是良性的。也许在当前的编译器、硬件和生成的代码下,你不会遇到这个问题,但你并没有保证。甚至可能下一个版本的Go编译器将生成不同的“优化”代码,这将导致你希望程序执行的行为出现问题——只是因为你在代码中留下了数据竞争。 - icza
1
@nicerobot,考虑使用sync/atomic操作来访问您的值:您不会得到任何排序保证(也不需要它们),但在内存模型方面,您将是安全的,因为这些函数确保了必要的内存栅栏。您可以搜索最近在“golang-nuts”邮件列表上处理此问题的线程。 - kostix
1
@nicerobot,FYI,这是我提到的线程,特别是来自核心团队成员之一的这个回复,涉及到sync/atomicLoad*()Store*()以及它们根据内存模型提供的happens-before保证;它还提到了一个有趣的问题。希望这对你有所帮助。 - kostix
显示剩余6条评论

5
竞态条件 而言,它是不安全的。简而言之,我对竞态条件的理解是:当有多个异步例程(协程、线程、进程、goroutine 等)尝试访问同一资源时,并且至少有一个是写操作时,就会发生竞态条件。因此,在您的示例中,我们有 2 个 goroutine 读取和写入函数类型的变量,我认为从并发的角度来看,重要的是这些变量在某个内存空间中,并且我们正在尝试在该内存部分中进行读取或写入。 简短回答:只需使用 -race 标志运行您的示例,使用 go run -racego build -race,您将看到检测到了数据竞争

这是一个很好的观点。但我不认为他的问题与竞态条件有关。也就是说,i可能是nil、部分赋值、无效、未定义或其他任何不是a或b的东西吗? - Amir Keibi
2
唯一的问题是给i赋值是否安全。 - Yandry Pozo
不是这样的,他澄清了问题是什么:“除了a或b之外的任何东西?” - Amir Keibi
1
@AmirKeibi 由于存在竞态条件,答案是肯定的。 - nos
我的问题实际上与数据竞争无关。尽管-race确实会显示出竞争,但如果值始终保证有效,我就不在意它。我相信这最终更多地涉及硬件问题,以及在同时发生并发读取时,写入地址的值是否可能处于无效状态。 - nicerobot
最后,我认为最好的建议是尊重-race :) - nicerobot

1
截至今天,如果 ab 的大小不超过机器字长,那么 i 必须等于 ab。否则,它可能包含一个未指定的值,这很可能是来自 ab 不同部分的交错。
截至2022年6月6日版本,Go 内存模型 保证,如果程序执行竞争条件,对于不大于机器字长的内存访问,必须是原子的。
否则,对于不大于机器字长的内存位置 x 的读取 r 必须观察到某个写入 w,使得 r 不在 w 之前发生,且不存在另一个写入 w',使得 w 发生在 w' 之前,w' 发生在 r 之后。也就是说,每次读取必须观察到由先前或并发写入的值。
这里的先于关系在上一节的内存模型中定义。
从较大的内存位置进行竞争性读取的结果是未指定的,但绝对不像 C++ 中那样是未定义的。
对于单个机器字长以上的内存位置的读取被鼓励但不要求满足与机器字长相同的语义,观察单个允许的写入 w。出于性能原因,实现可以将较大的操作视为一组按未指定顺序排列的单个机器字长操作。这意味着在多字数据结构上的竞争可能导致不对应于单个写入的不一致值。当值依赖于内部(指针、长度)或(指针、类型)对的一致性时,如在大多数 Go 实现中的接口值、映射、切片和字符串中,这些竞争又可能导致任意的内存损坏。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接