Go 1.19 中的原子指针

img

在计算机编程中,”原子”是指一次执行一个操作。Objective-C有原子属性,它确保了从不同的线程对一个属性进行安全的读写。在Objective-C中,它是与不可变类型一起使用的。这是因为为了改变不可变类型,实际上是 “重新创建”它。换句话说,在你的代码中改变一个不可变的类型不会导致编译器抛出一个错误。然而,当你这样做的时候,它会实例化一个新的对象。一个典型的例子是Goappend函数,它每次调用都会产生一个新的切片。在Objective-C中,原子属性将确保操作是一个接一个进行的,以防止线程同时访问一个内存地址。由于Go是多线程的,它也支持原子操作。Go 1.19引入了新的原子类型。我最喜欢的新增类型是atomic.Pointer,它为atomic.Value提供了一个平滑的替代方案。它也很好地展示了泛型是如何增强开发者体验的。

atomic.Pointer 原子指针

atomic.Pointer 是一个通用类型。与Value不同,它不需要断言你的存储值就可以访问。下面是一段定义和存储指针的代码:

  1. package main
  2. import (
  3. "fmt"
  4. "net"
  5. "sync/atomic"
  6. )
  7. type ServerConn struct {
  8. Connection net.Conn
  9. ID string
  10. Open bool
  11. }
  12. func main() {
  13. p := atomic.Pointer[ServerConn]{}
  14. s := ServerConn{ ID : "first_conn"}
  15. p.Store( &s )
  16. fmt.Println(p.Load()) // Will display value stored.
  17. }

将变量p实例化为一个指针结构字面量,然后将变量s的指针存储在p中,s代表一个服务器连接。至此,我们已经通过了实现原子性的第一步。通过将变量存储为原子值,我们将确保没有同时访问内存地址的情况。例如,如果同时并行读取和写入map,将导致程序恐慌。锁是防止这些恐慌发生的一个好方法,原子操作也是如此。

关于原子指针的使用示例

在之前提供的代码基础上,我将使用一个atomic.Pointer来每13秒重新创建一个数据库连接。首先编写一个函数,用来记录每10秒的连接ID。这将是查看新连接对象是否被传播的机制。然后,将定义一个内联函数,每13秒改变一次连接。下面是代码的样子:

  1. ...
  2. func ShowConnection(p * atomic.Pointer[ServerConn]){
  3. for {
  4. time.Sleep(10 * time.Second)
  5. fmt.Println(p, p.Load())
  6. }
  7. }
  8. func main() {
  9. c := make(chan bool)
  10. p := atomic.Pointer[ServerConn]{}
  11. s := ServerConn{ ID : "first_conn"}
  12. p.Store( &s )
  13. go ShowConnection(&p)
  14. go func(){
  15. for {
  16. time.Sleep(13 * time.Second)
  17. newConn := ServerConn{ ID : "new_conn"}
  18. p.Swap(&newConn)
  19. }
  20. }()
  21. <- c
  22. }

ShowConnection是作为一个Goroutine调用,内联函数将实例化一个新的ServerConn对象,并将其与当前连接对象交换。这在指针上是可行的,但是,这需要实现一个 “锁定-解锁 “系统。atomic包对此进行了抽象,并确保每个加载和保存都是一个接一个地处理。这是一个简单的例子,也是一个不那么常见的用例。另外,使用atomic.Pointer可能是一个 “过度工程”的案例,因为我程序的Goroutines是在不同时间段运行的。我将使用Go的race标志来查看我的程序的Goroutines是否在同一时间访问同一个内存地址。下面,将使用指针方式重写上述代码,而不是atomic.Pointer方式。

数据竞争

“当两个Goroutine同时访问同一个变量,并且至少有一个访问是写的时候,就会发生数据竞争”。为了快速验证数据竞赛,你可以执行go run,加上标志race参数来进行测试。为了演示原子类型如何防止这种情况,我们来重写上面的例子,使用经典的Go指针。下面是代码的样子:

  1. package main
  2. import (
  3. "fmt"
  4. "net"
  5. "time"
  6. )
  7. type ServerConn struct {
  8. Connection net.Conn
  9. ID string
  10. Open bool
  11. }
  12. func ShowConnection(p * ServerConn){
  13. for {
  14. time.Sleep(10 * time.Second)
  15. fmt.Println(p, *p)
  16. }
  17. }
  18. func main() {
  19. c := make(chan bool)
  20. p := ServerConn{ ID : "first_conn"}
  21. go ShowConnection(&p)
  22. go func(){
  23. for {
  24. time.Sleep(13 * time.Second)
  25. newConn := ServerConn{ ID : "new_conn"}
  26. p = newConn
  27. }
  28. }()
  29. <- c
  30. }

在检查了数据竞争后,终端上的输出是这样的:

  1. ~/go/src/atomic$ go run -race main_classic.go
  2. &{<nil> first_conn false} {<nil> first_conn false}
  3. ==================
  4. WARNING: DATA RACE
  5. Write at 0x00c000074570 by `Goroutine` 8:
  6. main.main.func1()
  7. /home/cheikh/go/src/atomic/main_classic.go:37 +0x6fPrevious read at 0x00c000074570 by `Goroutine` 7:
  8. runtime.convT()
  9. /usr/lib/go-1.18/src/runtime/iface.go:321 +0x0
  10. main.ShowConnection()
  11. /home/cheikh/go/src/atomic/main_classic.go:19 +0x65
  12. main.main.func2()
  13. /home/cheikh/go/src/atomic/main_classic.go:30 +0x39`Goroutine` 8 (running) created at:
  14. main.main()
  15. /home/cheikh/go/src/atomic/main_classic.go:33 +0x16e`Goroutine` 7 (running) created at:
  16. main.main()
  17. /home/cheikh/go/src/atomic/main_classic.go:30 +0x104
  18. ==================
  19. &{<nil> new_conn false} {<nil> new_conn false}
  20. &{<nil> new_conn false} {<nil> new_conn false}
  21. &{<nil> new_conn false} {<nil> new_conn false}

虽然这两个函数在不同的时间间隔运行,但它们在某些时候会发生碰撞(译者注:处理耗时变化,会导致可能退化为并行读写)。有原子指针的代码没有返回关于数据竞争的反馈。这是一个例子,说明原子指针在多线程环境中表现得更好。

总结

Go原子类型是管理共享资源的一种简单方法。它消除了不断实现互斥来控制资源访问的需要。这并不意味着mutex已经过时了,因为在某些操作中仍然需要它们。总之,atomic.Pointer是将原子内存原语引入你的程序的一个好方法。它是一个简单防止数据竞争的方法,而不需要花哨的互斥代码。访问这个链接,可以看到这篇文章中使用的代码。