MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go语言atomic包提供的内存同步原语概述

2022-06-022.8k 阅读

一、atomic包简介

在Go语言中,atomic包提供了一系列函数,用于实现原子操作。原子操作是不可中断的操作,在多线程或多协程环境下,它能确保操作的完整性,避免数据竞争(data race)问题。数据竞争通常发生在多个并发执行的线程或协程同时读写共享变量,而没有适当的同步机制时。这种情况下,程序的行为是未定义的,可能导致难以调试的错误。atomic包通过提供内存同步原语,使得开发者能够在底层硬件支持的情况下,以原子方式对共享变量进行操作,从而避免数据竞争。

二、原子操作类型

  1. 整数类型原子操作
    • AddInt32AddInt64:这两个函数分别用于对int32int64类型的变量进行原子加法操作。例如:
package main

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

func main() {
    var num int64
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt64(&num, 1)
        }()
    }
    wg.Wait()
    fmt.Println("Final value:", num)
}

在这个例子中,我们创建了10个协程,每个协程对num变量执行原子加法操作。由于AddInt64是原子操作,即使多个协程同时执行,也能确保num的正确累加,最终输出Final value: 10。 - LoadInt32LoadInt64:用于原子地读取int32int64类型变量的值。例如:

package main

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

func main() {
    var num int64 = 42
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            value := atomic.LoadInt64(&num)
            fmt.Println("Loaded value:", value)
        }()
    }
    wg.Wait()
}

这里,多个协程原子地读取num的值并打印。由于LoadInt64的原子性,读取操作不会受到其他协程对num修改的干扰。 - StoreInt32StoreInt64:用于原子地存储int32int64类型变量的值。例如:

package main

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

func main() {
    var num int64
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(val int64) {
            defer wg.Done()
            atomic.StoreInt64(&num, val)
        }(int64(i))
    }
    wg.Wait()
    fmt.Println("Final stored value:", atomic.LoadInt64(&num))
}

在这个例子中,不同协程原子地存储不同的值到num,最终打印出的是最后一次存储的值。 - SwapInt32SwapInt64:原子地交换int32int64类型变量的旧值和新值,并返回旧值。例如:

package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var num int64 = 10
    oldValue := atomic.SwapInt64(&num, 20)
    fmt.Println("Old value:", oldValue)
    fmt.Println("New value:", num)
}

这里,SwapInt64函数将num的值从10交换为20,并返回旧值10。 - CompareAndSwapInt32CompareAndSwapInt64:简称CAS操作,只有当*addr的值等于old时,才将其设置为new,并返回是否成功的布尔值。例如:

package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var num int64 = 10
    success := atomic.CompareAndSwapInt64(&num, 10, 20)
    if success {
        fmt.Println("Compare and swap successful. New value:", num)
    } else {
        fmt.Println("Compare and swap failed.")
    }
}

在这个例子中,由于num的初始值为10,CompareAndSwapInt64操作成功,num的值被更新为20。

  1. 指针类型原子操作
    • LoadPointer:原子地加载指针类型变量的值。例如:
package main

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

func main() {
    var ptr *int
    num := 42
    ptr = &num
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            loadedPtr := atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)))
            if loadedPtr != nil {
                value := *(*int)(loadedPtr)
                fmt.Println("Loaded value:", value)
            }
        }()
    }
    wg.Wait()
}

这里需要注意,由于atomic.LoadPointer返回的是unsafe.Pointer类型,需要进行适当的类型转换才能获取到实际指向的值。 - StorePointer:原子地存储指针类型变量的值。例如:

package main

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

func main() {
    var ptr *int
    var wg sync.WaitGroup
    num1 := 10
    num2 := 20
    for i := 0; i < 2; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            if i == 0 {
                atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)), unsafe.Pointer(&num1))
            } else {
                atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)), unsafe.Pointer(&num2))
            }
        }()
    }
    wg.Wait()
    if ptr != nil {
        fmt.Println("Final value:", *ptr)
    }
}

在这个例子中,不同协程原子地存储不同的指针值到ptr。 - SwapPointer:原子地交换指针类型变量的旧值和新值,并返回旧值。例如:

package main

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

func main() {
    var ptr *int
    num1 := 10
    num2 := 20
    oldPtr := atomic.SwapPointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)), unsafe.Pointer(&num1))
    if oldPtr != nil {
        oldValue := *(*int)(oldPtr)
        fmt.Println("Old value:", oldValue)
    }
    fmt.Println("New value:", *(*int)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)))))
    newOldPtr := atomic.SwapPointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)), unsafe.Pointer(&num2))
    if newOldPtr != nil {
        newOldValue := *(*int)(newOldPtr)
        fmt.Println("New old value:", newOldValue)
    }
    fmt.Println("Newest value:", *(*int)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)))))
}

此例展示了SwapPointer的多次使用,每次交换指针并获取旧指针指向的值。 - CompareAndSwapPointer:只有当*addr的值等于old时,才将其设置为new,并返回是否成功的布尔值。例如:

package main

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

func main() {
    var ptr *int
    num1 := 10
    num2 := 20
    atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)), unsafe.Pointer(&num1))
    success := atomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)), unsafe.Pointer(&num1), unsafe.Pointer(&num2))
    if success {
        fmt.Println("Compare and swap pointer successful. New value:", *(*int)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&ptr)))))
    } else {
        fmt.Println("Compare and swap pointer failed.")
    }
}

