为什么写了value属性 jq赋值value值不显示_[Go基础]理解 Go 标准库中的 atomic.Value 类型

2023-11-06

转载声明

文章作者:喵叔
上次更新:2019-03-15
许可协议:CC BY-NC-ND 4.0(转载请注明出处)
原文链接: https://blog.betacat.io/post/golang-atomic-value-exploration/
已联系作者获得转载许可

前言

在 Go 语言标准库中,sync/atomic包将底层硬件提供的原子操作封装成了 Go 的函数。但这些操作只支持几种基本数据类型,因此为了扩大原子操作的适用范围,Go 语言在 1.4 版本的时候向sync/atomic包中添加了一个新的类型Value。此类型的值相当于一个容器,可以被用来“原子地”存储(Store)和加载(Load)任意类型的值。

历史起源

我在golang-dev邮件列表中翻到了14年的这段讨论,有人报告了encoding/gob包在多核机器上(80-core)上的性能问题,认为encoding/gob之所以不能完全利用到多核的特性是因为它里面使用了大量的互斥锁(mutex),如果把这些互斥锁换成用atomic.LoadPointer/StorePointer来做并发控制,那性能将能提升20倍。

针对这个问题,有人提议在已有的atomic包的基础上封装出一个atomic.Value类型,这样用户就可以在不依赖 Go 内部类型unsafe.Pointer的情况下使用到atomic提供的原子操作。所以我们现在看到的atomic包中除了atomic.Value外,其余都是早期由汇编写成的,并且atomic.Value类型的底层实现也是建立在已有的atomic包的基础上。

那为什么在上面的场景中,atomic会比mutex性能好很多呢?作者Dmitry Vyukov总结了这两者的一个区别:

Mutexes do no scale. Atomic loads do.

Mutex操作系统实现,而atomic包中的原子操作则由底层硬件直接提供支持。在 CPU 实现的指令集里,有一些指令被封装进了atomic包,这些指令在执行的过程中是不允许中断(interrupt)的,因此原子操作可以在lock-free的情况下保证并发安全,并且它的性能也能做到随 CPU 个数的增多而线性扩展。

好了,说了这么多的原子操作,我们先来看看什么样的操作能被叫做原子操作

原子性

一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为原子性(atomicity)。这些操作对外表现成一个不可分割的整体,他们要么都执行,要么都不执行,外界不会看到他们只执行到一半的状态。而在现实世界中,CPU 不可能不中断的执行一系列操作,但如果我们在执行多个操作时,能让他们的中间状态对外不可见,那我们就可以宣称他们拥有了”不可分割”的原子性。

有些朋友可能不知道,在 Go(甚至是大部分语言)中,一条普通的赋值语句其实不是一个原子操作。例如,在32位机器上写int64类型的变量有中间状态,它会被拆成两次写操作(MOV)——写低 32 位和写高 32 位,如下图所示:

如果一个线程刚写完低32位,还没来得及写高32位时,另一个线程读取了这个变量,那它得到的就是一个毫无逻辑的中间变量,这很有可能使我们的程序出现诡异的 Bug。

这还只是一个基础类型,如果我们对一个结构体进行赋值,那它出现并发问题的概率就更高了。很可能写线程刚写完一小半的字段,读线程就来读取这个变量,那么就只能读到仅修改了一部分的值。这显然破坏了变量的完整性,读出来的值也是完全错误的。

面对这种多线程下变量的读写问题,我们的主角——atomic.Value登场了,它使得我们可以不依赖于不保证兼容性的unsafe.Pointer类型,同时又能将任意数据类型的读写操作封装成原子性操作(让中间状态对外不可见)。

使用姿势

atomic.Value类型对外暴露的方法就两个:

  • v.Store(c)- 写操作,将原始的变量c存放到一个atomic.Value类型的v里。
  • c = v.Load()- 读操作,从线程安全的v中读取上一步存放的内容。

简洁的接口使得它的使用也很简单,只需将需要作并发保护的变量读取和赋值操作用Load()Store()代替就行了。

下面是一个常见的使用场景。应用程序定期的从外界获取最新的配置信息,然后更改自己内存中维护的配置变量。工作线程根据最新的配置来处理请求。

package main

import (
 "sync/atomic"
 "time"
)

func loadConfig() map[string]string {
 // 从数据库或者文件系统中读取配置信息,然后以map的形式存放在内存里
 return make(map[string]string)
}

func requests() chan int {
 // 将从外界中接受到的请求放入到channel里
 return make(chan int)
}

