Go并发原子操作 waitGroup 对象池

 更新时间:2026年04月08日 09:22:00   作者:念何架构之路  
本文详细介绍了Go语言中原子操作和并发同步工具的使用,文章通过代码示例详细说明了这些并发工具的正确使用方式,并分析了它们的实现原理和性能特点,下面就来详细的介绍一下

原子操作:

原子操作即执行过程中不可被中断的操作.在针对某个值的原子操作执行过程当中.CPU绝不会再去执行其它针对该值的操作.无论这些操作是否为原子操作.

Go语言提供的原子操作都是非侵入式的.它们由标准库代码包sync/atomic中的众多函数代表.可以通过调用这些函数对几种简单类型执行原子操作.

增或减:

用于增或减的原子操作(以下简称原子增或减操作)的函数名称都以Add为前缀.后跟针对具体类型的名称.

示例:

func main() {
    var a int32 = 1
    var b int64 = 1
    int32Value := atomic.AddInt32(&a, 1)
    int64Value := atomic.AddInt64(&b, 1)
    fmt.Println(int32Value)
    fmt.Println(int64Value)
    reduceInt32 := atomic.AddInt32(&a, -1)
    reduceInt64 := atomic.AddInt64(&b, -1)
    fmt.Println(reduceInt32)
    fmt.Println(reduceInt64)
}

执行结果:

比较并交换:

比较并交换即"Compare And Swap"简称CAS.在sync/atomic包中.这类原子操作由名称以"CompareAndSwap"为前缀的若干函数代表.

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

函数接受三个参数.第一个参数的值是指向被操作的指针值.该值的类型是*int32.后两个参数的类型也是32.分别代表被操作的旧值和新值.函数在被调用后.会先判断参数addr的指向和旧值old的值是否相等.当判断成功后.才会用新值替换旧值.否则.后面的替换就会被忽略.

与前面的锁相比.CAS操作有明显不同.它总是假设被操作值未曾改变(即与旧值相等).并一旦确认这个假设的真实性就立即进行值替换.使用锁则是更加谨慎的做法.总是先假设会有并发操作会修改被操作值.并需要使用锁将相关操作放入临界区加以保护.可以说.使用锁的做法趋于悲观.而CAS操作的做法比较乐观.

CAS操作的优势是.可以在不创建互斥量和不形成临界区的情况下.完成并发安全的值替换操作.可以大大减少同步对程序性能的损耗.当然,CAS也有劣势.是被操作值被频繁变更的情况下.CAS操作并不那么容易成功.有时候.可能不得不利用for循环来进行多次尝试.

示例:

func main() {
    var value int32
    for i := 0; i < 10; i++ {
       go func() {
          for {
             v := value
             if atomic.CompareAndSwapInt32(&v, 0, 1) {
                break
             }
          }
       }()
    }
}

从上面例子可以看出.只有保证CAS操作成功后.for循环才会结束退出循环.CAS操作虽然不会阻塞goroutine.但是会不断自旋造成性能上的消耗.

载入:

前面展示的for循环中.使用语句v:=value为变量v赋值.要注意.在读取value的过程中.并不能保证没有对此值的并发读写操作.为了原子的读取某个值.sync/atomic代码包也提供了一系列函数.这些函数的名称都以"load"(意为载入)为前缀.

示例:

func main() {
    var value int32
    for i := 0; i < 10; i++ {
       go func() {
          for {
             v := atomic.LoadInt32(&value)
             if atomic.CompareAndSwapInt32(&v, 0, 1) {
                break
             }
          }
       }()
    }
}

函数atomic.LoadInt32接受一个*int32类型的指针值.并会返回该指针值指向的那个值.

注意:虽然这里使用了atomic.LoadInt32函数原子的载入value的值.后面的CAS操作仍然是有必要的.因为.赋值语句后面的if语句并不会原子执行.在它们执行期间.CPU仍然可能执行其它针对Value的操作.

存储:

与读操作相对应的是写入操作.而sync/atomic包也提供了对应的存储函数.这些函数的名称均以"Store"为前缀.

在原子的存储某个值的过程中.任何CPU都不会进行针对同一个值的读写操作.如果把所有针对此值的写操作都改为原子操作.就绝不会出现针对此值的读操作因被并发的进行.而读到修改了一半的值的情况.

