30 | 原子操作(下)

我们接着上一篇文章的内容继续聊,上一篇我们提到了,sync/atomic包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称 CAS)、加载(load)、存储(store)和交换(swap)。并且以此衍生出了两个问题。

今天我们继续来看第三个衍生问题: 比较并交换操作与交换操作相比有什么不同?优势在哪里?

回答是:比较并交换操作即 CAS 操作,是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。

所谓的交换指的是,把新值赋给变量,并返回变量的旧值。

在进行 CAS 操作的时候,函数会先判断被操作变量的当前值,是否与我们预期的旧值相等。如果相等,它就把新值赋给该变量,并返回true以表明交换操作已进行;否则就忽略交换操作,并返回false。

可以看到,CAS 操作并不是单一的操作,而是一种操作组合。这与其他的原子操作都不同。正因为如此,它的用途要更广泛一些。例如,我们将它与for语句联用就可以实现一种简易的自旋锁(spinlock)。

for {
if atomic.CompareAndSwapInt32(&num2, 10, 0) {
fmt.Println("The second number has gone to zero.")
break
}
time.Sleep(time.Millisecond * 500)
}

在for语句中的 CAS 操作可以不停地检查某个需要满足的条件,一旦条件满足就退出for循环。这就相当于,只要条件未被满足,当前的流程就会被一直“阻塞”在这里。

这在效果上与互斥锁有些类似。不过,它们的适用场景是不同的。我们在使用互斥锁的时候,总是假设共享资源的状态会被其他的 goroutine 频繁地改变。

而for语句加 CAS 操作的假设往往是:共享资源状态的改变并不频繁,或者,它的状态总会变成期望的那样。这是一种更加乐观,或者说更加宽松的做法。

package main

import (
"fmt"
"sync/atomic"
"time"
) func main() {
// 第三个衍生问题的示例。
forAndCAS1()
fmt.Println()
forAndCAS2()
} // forAndCAS1 用于展示简易的自旋锁。
func forAndCAS1() {
sign := make(chan struct{}, 2)
num := int32(0)
fmt.Printf("The number: %d\n", num)
go func() { // 定时增加num的值。
defer func() {
sign <- struct{}{}
}()
for {
time.Sleep(time.Millisecond * 500)
newNum := atomic.AddInt32(&num, 2)
fmt.Printf("The number: %d\n", newNum)
if newNum == 10 {
break
}
}
}()
go func() { // 定时检查num的值,如果等于10就将其归零。
defer func() {
sign <- struct{}{}
}()
for {
if atomic.CompareAndSwapInt32(&num, 10, 0) {
fmt.Println("The number has gone to zero.")
break
}
time.Sleep(time.Millisecond * 500)
}
}()
<-sign
<-sign
} // forAndCAS2 用于展示一种简易的(且更加宽松的)互斥锁的模拟。
func forAndCAS2() {
sign := make(chan struct{}, 2)
num := int32(0)
fmt.Printf("The number: %d\n", num)
max := int32(20)
go func(id int, max int32) { // 定时增加num的值。
defer func() {
sign <- struct{}{}
}()
for i := 0; ; i++ {
currNum := atomic.LoadInt32(&num)
if currNum >= max {
break
}
newNum := currNum + 2
time.Sleep(time.Millisecond * 200)
if atomic.CompareAndSwapInt32(&num, currNum, newNum) {
fmt.Printf("The number: %d [%d-%d]\n", newNum, id, i)
} else {
fmt.Printf("The CAS operation failed. [%d-%d]\n", id, i)
}
}
}(1, max)
go func(id int, max int32) { // 定时增加num的值。
defer func() {
sign <- struct{}{}
}()
for j := 0; ; j++ {
currNum := atomic.LoadInt32(&num)
if currNum >= max {
break
}
newNum := currNum + 2
time.Sleep(time.Millisecond * 200)
if atomic.CompareAndSwapInt32(&num, currNum, newNum) {
fmt.Printf("The number: %d [%d-%d]\n", newNum, id, j)
} else {
fmt.Printf("The CAS operation failed. [%d-%d]\n", id, j)
}
}
}(2, max)
<-sign
<-sign
}