func main() {
 // config变量用来存放该服务的配置信息
 var config atomic.Value
 // 初始化时从别的地方加载配置文件,并存到config变量里
 config.Store(loadConfig())
 go func() {
   // 每10秒钟定时的拉取最新的配置信息,并且更新到config变量里
   for {
     time.Sleep(10 * time.Second)
     // 对应于赋值操作 config = loadConfig()
     config.Store(loadConfig())
   }
 }()
 // 创建工作线程,每个工作线程都会根据它所读取到的最新的配置信息来处理请求
 for i := 0; i < 10; i++ {
   go func() {
     for r := range requests() {
       // 对应于取值操作 c := config
       // 由于Load()返回的是一个interface{}类型,所以我们要先强制转换一下
       c := config.Load().(map[string]string)
       // 这里是根据配置信息处理请求的逻辑...
       _, _ = r, c
     }
   }()
 }
}

内部实现

罗永浩浩曾说过:

Simplicity is the hidden complexity

我们来看看在简单的外表下,它到底有哪些 hidden complexity。

数据结构

atomic.Value被设计用来存储任意类型的数据,所以它内部的字段是一个interface{}类型,非常的简单粗暴。

type Value struct {
  v interface{}
}

除了Value外,这个文件里还定义了一个ifaceWords类型,这其实是一个空interface (interface{})的内部表示格式(参见runtime/runtime2.go中eface的定义)。它的作用是将interface{}类型分解,得到其中的两个字段。

type ifaceWords struct {
  typ  unsafe.Pointer
  data unsafe.Pointer
}

写入(Store)操作

在介绍写入之前,我们先来看一下 Go 语言内部的unsafe.Pointer类型。

unsafe.Pointer

出于安全考虑,Go 语言并不支持直接操作内存,但它的标准库中又提供一种*不安全(不保证向后兼容性)*的指针类型unsafe.Pointer,让程序可以灵活的读取/操作内存。

unsafe.Pointer的特别之处在于,它可以绕过 Go 语言类型系统的检查,与任意的指针类型互相转换。也就是说,如果两种类型具有相同的内存结构,我们可以将unsafe.Pointer当做桥梁,让这两种类型的指针相互转换,从而实现同一份内存拥有两种不同的解读方式。

比如说,[]bytestring其实内部的存储结构都是一样的,但 Go 语言的类型系统禁止他俩互换。如果借助unsafe.Pointer,我们就可以实现在零拷贝的情况下,将[]byte数组直接转换成string类型。

bytes := []byte{104, 101, 108, 108, 111}

p := unsafe.Pointer(&bytes) //强制转换成unsafe.Pointer,编译器不会报错
str := *(*string)(p) //然后强制转换成string类型的指针,再将这个指针的值当做string类型取出来
fmt.Println(str) //输出 "hello"

知道了unsafe.Pointer的作用,我们可以直接来看代码了:

func (v *Value) Store(x interface{}) {
  if x == nil {
    panic("sync/atomic: store of nil value into Value")
  }
  vp := (*ifaceWords)(unsafe.Pointer(v))  // Old value
  xp := (*ifaceWords)(unsafe.Pointer(&x)) // New value
  for {
    typ := LoadPointer(&vp.typ)
    if typ == nil {
      // Attempt to start first store.
      // Disable preemption so that other goroutines can use
      // active spin wait to wait for completion; and so that
      // GC does not see the fake type accidentally.
      runtime_procPin()
      if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(^uintptr(0))) {
        runtime_procUnpin()
        continue
      }
      // Complete first store.
      StorePointer(&vp.data, xp.data)
      StorePointer(&vp.typ, xp.typ)
      runtime_procUnpin()
      return
    }
    if uintptr(typ) == ^uintptr(0) {
      // First store in progress. Wait.
      // Since we disable preemption around the first store,
      // we can wait with active spinning.
      continue
    }
    // First store completed. Check type and overwrite data.
    if typ != xp.typ {
      panic("sync/atomic: store of inconsistently typed value into Value")
    }
    StorePointer(&vp.data, xp.data)
    return
  }
}