这里,CompareAndSwapPointer操作成功将ptr从指向num1改为指向num2

  1. uintptr类型原子操作 uintptr是一种无符号整数类型,通常用于存储指针值。atomic包中提供了与整数类型类似的uintptr原子操作函数,如AddUintptrLoadUintptrStoreUintptrSwapUintptrCompareAndSwapUintptr。其使用方式与整数类型原子操作类似,例如AddUintptr的使用:
package main

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

func main() {
    var num uintptr = 10
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddUintptr(&num, 5)
        }()
    }
    wg.Wait()
    fmt.Println("Final value:", num)
}

此例中,多个协程对num进行原子加法操作,每次加5。

三、内存同步语义

  1. 顺序一致性(Sequential Consistency) Go语言的atomic包默认提供顺序一致性的内存模型。顺序一致性意味着所有的原子操作在所有的处理器上看起来都是以相同的顺序发生的。这使得并发程序的行为更容易理解和预测。例如,考虑以下代码:
package main

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

func main() {
    var a, b int64
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        atomic.StoreInt64(&a, 1)
        atomic.StoreInt64(&b, 2)
        wg.Done()
    }()
    go func() {
        for atomic.LoadInt64(&b) == 0 {
        }
        value := atomic.LoadInt64(&a)
        fmt.Println("Loaded value of a:", value)
        wg.Done()
    }()
    wg.Wait()
}

在这个例子中,第二个协程会一直等待b的值变为非零,然后读取a的值。由于顺序一致性,一旦b被设置为2,a必然已经被设置为1,所以最终会打印Loaded value of a: 1

  1. 释放 - 获得语义(Release - Acquire Semantics) 虽然Go语言的atomic包默认提供顺序一致性,但在某些情况下,释放 - 获得语义可以提供更高效的实现。释放操作(如Store系列函数)会将缓存中的修改刷新到内存,而获得操作(如Load系列函数)会从内存中读取最新的值。例如:
package main

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

func main() {
    var flag int32
    var data int64
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        data = 42
        atomic.StoreInt32(&flag, 1)
        wg.Done()
    }()
    go func() {
        for atomic.LoadInt32(&flag) == 0 {
        }
        value := atomic.LoadInt64(&data)
        fmt.Println("Loaded value of data:", value)
        wg.Done()
    }()
    wg.Wait()
}

在这个例子中,第一个协程先设置data的值,然后进行StoreInt32释放操作,第二个协程通过LoadInt32获得操作等待flag被设置,然后读取data的值。由于释放 - 获得语义,能确保第二个协程读取到data的正确值42。

四、原子操作与锁的比较

  1. 性能方面 在高并发场景下,原子操作通常比锁更高效。锁会导致线程或协程的阻塞,而原子操作是在硬件层面实现的,不需要上下文切换。例如,对于简单的计数器操作,使用原子操作:
package main

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

func main() {
    var num int64
    var wg sync.WaitGroup
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt64(&num, 1)
        }()
    }
    wg.Wait()
    fmt.Println("Final value:", num)
}

如果使用互斥锁实现同样的功能:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var num int
    var mu sync.Mutex
    var wg sync.WaitGroup
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            num++
            mu.Unlock()
        }()
    }
    wg.Wait()
    fmt.Println("Final value:", num)
}

在高并发情况下,原子操作的性能优势会更加明显,因为它避免了锁带来的线程阻塞和上下文切换开销。

  1. 适用场景方面 原子操作适用于简单的数据类型,如整数、指针等,并且操作通常是单一的,如加法、比较交换等。而锁适用于更复杂的场景,例如需要保护一段代码块,其中可能涉及多个相关的操作。例如,在实现一个线程安全的链表时,可能需要使用锁来保护链表的插入、删除等操作,因为这些操作涉及多个指针的修改,单纯的原子操作难以满足需求。

五、使用atomic包的注意事项

  1. 数据类型匹配 在使用atomic包的函数时,必须确保操作的数据类型与函数所期望的类型完全匹配。例如,不能将int类型的变量传递给atomic.AddInt32函数,即使在某些系统上intint32的大小可能相同。这是因为不同的系统架构可能对数据类型的大小有不同的定义,使用不匹配的类型可能导致未定义行为。

  2. 避免不必要的原子操作 虽然原子操作在高并发场景下有性能优势,但在单线程环境或不需要并发访问的情况下,使用原子操作会增加不必要的开销。例如,在一个只在单个协程中使用的变量上使用原子操作是没有意义的,直接进行常规的读写操作即可。

  3. 与其他同步机制的结合使用 在复杂的并发场景中,atomic包提供的原子操作可能不足以满足所有需求,可能需要与其他同步机制(如互斥锁、条件变量等)结合使用。例如,在实现一个线程安全的队列时,除了使用原子操作来保证队列头部和尾部指针的更新安全,还可能需要使用互斥锁来保护队列的整体状态,以及条件变量来处理队列空或满的情况。

  4. 理解内存模型 在使用atomic包时,深入理解Go语言的内存模型是非常重要的。不同的原子操作具有不同的内存同步语义,如顺序一致性、释放 - 获得语义等。不理解这些语义可能导致在并发编程中出现难以调试的错误。例如,在编写涉及多个原子操作的复杂逻辑时,必须清楚这些操作之间的内存同步关系,以确保程序的正确性。

通过合理使用atomic包提供的内存同步原语,开发者能够有效地处理并发编程中的数据竞争问题,提高程序的性能和可靠性。同时,在使用过程中要注意各种细节和适用场景,结合其他同步机制,编写出高效、健壮的并发程序。