第四个衍生问题:假设我已经保证了对一个变量的写操作都是原子操作,比如:加或减、存储、交换等等,那我对它进行读操作的时候,还有必要使用原子操作吗?

回答是:很有必要。其中的道理你可以对照一下读写锁。为什么在读写锁保护下的写操作和读操作之间是互斥的?这是为了防止读操作读到没有被修改完的值,对吗?

如果写操作还没有进行完,读操作就来读了,那么就只能读到仅修改了一部分的值。这显然破坏了值的完整性,读出来的值也是完全错误的。

所以,一旦你决定了要对一个共享资源进行保护,那就要做到完全的保护。不完全的保护基本上与不保护没有什么区别。

好了,上面的主问题以及相关的衍生问题涉及了原子操作函数的用法、原理、对比和一些最佳实践,希望你已经理解了。

由于这里的原子操作函数只支持非常有限的数据类型,所以在很多应用场景下,互斥锁往往是更加适合的。

不过,一旦我们确定了在某个场景下可以使用原子操作函数,比如:只涉及并发地读写单一的整数类型值,或者多个互不相关的整数类型值,那就不要再考虑互斥锁了。

这主要是因为原子操作函数的执行速度要比互斥锁快得多。而且,它们使用起来更加简单,不会涉及临界区的选择,以及死锁等问题。当然了,在使用 CAS 操作的时候,我们还是要多加注意的,因为它可以被用来模仿锁,并有可能“阻塞”流程。

知识扩展

问题:怎样用好sync/atomic.Value?

为了扩大原子操作的适用范围,Go 语言在 1.4 版本发布的时候向sync/atomic包中添加了一个新的类型Value。此类型的值相当于一个容器,可以被用来“原子地”存储和加载任意的值。

atomic.Value类型是开箱即用的,我们声明一个该类型的变量(以下简称原子变量)之后就可以直接使用了。这个类型使用起来很简单,它只有两个指针方法:Store和Load。不过,虽然简单,但还是有一些值得注意的地方的。

首先一点,一旦atomic.Value类型的值(以下简称原子值)被真正使用,它就不应该再被复制了。什么叫做“真正使用”呢?

我们只要用它来存储值了,就相当于开始真正使用了。atomic.Value类型属于结构体类型,而结构体类型属于值类型。

所以,复制该类型的值会产生一个完全分离的新值。这个新值相当于被复制的那个值的一个快照。之后,不论后者存储的值怎样改变,都不会影响到前者,反之亦然。

另外,关于用原子值来存储值,有两条强制性的使用规则。第一条规则,不能用原子值存储nil。

也就是说,我们不能把nil作为参数值传入原子值的Store方法,否则就会引发一个 panic。

这里要注意,如果有一个接口类型的变量,它的动态值是nil,但动态类型却不是nil,那么它的值就不等于nil。我在前面讲接口的时候和你说明过这个问题。正因为如此,这样一个变量的值是可以被存入原子值的。

第二条规则,我们向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值。

例如,我第一次向一个原子值存储了一个string类型的值,那我在后面就只能用该原子值来存储字符串了。如果我又想用它存储结构体,那么在调用它的Store方法的时候就会引发一个 panic。这个 panic 会告诉我,这次存储的值的类型与之前的不一致。

你可能会想:我先存储一个接口类型的值,然后再存储这个接口的某个实现类型的值,这样是不是可以呢?

很可惜,这样是不可以的,同样会引发一个 panic。因为原子值内部是依据被存储值的实际类型来做判断的。所以,即使是实现了同一个接口的不同类型,它们的值也不能被先后存储到同一个原子值中。

遗憾的是,我们无法通过某个方法获知一个原子值是否已经被真正使用,并且,也没有办法通过常规的途径得到一个原子值可以存储值的实际类型。这使得我们误用原子值的可能性大大增加,尤其是在多个地方使用同一个原子值的时候。

下面,我给你几条具体的使用建议。

1、不要把内部使用的原子值暴露给外界。比如,声明一个全局的原子变量并不是一个正确的做法。这个变量的访问权限最起码也应该是包级私有的。

2、如果不得不让包外,或模块外的代码使用你的原子值,那么可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。