大概的逻辑:

  • 第5~6行 - 通过unsafe.Pointer现有的要写入的值分别转成ifaceWords类型,这样我们下一步就可以得到这两个interface{}的原始类型(typ)和真正的值(data)。
  • 从第7行开始就是一个无限 for 循环。配合CompareAndSwap食用,可以达到乐观锁的功效。
  • 第8行,我们可以通过LoadPointer这个原子操作拿到当前Value中存储的类型。下面根据这个类型的不同,分3种情况处理。
  1. 第一次写入(第9~24行) - 一个Value实例被初始化后,它的typ字段会被设置为指针的零值 nil,所以第9行先判断如果typ是 nil 那就证明这个Value还未被写入过数据。那之后就是一段初始写入的操作:
  • runtime_procPin()这是runtime中的一段函数,具体的功能我不是特别清楚,也没有找到相关的文档。这里猜测一下,一方面它禁止了调度器对当前 goroutine 的抢占(preemption),使得它在执行当前逻辑的时候不被打断,以便可以尽快地完成工作,因为别人一直在等待它。另一方面,在禁止抢占期间,GC 线程也无法被启用,这样可以防止 GC 线程看到一个莫名其妙的指向^uintptr(0)的类型(这是赋值过程中的中间状态)。
  • 使用CAS操作,先尝试将typ设置为^uintptr(0)这个中间状态。如果失败,则证明已经有别的线程抢先完成了赋值操作,那它就解除抢占锁,然后重新回到 for 循环第一步。
  • 如果设置成功,那证明当前线程抢到了这个”乐观锁”,它可以安全的把v设为传入的新值了(19~23行)。注意,这里是先写data字段,然后再写typ字段。因为我们是以typ字段的值作为写入完成与否的判断依据的。
  1. 第一次写入还未完成(第25~30行)- 如果看到typ字段还是^uintptr(0)这个中间类型,证明刚刚的第一次写入还没有完成,所以它会继续循环,”忙等”到第一次写入完成。
  2. 第一次写入已完成(第31行及之后) - 首先检查上一次写入的类型与这一次要写入的类型是否一致,如果不一致则抛出异常。反之,则直接把这一次要写入的值写入到data字段。

这个逻辑的主要思想就是,为了完成多个字段的原子性写入,我们可以抓住其中的一个字段,以它的状态来标志整个原子写入的状态。这个想法我在TiDB 的事务实现中看到过类似的,他们那边叫Percolator模型,主要思想也是先选出一个primaryRow,然后所有的操作也是以primaryRow的成功与否作为标志。嗯,果然是太阳底下没有新东西。

如果没有耐心看代码,没关系,这儿还有个简化版的流程图:

读取(Load)操作

先上代码:

func (v *Value) Load() (x interface{}) {
  vp := (*ifaceWords)(unsafe.Pointer(v))
  typ := LoadPointer(&vp.typ)
  if typ == nil || uintptr(typ) == ^uintptr(0) {
    // First store not yet completed.
    return nil
  }
  data := LoadPointer(&vp.data)
  xp := (*ifaceWords)(unsafe.Pointer(&x))
  xp.typ = typ
  xp.data = data
  return
}

读取相对就简单很多了,它有两个分支:

  1. 如果当前的typ是 nil 或者^uintptr(0),那就证明第一次写入还没有开始,或者还没完成,那就直接返回 nil (不对外暴露中间状态)。
  2. 否则,根据当前看到的typdata构造出一个新的interface{}返回出去。

总结

本文从邮件列表中的一段讨论开始,介绍了atomic.Value的被提出来的历史缘由。然后由浅入深的介绍了它的使用姿势,以及内部实现。让大家不仅知其然,还能知其所以然。

另外,再强调一遍,原子操作由底层硬件支持,而锁则由操作系统提供的 API 实现。若实现相同的功能,前者通常会更有效率,并且更能利用计算机多核的优势。所以,以后当我们想并发安全的更新一些变量的时候,我们应该优先选择用atomic.Value来实现。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

