【问题标题】:How to implement "i++ and i>=max ? 0: i" that only use atomic in Go如何在 Go 中实现仅使用原子的“i++ and i>=max ? 0: i”
【发布时间】:2018-09-12 19:25:35
【问题描述】:

只使用原子实现以下代码:

const Max = 8
var index int
func add() int {
    index++
    if index >= Max {
        index = 0
    }
    return index
}

例如:

func add() int {
    atomic.AddUint32(&index, 1)
    // error: race condition 
    atomic.CompareAndSwapUint32(&index, Max, 0)
    return index
}

但这是错误的。有一个竞争条件。 不使用锁可以实现吗?

【问题讨论】:

  • 你需要一个 CAS(比较和交换)循环
  • @Ivan 你能详细说明一下吗?问题中的实现已经使用了CAS,能解释一下CAS循环的概念吗?
  • 您的种族不在原子操作中,请查看种族检测器向您显示的确切位置。您正在阅读 return 语句中的 index 值。
  • @mhutter 我真的不知道 Go,我的 atm 也没有编译器。我只能在伪代码或 C(或 C++)中显示使用示例。它可能会在一些帮助下转换为 Go。

标签: go locking atomic cas


【解决方案1】:

在没有循环和锁的情况下解决它

一个简单的实现可能如下所示:

const Max = 8

var index int64

func Inc() int64 {
    value := atomic.AddInt64(&index, 1)
    if value < Max {
        return value // We're done
    }

    // Must normalize, optionally reset:
    value %= Max
    if value == 0 {
        atomic.AddInt64(&index, -Max) // Reset
    }
    return value
}

它是如何工作的?

它只是将计数器加 1; atomic.AddInt64() 返回新值。如果小于Max,“我们完成了”,我们可以返回它。

如果它大于或等于Max,那么我们必须标准化这个值(确保它在[0..Max)的范围内)并重置计数器。

重置只能由单个调用者(单个 goroutine)完成,它将由计数器的值选择。获胜者将是使计数器到达Max的人。

避免需要锁的技巧是通过添加-Max 来重置它,而不是通过将其设置为0。由于计数器的值是标准化的,所以如果其他 goroutine 调用它并同时递增它,它不会造成任何问题。

当然,有许多 goroutine 同时调用这个 Inc(),在应该重置它的 goroutine 实际执行重置之前,计数器可能会增加更多 Max 倍,这将导致计数器达到或超过2 * Max 甚至3 * Max(一般为:n * Max)。因此,我们通过使用value % Max == 0 条件来决定是否应该进行重置来处理这个问题,对于n 的每个可能值,这只会在单个goroutine 上发生。

简化

请注意,标准化不会更改 [0..Max) 范围内的值,因此您可以选择始终执行标准化。如果你愿意,你可以把它简化成这样:

func Inc() int64 {
    value := atomic.AddInt64(&index, 1) % Max
    if value == 0 {
        atomic.AddInt64(&index, -Max) // Reset
    }
    return value
}

读取计数器而不增加它

不应直接访问index 变量。如果需要读取计数器的当前值而不增加它,可以使用以下函数:

func Get() int64 {
    return atomic.LoadInt64(&index) % Max
}

极端情况

让我们分析一个“极端”的场景。在此,Inc() 被调用 7 次,返回数字 1..7。现在在增量之后对Inc() 的下一次调用将看到计数器位于8 = Max。然后它将值标准化为0 并希望重置计数器。现在假设在实际执行重置(即添加-8)之前,发生了8 个其他调用。他们将计数器递增 8 次,最后一次将再次看到计数器的值是 16 = 2 * Max。所有调用都会将值标准化到0..7 范围内,最后一次调用将再次继续执行重置。假设此重置再次延迟(例如出于调度原因),还有另外 8 个调用进入。最后,计数器的值将是 24 = 3 * Max,最后一次调用将继续执行重置。

请注意,所有调用都只会返回[0..Max) 范围内的值。一旦执行了所有重置操作,计数器的值将正确地为0,因为它的值是24,并且有3个“挂起”的重置操作。在实践中,这种情况发生的可能性很小,但这个解决方案可以很好地有效地处理它。

【讨论】:

  • @Ivan 这些都不是问题。无法读取index 变量,只有Inc() 的返回值有效,它始终在[0..Max) 的范围内。我的解决方案不是建立在 Max 的基础上,不涉及位操作,只有一个余数 % 可用于“任何”数字。
  • 是的,原始代码没有提到index 是否应该永远等于Max,但是非原子代码不会让它超出范围。如果其他线程读取此变量,他们可能会看到“无效”值。
  • @Ivan 其他 goroutine 无法在没有同步的情况下读取 index,这通常是 Go 中未定义的行为。如果需要读取index 而不增加它,这将是一个简单的LoadInt64() % Max 操作。
  • 目前还不清楚其他 goroutine 是否知道是否应该执行 % Max。我的观点是它不等同于非原子代码。在我看来,这项任务的重点是学习如何使用 CAS 循环来实现更复杂的原子操作。
  • @Ivan 是的,这个解决方案确实不完全相同,尽管它以更清洁、更有效的方式解决了原始问题。无论如何,其他 goroutines 不应该与index 混淆,如果需要,可以提供一个导出函数来读取并返回其值。
【解决方案2】:

我假设您的目标是永远不要让index 的值等于或大于Max。这可以使用 CAS (Compare-And-Swap) 循环来解决:

const Max = 8
var index int32

func add() int32 {
    var next int32;

    for {
        prev := atomic.LoadInt32(&index)
        next = prev + 1;

        if next >= Max {
            next = 0
        }

        if (atomic.CompareAndSwapInt32(&index, prev, next)) {
            break;
        }
    }

    return next
}

CAS 可用于像这样以原子方式实现几乎任何操作。算法是:

  1. 加载值
  2. 执行所需的操作
  3. 使用 CAS,失败时转到 1。

【讨论】:

  • LoadInt32(&index) 如果index是int32,是否可以直接使用prev:=index。
  • @wllenyj LoadInt32 需要用于保证排序,正常分配可能不起作用,具体取决于编译器和处理器架构。
猜你喜欢
  • 2011-11-07
  • 2017-01-12
  • 1970-01-01
  • 1970-01-01
  • 2012-09-07
  • 1970-01-01
  • 1970-01-01
  • 2022-12-17
  • 2011-07-27
相关资源
最近更新 更多