3、如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法性。若不合法,则应该直接返回对应的错误值,从而避免 panic 的发生。

4、如果可能的话,我们可以把原子值封装到一个数据类型中,比如一个结构体类型。这样,我们既可以通过该类型的方法更加安全地存储值,又可以在该类型中包含可存储值的合法类型信息。

除了上述使用建议之外,我还要再特别强调一点:尽量不要向原子值中存储引用类型的值。因为这很容易造成安全漏洞。请看下面的代码:

var box6 atomic.Value
v6 := []int{1, 2, 3}
box6.Store(v6)
v6[1] = 4 // 注意,此处的操作不是并发安全的!

我把一个[]int类型的切片值v6, 存入了原子值box6。注意,切片类型属于引用类型。所以,我在外面改动这个切片值,就等于修改了box6中存储的那个值。这相当于绕过了原子值而进行了非并发安全的操作。那么,应该怎样修补这个漏洞呢?可以这样做:

store := func(v []int) {
replica := make([]int, len(v))
copy(replica, v)
box6.Store(replica)
}
store(v6)
v6[2] = 5 // 此处的操作是安全的。

我先为切片值v6创建了一个完全的副本。这个副本涉及的数据已经与原值毫不相干了。然后,我再把这个副本存入box6。如此一来,无论我再对v6的值做怎样的修改,都不会破坏box6提供的安全保护。

以上,就是我要告诉你的关于atomic.Value的注意事项和使用建议。你可以在 demo64.go 文件中看到相应的示例。

package main

import (
"errors"
"fmt"
"io"
"os"
"reflect"
"sync/atomic"
) func main() {
// 示例1。
var box atomic.Value
fmt.Println("Copy box to box2.")
box2 := box // 原子值在真正使用前可以被复制。
v1 := [...]int{1, 2, 3}
fmt.Printf("Store %v to box.\n", v1)
box.Store(v1)
fmt.Printf("The value load from box is %v.\n", box.Load())
fmt.Printf("The value load from box2 is %v.\n", box2.Load())
fmt.Println() // 示例2。
v2 := "123"
fmt.Printf("Store %q to box2.\n", v2)
box2.Store(v2) // 这里并不会引发panic。
fmt.Printf("The value load from box is %v.\n", box.Load())
fmt.Printf("The value load from box2 is %q.\n", box2.Load())
fmt.Println() // 示例3。
fmt.Println("Copy box to box3.")
box3 := box // 原子值在真正使用后不应该被复制!
fmt.Printf("The value load from box3 is %v.\n", box3.Load())
v3 := 123
fmt.Printf("Store %d to box3.\n", v3)
//box3.Store(v3) // 这里会引发一个panic,报告存储值的类型不一致。
_ = box3
fmt.Println() // 示例4。
var box4 atomic.Value
v4 := errors.New("something wrong")
fmt.Printf("Store an error with message %q to box4.\n", v4)
box4.Store(v4)
v41 := io.EOF
fmt.Println("Store a value of the same type to box4.")
box4.Store(v41)
v42, ok := interface{}(&os.PathError{}).(error)
if ok {
fmt.Printf("Store a value of type %T that implements error interface to box4.\n", v42)
//box4.Store(v42) // 这里会引发一个panic,报告存储值的类型不一致。
}
fmt.Println() // 示例5。
box5, err := NewAtomicValue(v4)
if err != nil {
fmt.Printf("error: %s\n", err)
}
fmt.Printf("The legal type in box5 is %s.\n", box5.TypeOfValue())
fmt.Println("Store a value of the same type to box5.")
err = box5.Store(v41)
if err != nil {
fmt.Printf("error: %s\n", err)
}
fmt.Printf("Store a value of type %T that implements error interface to box5.\n", v42)
err = box5.Store(v42)
if err != nil {
fmt.Printf("error: %s\n", err)
}
fmt.Println() // 示例6。
var box6 atomic.Value
v6 := []int{1, 2, 3}
fmt.Printf("Store %v to box6.\n", v6)
box6.Store(v6)
v6[1] = 4 // 注意,此处的操作不是并发安全的!
fmt.Printf("The value load from box6 is %v.\n", box6.Load())
// 正确的做法如下。
v6 = []int{1, 2, 3}
store := func(v []int) {
replica := make([]int, len(v))
copy(replica, v)
box6.Store(replica)
}
fmt.Printf("Store %v to box6.\n", v6)
store(v6)
v6[2] = 5 // 此处的操作是安全的。
fmt.Printf("The value load from box6 is %v.\n", box6.Load())
} type atomicValue struct {
v atomic.Value
t reflect.Type
} func NewAtomicValue(example interface{}) (*atomicValue, error) {
if example == nil {
return nil, errors.New("atomic value: nil example")
}
return &atomicValue{
t: reflect.TypeOf(example),
}, nil
} func (av *atomicValue) Store(v interface{}) error {
if v == nil {
return errors.New("atomic value: nil value")
}
t := reflect.TypeOf(v)
if t != av.t {
return fmt.Errorf("atomic value: wrong type: %s", t)
}
av.v.Store(v)
return nil
} func (av *atomicValue) Load() interface{} {
return av.v.Load()
} func (av *atomicValue) TypeOfValue() reflect.Type {
return av.t
}