为什么写了value属性 jq赋值value值不显示_[Go基础]理解 Go 标准库中的 atomic.Value 类型 的相关文章

  • QT(2):信号槽机制和源码

    信号槽 信号槽是观察者模式的一种实现 订阅 发布 一个信号就是一个能够被观察的事件 一个槽就是一个观察者 被观察者 多观察者 信号发出者调用所有注册的槽函数 信号槽本质上是两个对象的函数地址映射 单线程下 相当于函数指针调用 多线程下 发送
  • 多态 数组 继承 类与接口

    多态 1 多态概念 多态就是多种状态 表现为多种形式的能力 2 多态怎么理解 多态可分为静态多态和动态多态 静态多态 当创建一个父类时 子类可以调用父类中的所有方法 而自己没有独有的方法 具体表现方法是重载 动态多态 当创建一个父类时 子类
  • 算法竞赛进阶指南 递归实现组合型枚举

    文章目录 1 递归实现指数型枚举 2 递归实现排列型枚举 题目链接 https ac nowcoder com acm contest 998 B 1 递归实现指数型枚举 思路 在 递归实现指数型枚举 的基础上 如果已经选择了超过 m m
  • k8s集群中部署微服务Vue

    k8s集群中部署微服务后台管理项目 admin 一 项目创建 修改依据 npm版本为12 2 0 可以使用 nvm进行安装及切换使用 npm install node sass 4 14 npm install 二 项目容器镜像准备 roo
  • 设计链表00

    题目链接 设计链表 题目描述 注意 addAtIndex index val 方法中 如果 index 等于链表的长度 则该节点将附加到链表的末尾 如果 index 大于链表长度 则不会插入节点 如果index小于0 则在头部插入节点 解答
  • python中类及其实例详解

    http wiki woodpecker org cn moin PyNewStyleClass 1 python中的 new style class 及其实例详解 原文见 Python In a Nutshell 2003 5 2节 1
  • Windows11 0x80190001错误解决

    一 Windows11 0x80190001错误 笔者当前使用的系统版本为 win11 21H2 windows11出现这样的错误 初步判断为windows的网络连接问题引起的 或者是系统当前网络设置的问题 当然也不排除win11自身系统的
  • 图形学变换——平移、旋转和缩放

    图形学变换 一 概述 二 平移 二 旋转 三 缩放 一 概述 在齐次坐标中 所有的仿射变换都可以使用如下形式的 4 x 4 矩阵来表示 点 p x y z
  • Java知识点回顾系列(集合框架)

    记录一下Java中集合的使用与区别 主要讲List Set Map的原理 使用方法 在Java世界里 集合框架的核心接口为Collection List 列表 Set 集合 和Map 映射 集合关系图 根据上面的关系图可以得到以下的结论 C
  • 游戏业务被攻击了应该如何防护?

    游戏作为最容易遭到黑客攻击行业 总是避免不了被攻击 这个问题始终是绕不过去的一个点 那么为什么黑客这么喜欢攻击游戏行业呢 主要原因有那么几点 1 勒索行为 这个是绝大部分黑客攻击平台的原因 是为了勒索钱财 收取保护费 2 恶意竞争 同行竞争
  • 区块链技术基本概念(上)

    区块链本质是一个对等网络的分布式账本数据库 数据区块 挖矿与分叉问题 一 区块是在挖矿的过程中产生的 二 挖矿实际上就是穷举随机数算法 把上个区块的哈希值加上10分钟内的全部交易单打包 再加上一个随机数 算出一个256位的字符串哈希值 输入
  • 01-物联网(环保管家)(一)温湿度与plc通信具体实现步骤

    先解释几个名词 DTU Data Transfer unit 是专门用于将串口数据转换为IP数据或将IP数据转换为串口数据通过无线通信网络进行传送的无线终端设备 DTU广泛应用于气象 水文水利 地质等行业 MQTT MQTT 是 IBM 为
  • 数据结构(栈和队列)

    Queue常用子类 PriorityQueue Deque常用子类 LinkedList以及ArrayDeque Queue有一个直接子类PriorityQueue 而Deque中直接子类有两个 LinkedList以及ArrayDeque
  • 时间序列分析 - 移动平均SMA, EMA(EWMA) 之python

    pandas pandas DataFrame rolling pandas DataFrame ewm pandas DataFrame mean 其中rolling可以指定窗口类型win type 比如boxcar boxcar tri
  • jsp为基础,设计并实现了一个酒店客房管理系统

    摘要 本毕业设计以jsp为基础 设计并实现了一个酒店客房管理系统 该系统包括客房信息管理 客房预订管理和客房入住管理等模块 通过使用jsp技术 能够实现酒店客房的在线管理 提高客房管理的效率和准确性 关键词 jsp 酒店客房管理系统 客房信
  • gauge自动化框架踩坑(五):关于表格

    官方文档使用表格的方式有两种 1 表格参数 2 动态参数 现针对不同的情况做一分析 一 表格参数 官方文档的解释是 表格参数被用来作为一个step 执行多条数据 以官网的模板项目为例 这里给了一个表格 作为一个step Almost all
  • 将时间序列转成图像——马尔可夫转移场方法 Matlab实现

    目录 1 方法 2 Matlab代码实现 3 结果 若觉文章质量良好且有用 请别忘了点赞收藏加关注 这将是我继续分享的动力 万分感谢 其他 1 时间序列转二维图像方法及其应用研究综述 vm 1215的博客 CSDN博客 2 将时间序列转成图
  • BOOST升压电路PCB布局布线

    一 正确找出BOOST的高频电流环路 尽可能让di dt大的路径小 在boost中为开关管 二极管 与输出电容 二 输入环路 先经过Cin再到芯片输入脚 三 输出环路 重要 SW覆盖面积要小 四 反馈环路 重要 与FB相连的两个电阻越靠近F
  • 零拷贝内存 & 固定内存

    一 总览 虚拟内存是一种计算机内存管理的技术 它让程序认为程序自身有一段完整的连续可用的内存 一个地址空间 当程序运行时所占的内存空间大于物理空间容量 操作系统可以将暂时不用的数据放入到磁盘 用的时候再拿出来 这样磁盘有一部分空间就是用来存

随机推荐