原子的值存储操作总会成功.因为它并不关系操作的旧值是什么.这与前面的CAS操作有明显区别.

函数atomic.StoreInt32会接受两个参数.第一个参数的类型是*int32.它同样是指向被操作值的指针值.第二个参数是int32类型.它的值是欲存储的新值.

交换:

在sync/atomic代码包中还有一些函数.它们的功能与前文所讲的CAS操作和原子载入操作都有相似之处.这里的功能可以称为原子交换操作.这类函数的名称都以"Swap"为前缀.

与CAS操作不同.原子操作不会关心被操作的旧值.而是直接设置新值.但它又比原子存储操作多做了一步.它会返回被操作的旧值.此类操作比CAS操作的约束更少.同时又比原子载入操作的功能更强.

func SwapInt32(addr *int32, new int32) (old int32)

从函数可知.它接受两个参数.其中第一个参数代表了被操作的内存地址的*int32类型值.第二个参数用来表示新值.注意.该函数是有结果值的.该值即被新值替换掉的旧值.函数被调用后.会把第二个参数的值置于第一个参数值所代表的的内存地址上.并将之前在该地址上的那个值作为结果返回.

原子值:

sync/atomic.Value是一个结构体类型.暂且称为原子类型.它用于存储需要原子读写的值.与sync/atomic包中的其它函数不同.sync/atomic.Value可接受的被操作值的类型不限.

该类型有两个公开的指针方法Load和Store.前者用于原子的读取原子值实例中存储的值.它会返回一个interface{}类型的结果且不接受任何参数.后者用于原子的在原子实例中存储一个值.它接受一个interface{}类型的参数而没有任何结果.在未曾通过Store方法向原子值实例存储值之前.它的Load方法总会返回nil.

对于原子值实例的Store方法有两个限制.第一.作为参数传入该方法的值不能为nil.第二.作为参数传入该方法的值必须与之前传入的值(如果有的话)的类型相同.一旦原子值实例存储了某一个类型的值.那么它之后存储的值就必须是该类型的.如果违反了任意一个限制.对该方法的调用都会引发一个运行是恐慌.

严格来说.sync/atomic.Value类型的变量一旦声明.其值就不应该被复制到它处.作为源值赋给别的变量 作为参数值传入参数 作为结果值从函数返回 作为元素值通过通道传递等都会造成值的复制.所以这类变量之上不应该实施这些操作.虽然编译不会错误.但Go标准工具go vet会报告此类不正确(或说有安全隐患).不过原子类型不会有这个问题.根本原因.对结构体值的复制不但会生成该值的副本.还会生成其中字段的副本.这样一来.本应施加于此的并发安全也就失效了.向副本存储值的操作也与原值无关了.

示例:

func main() {
    var countVal atomic.Value
    countVal.Store([]int{1, 3, 5, 7})
    anotherStore(countVal)
    fmt.Printf("The count value: %v\n", countVal.Load())
}

func anotherStore(countVal atomic.Value) {
    countVal.Store([]int{2, 4, 6, 8})
}

执行结果:

使用场景:

type ConcurrentArray interface {
    //设置指定索引上的元素值.
    Set(index uint32,elem int) (err error)
    //用于获取指定索引元素的值.
    Get(index uint32) (elem int, err error)
    //用于获取元素的长度.
    Len() uint32
}

接口实现:

type concurrentArray struct {
    length uint32
    val atomic.Value
}

创建函数:

func NewConcurrentArray(length uint32) ConcurrentArray {
    array := concurrentArray{}
    array.length = length
    array.val.Store(make([]int, array.length))
    return &array
}

接口实现方法:

func (array *concurrentArray) Len() uint32 {
    return array.length
}

func (array *concurrentArray) Set(index uint32, elem int) (err error) {
    newArray := make([]int, array.length)
    copy(newArray, array.val.Load().([]int))
    newArray[index] = elem
    array.val.Store(newArray)
    return
}

func (array *concurrentArray) Get(index uint32) (elem int, err error) {
    elem = array.val.Load().([]int)[index]
    return elem,nil
}

只执行一次:

与互斥锁和读写锁一样,sync.Once也是开箱即用.

var once sync.Once

once.Do(func() {fmt.Println("Once")})