总结

我们把这两篇文章一起总结一下。相对于原子操作函数,原子值类型的优势很明显,但它的使用规则也更多一些。首先,在首次真正使用后,原子值就不应该再被复制了。

其次,原子值的Store方法对其参数值(也就是被存储值)有两个强制的约束。一个约束是,参数值不能为nil。另一个约束是,参数值的类型不能与首个被存储值的类型不同。也就是说,一旦一个原子值存储了某个类型的值,那它以后就只能存储这个类型的值了。

基于上面这几个注意事项,我提出了几条使用建议,包括:不要对外暴露原子变量、不要传递原子值及其指针值、尽量不要在原子值中存储引用类型的值,等等。与之相关的一些解决方案我也一并提出了。希望你能够受用。

原子操作明显比互斥锁要更加轻便,但是限制也同样明显。所以,我们在进行二选一的时候通常不会太困难。但是原子值与互斥锁之间的选择有时候就需要仔细的考量了。不过,如果你能牢记我今天讲的这些内容的话,应该会有很大的助力。

思考题

今天的思考题只有一个,那就是:如果要对原子值和互斥锁进行二选一,你认为最重要的三个决策条件应该是什么?

笔记源码

https://github.com/MingsonZheng/go-core-demo

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

Go语言核心36讲(Go语言实战与应用八)--学习笔记的更多相关文章

  1. Go语言核心36讲(Go语言实战与应用二)--学习笔记

    24 | 测试的基本规则和流程(下) Go 语言是一门很重视程序测试的编程语言,所以在上一篇中,我与你再三强调了程序测试的重要性,同时,也介绍了关于go test命令的基本规则和主要流程的内容.今天我 ...

  2. Go语言核心36讲(Go语言基础知识三)--学习笔记

    03 | 库源码文件 在我的定义中,库源码文件是不能被直接运行的源码文件,它仅用于存放程序实体,这些程序实体可以被其他代码使用(只要遵从 Go 语言规范的话). 这里的"其他代码" ...

  3. Go语言核心36讲(Go语言实战与应用一)--学习笔记

    23 | 测试的基本规则和流程 (上) 在接下来的日子里,我将带你去学习在 Go 语言编程进阶的道路上,必须掌握的附加知识,比如:Go 程序测试.程序监测,以及 Go 语言标准库中各种常用代码包的正确 ...

  4. Go语言核心36讲(Go语言实战与应用三)--学习笔记

    25 | 更多的测试手法 在本篇文章,我会继续为你讲解更多更高级的测试方法.这会涉及testing包中更多的 API.go test命令支持的,更多标记更加复杂的测试结果,以及测试覆盖度分析等等. 前 ...

  5. Go语言核心36讲(Go语言实战与应用四)--学习笔记

    26 | sync.Mutex与sync.RWMutex 从本篇文章开始,我们将一起探讨 Go 语言自带标准库中一些比较核心的代码包.这会涉及这些代码包的标准用法.使用禁忌.背后原理以及周边的知识. ...

  6. Go语言核心36讲(Go语言实战与应用十二)--学习笔记

    34 | 并发安全字典sync.Map (上) 我们今天再来讲一个并发安全的高级数据结构:sync.Map.众所周知,Go 语言自带的字典类型map并不是并发安全的. 前导知识:并发安全字典诞生史 换 ...

  7. Go语言核心36讲(Go语言实战与应用十四)--学习笔记

    36 | unicode与字符编码 在开始今天的内容之前,我先来做一个简单的总结. Go 语言经典知识总结 在数据类型方面有: 基于底层数组的切片: 用来传递数据的通道: 作为一等类型的函数: 可实现 ...

  8. Go语言核心36讲(Go语言实战与应用十八)--学习笔记

    40 | io包中的接口和工具 (上) 我们在前几篇文章中,主要讨论了strings.Builder.strings.Reader和bytes.Buffer这三个数据类型. 知识回顾 还记得吗?当时我 ...

  9. Go语言核心36讲(Go语言实战与应用二十二)--学习笔记

    44 | 使用os包中的API (上) 我们今天要讲的是os代码包中的 API.这个代码包可以让我们拥有操控计算机操作系统的能力. 前导内容:os 包中的 API 这个代码包提供的都是平台不相关的 A ...

  10. Go语言核心36讲(Go语言实战与应用二十四)--学习笔记

    46 | 访问网络服务 前导内容:socket 与 IPC 人们常常会使用 Go 语言去编写网络程序(当然了,这方面也是 Go 语言最为擅长的事情).说到网络编程,我们就不得不提及 socket. s ...

