Golang中非原子加载与CompareAndSwap是否冲突?

Golang中非原子加载与CompareAndSwap是否冲突? 非原子读取是否会与 atomic.CompareAndSwap 冲突?我有以下代码:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	x := int32(0)
	for i := 0; i < 100000; i++ {
		n := int32(0)
		wg := sync.WaitGroup{}
		wg.Add(2)
		go func() {
			atomic.CompareAndSwapInt32(&n, 1, 2)
			wg.Done()
		}()
		go func() {
			x += n
			wg.Done()
		}()
		wg.Wait()
	}
	fmt.Println(x)
}

这是一个非常简单的程序:有两个 goroutine,其中一个在 n 上调用 atomic.CompareAndSwap(),另一个(非原子地)读取 n。使用 go run -race main.go 运行此程序会得到以下输出:

==================
WARNING: DATA RACE
Read at 0x00c00001413c by goroutine 8:
  main.main.func2()
      /tmp/atomictest/main.go:20 +0x59

Previous write at 0x00c00001413c by goroutine 7:
  sync/atomic.CompareAndSwapInt32()
      /home/jauhararifin/.gvm/gos/go1.21/src/runtime/race_amd64.s:310 +0xb
  sync/atomic.CompareAndSwapInt32()
      <autogenerated>:1 +0x18

Goroutine 8 (running) created at:
  main.main()
      /tmp/atomictest/main.go:19 +0x4a

Goroutine 7 (running) created at:
  main.main()
      /tmp/atomictest/main.go:15 +0x17c
==================

检测到数据竞争,因为我(非原子地)读取 n 的同时,atomic.CompareAndSwap(&n, ..., ...) 也在并发执行。请注意,我将 CAS 操作设置为总是失败。


这可以理解,因为我在读取 n 时没有使用原子操作。但我阅读了 Go sync.Mutex 的实现:

func (m *Mutex) Lock() {
	// Fast path: grab unlocked mutex.
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
	// Slow path (outlined so that the fast path can be inlined)
	m.lockSlow()
}

func (m *Mutex) lockSlow() {
	var waitStartTime int64
	starving := false
	awoke := false
	iter := 0
	old := m.state
	...
}

如你所见,m.state 上存在可能的并发访问。在快速路径中,一个 goroutine 可以在 &m.state 上执行 CAS。同时,另一个 goroutine 可以在慢速路径中执行 old := m.state。但是,使用 -race 标志并发执行 Lock() 并没有显示任何数据竞争报告。这是如何实现的?


更多关于Golang中非原子加载与CompareAndSwap是否冲突?的实战教程也可以访问 https://www.itying.com/category-94-b0.html

10 回复

peakedshout:

看起来标准库有某种忽略机制?

这是我的第一个假设。但我不知道如何证明这一点。

更多关于Golang中非原子加载与CompareAndSwap是否冲突?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


if race.Enabled {
        race.Acquire(unsafe.Pointer(m))
    }

这告诉检测器不存在竞争问题。

这似乎是真的。 感谢你为我解答了一个问题。 之前,我一直在考虑运行时的检测,没想到关键点在于在cmd中构建。

再次感谢。

嗯,你说得对。 这非常有趣。我修改了标准库并添加了一个函数:

func(m *Mutex)Test(){
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
_=m.state
}

发现调用时没有产生警告?看起来标准库有某种忽略机制?

哦,我误解了你的问题。 我重新阅读了你提供的代码以及互斥锁的源代码。

这两个例子本质上是不同的。 你需要理解 atomic.CompareAndSwapInt32 在做什么:

  1. 开始原子执行
  2. 读取状态变量,比较旧变量,当一致时写入新值
  3. 结束原子执行 唯一能执行上述操作的 goroutine 是第一个抢到锁的 goroutine (A)。

那么后续的 goroutine 在做什么呢?

  1. 开始原子执行
  2. 读取状态变量,比较旧变量,发现不一致,不写入新值
  3. 结束原子执行
  4. 读取状态变量 (old := m.state)

你发现了吗?后续的 goroutine 只涉及读取操作,没有发生写入。并发读取变量不会产生竞争。

一个简化的例子是:

type T struct {
	x int32
}

func (t *T) X() {
	if atomic.CompareAndSwapInt32(&t.x, 0, 1) {
		return
	}
	_ = t.x
	//fmt.Println(t.x)
}