声明了一个名为once的sync.Once类型的变量.然后用执行它的Do方法.Do方法接受一个无参数 无结果的函数值作为参数.该方法一旦被调用.就会调用作为参数的那个函数.对同一个sync.Once类型值的Do方法的有效调用次数永远是一次.

示例:

func main() {
    var count int
    var once sync.Once
    max := rand.Intn(100)
    for i := 0; i < max; i++ {
       once.Do(func() {
          count++
       })
    }
    fmt.Printf("Count: %d\n", count)
}

waitGroup:

sync.WAitGroup类型的值是并发安全的.也是开箱即用的.在声明中var wg sync.WaitGroup之后.就可以直接使用wg变量了.该类型有三个方法.即Add Done和wait.

func main() {
    var wg sync.WaitGroup
    wg.Add(3)
    for i := 0; i < 3; i++ {
       go func() {
          fmt.Printf("协程:%d执行了.\n", i)
          time.Sleep(1 * time.Second)
          wg.Done()
       }()
    }
    fmt.Println("等待协程开始执行")
    wg.Wait()
    fmt.Println("所有协程都执行完了.结束主线程")
}

使用规则:

1).对一个sync.WaitGroup类型值的Add方法的第一次调用.发生在调用该值的Done之前.

2).对一个sync.WaitGroup类型值的Add方法的第一次调用.同样发生在调用该值的wait方法之前.

3).在一个sync.WaitGroup类型值的生命周期内.其中的给定计数总是由起初的0变为某个正整数(或先后变为某几个正整数).然后在回归为0.完成这样的变化曲线所用的时间称为一个计数周期.

4).给定计数的每次变化都是由对Add方法或Done方法的调用引起的.一个计数的周期总是从Add方法调用开始的.并且也总是以对Add方法或Done方法的调用为结束标志.在一个计数周期之内调用wait方法.就会使调用所在的goroutine阻塞.直到该计数周期结束的那一刻.

5).sync.WaitGroup类型值是可以复用的.此类值的生命周期可以包含任意个计数周期.一旦一个计数周期结束.在前面对该值的方法调用所产生的作用就会消失.它们不会影响该值的后续计数周期.一个sync.WaitGroup类型值在其每个计数周期中的状态和作用都是独立的.

临时对象池:

sync.Pool类型值可以看作存放临时值的容器.此类容器是自动伸缩的 高效的.同时也是并发安全的.为了描述方便.把sync.Pool类型的值称为"临时对象池".而把存于其中的值称为"对象值".

在用复合字面量初始化一个临时对象池的时候.可以为它唯一的公开字段New赋值.该字段的类型是func() interface{}.即一个函数类型.赋给该字段的函数会被临时对象池用来创建对象值.不过.该函数一般仅在池中无可用对象值的时候才被调用..把这个函数称为"对象值生成函数".

sync.Pool类型由两个公开的指针方法:Get和Put.前者的功能是从池中获取一个interface{}类型的值.后者的作用则是把一个interface{}类型的值放置于池中.

通过Get方法获取到的值是任意的.如果一个临时对象池的Put方法从未被调用过.且它的New字段也未曾被赋予一个非null的函数值.那么它的Get方法返回的结果就一定会是nil.临时对象池在功能上与一个通用的缓存池有一些相似.实际上.临时对象池本身的特性决定了它是一个很独特的同步工具.

特性:

1).临时对象池可以把由其中的对象值产生的存储压力进行分摊.更进一步说.它会专门为每一个与操作它的goroutine相关联的P建立本地池.在临时对象池的Get方法被调用时.它一般会先尝试从与本地P对应的那个本地私有池和本地共享池中获取一个对象池.如果获取失败.它就会尝试从其它P的本地共享池中偷一个对象值并直接返回给调用方.如果依然未果.它就只能把希望寄托于当前临时对象池的对象生成函数了.注意.这个对象值生成函数生成的对象永远不会被放置到池中.而是会被直接返回给调用方.另一方面.临时对象池的Put方法会把它的参数值存放到本地P的本地池中.每个相关的P的本地共享池中所有对象值.都是在当前临时对象池的范围内共享的.也就是说.它们随时可能被偷走.

2).对垃圾回收友好.垃圾回收的执行一般会使临时对象池中的对象值全部移除.也就是说.即使永远不会显示的从临时对象池中取走某个对象值.该对象值也不会永远待在临时对象池中.它的生命周期取决于垃圾回收任务的下一次执行时间.