随机推荐

  1. Bootstrap的模态框无法弹出的问题

    今天在使用Bootstrap官网所提供的模态框插件时候发现其中的 可选尺寸模态框 无法弹出 在模态框前使用过其他 Bootstrap的js插件,可以正常使用,说明所需依赖js文件已经正常引用 注意:j ...

  2. 洛谷 P1862 输油管道问题

    题意 题目链接:P1862 输油管道问题 不难看出每个油井的 \(x\) 坐标是没用的,所以问题转化为如下. 代数意义:给出 \(n\) 个数 \(y_1,y_2,\ldots,y_n\),找一个数 ...

  3. WinForm事件与消息

    WinForm事件与消息 消息概述以及在C#下的封装 Windows下应用程序的执行是通过消息驱动的.所有的外部事件,如键盘输入.鼠标移动.按动鼠标都由OS系统转换成相应的"消息" ...

  4. .Net Core 获取上下文HttpContext

    1.先定义一个类 using Microsoft.AspNetCore.Http; namespace BCode.Util { public class MvcContext { public st ...

  5. Java初步学习——2021.09.23每日报告,第三周周四

    (1)今天做了什么: (2)明天准备做什么? (3)遇到的问题,如何解决? 学习数组,编写了一个随机选牌的代码.自己最开始一直想只设置一个字符串数组,利用随机数来输出,但那样对字符串赋值会比较麻烦.可 ...

  6. 步行(walk.cpp) noip模拟

    步行(walk.cpp) [题目描述] 小C喜欢步行,只有缓慢的步行,小C才能沉浸于其中,享受旅途中那些美好的瞬间. 小C来到了一座新的城市生活,这座城市可以看成 \(n\) 个点, \(n−1\) ...

  7. Frida过反调试

    原理介绍:https://www.anquanke.com/post/id/85996 code setImmediate(function () { Java.perform(function () ...

  8. 【UE4 C++】碰撞检测与事件绑定

    概念 碰撞对象通道与预设 默认提供碰撞对象类型,如 WorldStatic.WorldDynamic等.允许用户自定义 默认提供碰撞预设,如 NoCollision.BloackAll.Overlap ...

  9. Beta阶段第四次会议

    Beta阶段第四次会议 时间:2020.5.20 完成工作 姓名 工作 难度 完成度 ltx 1.对小程序进行修改2.提出相关api修改要求 轻 85% xyq 1.设计所需api文档2.编写相关技术 ...

  10. JavaAgent型内存马基础

    Java Instrumentation ​ java Instrumentation指的是可以用独立于应用程序之外的代理(agent)程序来监测和协助运行在JVM上的应用程序.这种监测和协助包括但不 ...