但是,即使没有触发 race.Acquire(unsafe.Pointer(m)),数据竞争也应该发生。例如,假设有 3 个 goroutine:

  1. goroutine 1:锁定互斥锁,在快速路径中成功
  2. goroutine 2:在快速路径中锁定互斥锁,但失败并进入慢速路径
  3. goroutine 2 现在即将执行 old := m.state
  4. goroutine 3:锁定互斥锁,它尝试在快速路径中执行 atomic.CompareAndSwap(...)
  5. goroutine 1:释放锁
  6. 此处存在数据竞争:goroutine 2 现在执行 old := m.state,而 goroutine 3 同时执行 atomic.CompareAndSwap(...)。在 goroutine 2 和 3 的情况下,没有调用 race.Acquire(unsafe.Pointer(m))

在上述场景中,goroutine 2 和 3 并发访问 m,其中一个执行加载操作,另一个执行 CAS 操作,期间没有调用 runtime.Acquire。然而,并没有捕获到数据竞争。

我查阅了一些资料:

llvm/llvm-project/blob/main/compiler-rt/lib/tsan/go/tsan_go.cpp

//===-- tsan_go.cpp -------------------------------------------------------===//
//
// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
// See https://llvm.org/LICENSE.txt for license information.
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
//
//===----------------------------------------------------------------------===//
//
// ThreadSanitizer runtime for Go language.
//
//===----------------------------------------------------------------------===//

#include "tsan_rtl.h"
#include "tsan_symbolize.h"
#include "sanitizer_common/sanitizer_common.h"
#include <stdlib.h>