func main() {
    // 1. 保存原始GC配置,函数结束后恢复(正确写法)
    originalGC := debug.SetGCPercent(-1)
    defer debug.SetGCPercent(originalGC)

    var count int32
    // 统一类型:全部存储 int32 类型
    newFunc := func() interface{} {
       return atomic.AddInt32(&count, 1)
    }
    pool := sync.Pool{New: newFunc}

    // 池空,调用New函数
    v1 := pool.Get()
    fmt.Printf("Value 1: %v\n", v1)

    // 存入与New函数返回值 相同类型 的对象(int32)
    pool.Put(int32(10))
    pool.Put(int32(11))
    pool.Put(int32(12))
    v2 := pool.Get()
    fmt.Printf("Value 2: %v\n", v2)

    // 手动触发GC,Pool缓存会被回收
    debug.SetGCPercent(100)
    runtime.GC()

    // GC后缓存清空,再次调用New
    v3 := pool.Get()
    fmt.Printf("Value 3: %v\n", v3)

    // 清空New函数
    pool.New = nil
    v4 := pool.Get()
    fmt.Printf("Value 4: %v\n", v4)
}

到此这篇关于Go并发原子操作 waitGroup 对象池的文章就介绍到这了,更多相关Go原子操作waitGroup 对象池内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • go语言题解LeetCode674最长连续递增序列

    go语言题解LeetCode674最长连续递增序列

    这篇文章主要为大家介绍了go语言题解LeetCode674最长连续递增序列示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-12-12
  • 一文带你深入了解Go语言中的事务

    一文带你深入了解Go语言中的事务

    事务中止时,你结束事务了吗?在开发时有可能就会犯这样的错误,其问题就是你在提交事务时,如果中间有其他业务就取消操作,那么事务也关闭了吗?本文就来详细讲讲
    2023-04-04
  • 使用gRPC实现获取数据库版本

    使用gRPC实现获取数据库版本

    这篇文章主要为大家详细介绍了如何使用gRPC实现获取数据库版本,文中的示例代码讲解详细,具有一定的借鉴价值,感兴趣的小伙伴可以跟随小编一起学习一下
    2023-12-12
  • Go实现将io.Writer转换成字符串

    Go实现将io.Writer转换成字符串

    golang中提供了各种类型之间的转换方法,其中,将其他类型转换为字符串类型是常见的操作,本文主要介绍了Go实现将io.Writer转换成字符串,具有一定的参考价值,感兴趣的可以了解一下
    2024-05-05
  • Go开发中有哪几种无法恢复的致命场景分析

    Go开发中有哪几种无法恢复的致命场景分析

    这篇文章主要为大家介绍了Go有哪几种无法恢复的致命场景示例解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-08-08
  • Golang使用Consul详解

    Golang使用Consul详解

    Consul是一个服务发现软件, 提供了服务发现\键值存储\健康检查等功能,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-06-06
  • Go 切片copy()函数的用法与原理详解

    Go 切片copy()函数的用法与原理详解

    本文详细剖析Go语言切片复制的核心API copy()函数,涵盖基础用法、目标切片长度、复制子切片、目标切片为空、合并切片、与append对比及实战应用,通过具体示例,解释了完全独立复制、不自动扩容、返回值含义等关键知识点
    2026-05-05
  • Golang高性能持久化解决方案BoltDB数据库介绍

    Golang高性能持久化解决方案BoltDB数据库介绍

    这篇文章主要为大家介绍了Golang高性能持久化解决方案BoltDB数据库介绍,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步
    2021-11-11
  • 4个场景教会你Go中Goroutine和通道是怎么用的

    4个场景教会你Go中Goroutine和通道是怎么用的

    本篇给出了4个在运维开发工作中较为常见的且也是比较典型的场景,通过这些场景来带大家掌握Go中Goroutine和通道是怎么使用的,需要的可以学习一下
    2023-05-05
  • Golang程序如何查找内存泄漏(pprof)

    Golang程序如何查找内存泄漏(pprof)

    该文章详细介绍了如何使用Golang的pprof工具查找内存泄漏,首先,在main包中引入pprof包并设置条件编译,然后编译程序并运行,通过执行gotoolpprof-inuse_space命令,可以进入交互模式并使用top命令查看内存分配最多的函数,如果本机中有源代码
    2024-12-12

最新评论