使用原子操作的计数器和使用互斥锁的计数器之间的Go是否有区别?

Hug*_*ugh 6 go

我最近看到一些关于使用原子增量/负载实现的计数器与使用互斥量来同步增量/负载的计数器之间是否存在差异的一些讨论.

以下计数器实现在功能上是否相同?

type Counter interface {
    Inc()
    Load() int64
}

// Atomic Implementation

type AtomicCounter struct {
    counter int64
}

func (c *AtomicCounter) Inc() {
    atomic.AddInt64(&c.counter, 1)
}

func (c *AtomicCounter) Load() int64 {
    return atomic.LoadInt64(&c.counter)
}

// Mutex Implementation

type MutexCounter struct {
    counter int64
    lock    sync.Mutex
}

func (c *MutexCounter) Inc() {
    c.lock.Lock()
    defer c.lock.Unlock()

    c.counter++
}

func (c *MutexCounter) Load() int64 {
    c.lock.Lock()
    defer c.lock.Unlock()

    return c.counter
}
Run Code Online (Sandbox Code Playgroud)

我运行了一堆测试用例(Playground Link)并且无法看到任何不同的行为.在我的机器上运行测试时,所有PrintAll测试功能都会无序打印数字.

有人可以确认它们是否相同或者是否存在不同的边缘情况?是否优先使用一种技术而不是另一种?该原子文档不说,它应该只在特殊情况下使用.

更新: 导致我问这个问题的原始问题就是这个问题,但它现在暂停了,我觉得这方面值得自己讨论.在答案中,似乎使用互斥锁可以保证正确的结果,而原子可能不会,特别是如果程序在多个线程中运行.我的问题是:

  • 他们可以产生不同的结果是否正确?(见下面的更新.答案是肯定的.).
  • 是什么导致这种行为?
  • 这两种方法之间有什么权衡?

另一个更新:

我发现了一些代码,其中两个计数器的行为不同.在我的机器上运行时,此功能将完成MutexCounter,但不是AtomicCounter.不要问我为什么要运行这段代码:

func TestCounter(counter Counter) {
    end := make(chan interface{})

    for i := 0; i < 1000; i++ {
        go func() {
            r := rand.New(rand.NewSource(time.Now().UnixNano()))
            for j := 0; j < 10000; j++ {
                k := int64(r.Uint32())
                if k >= 0 {
                    counter.Inc()
                }
            }
        }()
    }

    go func() {
        prevValue := int64(0)
        for counter.Load() != 10000000 { // Sometimes this condition is never met with AtomicCounter.
            val := counter.Load()
            if val%1000000 == 0 && val != prevValue {
                prevValue = val
            }
        }

        end <- true

        fmt.Println("Count:", counter.Load())
    }()

    <-end
}
Run Code Online (Sandbox Code Playgroud)

kos*_*tix 10

原子在常见情况下更快:编译器将从包中对函数的每个调用转换为sync/atomic一组特殊的机器指令,这些指令基本上在 CPU 级别 \xe2\x80\x94 上运行,例如,在 x86\n 架构上,atomic.AddInt64将是转换为一些ADD以指令为前缀的普通\n类指令LOCK(参见示例)\xe2\x80\x94\n,后者确保系统中所有CPU上更新的内存位置\n的一致视图。

\n\n

互斥体是一件非常复杂的事情,因为它最终包装了本机操作系统特定线程同步 API 的一些位(例如,在 Linux 上futex)。

\n\n

另一方面,当涉及同步内容时,Go 运行时得到了相当多的优化(考虑到 Go 的主要卖点之一,这有点令人期待),并且互斥体实现试图避免如果可能的话,使用内核在 goroutine 之间执行同步,并完全在 Go 运行时本身中执行。

\n\n

如果互斥锁的争用相当低,这可能会解释基准测试中\n的时间没有明显差异。

\n\n
\n\n

尽管如此,我仍然觉得有必要指出 \xe2\x80\x94 以防万一 \xe2\x80\x94 原子和\n更高级别的同步设施旨在解决不同的\n任务。比如说,在一般情况下,在整个函数 \xe2\x80\x94 甚至单个语句的执行过程中,你不能使用原子来保护某些内存状态。

\n


Fli*_*mzy 9

行为没有区别.性能有所不同.

由于设置和拆卸,并且由于它们在锁定期间阻止其他goroutine,因此互斥体很慢.

原子操作很快,因为它们使用原子CPU指令,而不是依赖外部锁.

因此,只要可行,原子操作应该是首选.


Hug*_*ugh 5

好吧,我将尝试自我解答以解决问题。欢迎进行编辑。

有一个关于原子包一些讨论在这里。但是引用最有说服力的评论:

简短的摘要是,如果您必须询问,则应避免使用该包装。或者,阅读C ++ 11标准的“原子操作”一章;如果您了解如何在C ++中安全地使用这些操作,那么您将不仅仅可以使用Go的sync/atomic 软件包。

就是说,只要您只是报告统计信息即可,坚持atomic.AddInt32并保持atomic.LoadInt32安全,并且实际上不依赖于带有不同goroutine状态的任何含义的值。

和:

原子性不能保证的是值的可观察性的任何顺序。我的意思是,atomic.AddInt32()仅保证此操作存储在&cnt上的内容是准确的*cnt + 1(值*cnt是当操作开始时执行从内存中获取活动goroutine的CPU 的值);它不提供任何保证,如果另一个goroutine将尝试同时读取此值,则它将获取该相同的值*cnt + 1

另一方面,互斥锁和通道可确保对共享/传递的值的访问进行严格排序(取决于Go内存模型的规则)。

关于问题中的代码示例为何从未完成的原因,这是由于func正在读取计数器的代码处于非常紧密的循环中。使用原子计数器时,没有同步事件(例如,mutex锁定/解锁,系统调用),这意味着goroutine永远不会产生控制权。这样做的结果是,此goroutine使正在运行的线程饿死,并阻止调度程序将时间分配给分配给该线程的任何其他goroutine,这包括使计数器递增的计数器,这意味着计数器永远不会达到10000000。