namespace __tsan {

void InitializeInterceptors() {

此文件已被截断。 显示原始文件

以及一些源代码: src/runtime/race/* src/runtime/race.go src/sync/*

看起来竞态检测是由外部工具执行的,但我仍然不知道如何注册特定的忽略包。 看来我必须分析 goroutine 的工作流程…… 但目前我没有精力继续探索。 我会等待其他人的回答。

你找到了吗?后续的 goroutine 只涉及读操作,没有写操作发生。变量的并发读取不会产生竞态。

如果你检查我的原始代码:

n := int32(0)
go func() {
	atomic.CompareAndSwapInt32(&n, 1, 2)
}()
go func() {
	x += n
}()

上面的 atomic.CompareAndSwapInt32 永远不会成功交换 n。因为 n 初始化为 0,而 CAS 试图将其从 1 交换到 2。所以 CAS 总是失败。它只是读取 n,什么也不做。它从未交换 n。在此次执行结束时,n 总是保证为零。 所以,在我的原始代码中,同样没有写操作发生。但是,Go 将其计为数据竞态。


如果你看我上面的例子:

  1. goroutine 1:锁定互斥锁,在快速路径中成功
  2. goroutine 2:在快速路径中锁定互斥锁,但失败并进入慢速路径
  3. goroutine 2 现在即将执行 old := m.state
  4. goroutine 3:锁定互斥锁,它尝试在快速路径中执行 atomic.CompareAndSwap(...)
  5. goroutine 1:释放锁
  6. 数据竞态发生:goroutine 2 现在执行 old := m.state,而 goroutine 3 并发地执行 atomic.CompareAndSwap(...)。在 goroutine 2 和 3 的情况下,没有调用 race.Acquire(unsafe.Pointer(m))

在上面的步骤 (6) 中,goroutine 2 和 3 都可以访问相同的数据 m.state,其中 goroutine 3 将通过 CAS 写入它,而 goroutine 2 将在没有原子操作的情况下读取它。但这并没有触发数据竞态检测。

我花了一些时间研究在启用 -race 标志时,Go 是如何生成 SSA 的。事实证明,当你读取一个变量时,Go 会在 SSA 中添加额外的 call runtime.raceread 指令。这个指令通常不会被添加。只有当你使用 -race 标志构建时,它才会被添加。

有趣的是,在 mutex.go 文件(位于 sync 包内)中,并没有添加 runtime.raceread 调用。

因此,我搜索了 Go 源代码,在代码生成部分寻找 raceread 的出现。我发现了这个:

// src/cmd/compile/internal/ssagen/ssa.go
ir.Syms.Raceread = typecheck.LookupRuntimeFunc("raceread")

然后我查看了 ir.Syms.Raceread 在何处被使用,并发现了这个:

// src/cmd/compile/internal/ssagen/ssa.go
func (s *state) instrument2(t *types.Type, addr, addr2 *ssa.Value, kind instrumentKind) {
...
	} else if base.Flag.Race {
		// for non-composite objects we can write just the start
		// address, as any write must write the first byte.
		switch kind {
		case instrumentRead:
			fn = ir.Syms.Raceread
		case instrumentWrite:
			fn = ir.Syms.Racewrite
		default:
			panic("unreachable")
		}
	} else if base.Flag.ASan {

我的直觉告诉我,当添加 -race 标志时会调用 instrument2。因此,我仔细查看了 instrument2

func (s *state) instrument2(t *types.Type, addr, addr2 *ssa.Value, kind instrumentKind) {
	if !s.curfn.InstrumentBody() {
		return
	}

看起来,当 InstrumentBody() 返回 false 时,会跳过检测代码。

func (f *Func) InstrumentBody() bool           { return f.flags&amp;funcInstrumentBody != 0 }
func (f *Func) SetInstrumentBody(b bool)           { f.flags.set(funcInstrumentBody, b) }

于是,我查看了谁调用了 SetInstrumentBody

	if !base.Flag.Race || !base.Compiling(base.NoRacePkgs) {
		fn.SetInstrumentBody(true)
	}

base.NoRacePkgs 看起来很有希望,我查看了它的内部:

var NoRacePkgs = []string{"sync", "sync/atomic"}

是的,看起来 sync 包是一个例外。检测代码不会添加到那里。因此,即使在 sync 包中存在数据竞争,Go 也不会报告它们。

在 Go 中,非原子读取与 atomic.CompareAndSwap 确实会引发数据竞争,但 sync.Mutex 的实现通过特定的内存访问模式避免了这个问题。关键在于 Go 的内存模型和编译器/运行时对竞争检测的处理方式。

你的示例代码确实存在数据竞争,因为对 n 的非原子读取与 CAS 操作并发执行。而 sync.Mutex 的实现之所以没有报告竞争,是因为:

  1. 内存访问模式:在 lockSlow() 中读取 m.state 时,虽然是非原子操作,但此时互斥锁已经处于锁定状态或正在竞争状态,读取的是当前 goroutine 的本地视图。

  2. 竞争检测器的启发式规则:Go 的竞争检测器对某些模式进行了特殊处理,特别是标准库中的同步原语。

  3. 内存屏障和编译器优化sync/atomic 操作包含隐式的内存屏障,影响编译器的优化和内存可见性。

以下是更清晰的示例说明:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

type CustomMutex struct {
	state int32
}

func (m *CustomMutex) Lock() {
	// 快速路径:尝试直接获取锁
	if atomic.CompareAndSwapInt32(&m.state, 0, 1) {
		return
	}
	// 慢速路径:读取当前状态
	old := m.state // 非原子读取,但此时锁已被持有
	fmt.Printf("Current state: %d\n", old)
	// 实际实现会有更复杂的逻辑
}

func main() {
	var mu CustomMutex
	var wg sync.WaitGroup
	
	// 模拟并发访问
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			mu.Lock()
			// 临界区
			atomic.StoreInt32(&mu.state, 0) // 原子释放
			wg.Done()
		}()
	}
	wg.Wait()
}

关键区别在于:

  • sync.MutexlockSlow() 中读取 m.state 时,该值已经通过原子操作(CAS)被修改,且后续操作会重新验证状态
  • 标准库的实现经过了精心设计,确保即使有非原子读取,也不会破坏正确性
  • 竞争检测器对标准库模式有特殊处理

对于用户代码,安全的方法是始终使用原子操作进行并发访问:

// 安全的方式:使用 atomic.Load
func safeRead(addr *int32) int32 {
	return atomic.LoadInt32(addr)
}

// 或者使用 atomic.Value 进行更复杂的类型
var sharedValue atomic.Value

func writer() {
	sharedValue.Store("new value")
}

func reader() {
	val := sharedValue.Load()
	fmt.Println(val)
}

总结:虽然 sync.Mutex 的实现中混合了原子和非原子操作,但这是经过特殊设计和验证的模式。在用户代码中,为避免数据竞争,应始终对共享变量的并发访问使用原子操作或互斥锁保护。

回到顶部