并发安全和锁(sync) & 原子操作和CAS

锁的本质

  1. 锁的本质,就是一种资源,是由操作系统维护的一种专门用于同步的资源,就是一块存储了标记位的内存空间。当这个空间被赋值为1的时候表示加锁了,赋值为0的时候表示解锁了。多个线程抢一个锁,就是抢着要把这块内存空间赋值为1。
  2. 在单核环境中,只需要在加锁之前,关闭中断,加锁完成后,打开中断。就可以保证加锁过程的原子性,只有一个线程可以抢到锁。
  3. 在多核环境中,内存空间是共享的,每个核上各跑一个线程。此时要保证一次只有一个线程抢到锁,就需要硬件层面的某种支持。
  4. 最简单的办法就是将自己的资源和操作系统定义好的锁绑定到一起。也就是说,进程要获取资源之前,必须要获得操作系统的锁。

go的锁

Golang的提供的同步机制有sync模块下的Mutex、WaitGroup以及语言自身提供的chan等。这些同步的方法都是以runtime中实现的底层同步机制(cas、atomic、spinlock、sem)为基础的

自旋锁(spinlock)

  1. 自旋锁是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断地判断是否能够被成功获取,知直到获取到锁才会退出循环。
  2. 获取锁的线程一直处于活跃状态。
  3. Golang中的自旋锁用来实现其他类型的锁,与互斥锁类似,不同点在于,它不是通过休眠来使进程阻塞,而是在获得锁之前一直处于活跃状态(自旋)

mutex的必要性

在某些场景下为什么不能把互斥锁替换成原子(atomic)操作?

锁在高度竞争时会不断挂起恢复线程从而让出cpu资源,原子变量在高度竞争时会一直占用cpu。

线程安全

  1. 线程安全:多个线程执行流对临界资源的不安全争抢操作。

  2. 常见临界资源有全局变量和静态变量。

  3. 栈是保证一个执行流独立运行的基本条件,所以线程有自己独立的栈空间。大部分情况下线程使用的变量都是局部变量,变量的地址空间在线程栈空间内。这种情况下,一个变量属于单线程,其他线程无法获得这种变量。但是有些时候,需要一些变量在多个线程之间共享,这种变量称之为共享变量。可以通过数据在线程之间的交互,完成多个线程的交互。但是多个线程并发的操作共享变量,就会导致线程安全问题(数据不一致)。此处可以吧共享变量看做临界资源。

  4. 同步和互斥机制实现线程安全。互斥:同一时间对临界资源的唯一访问。同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步(临界资源:操作系统一个资源同时只能有一个执行访问)

中断

  1. 打断计算机正常执行的这种现象叫中断(Interrupt);
  2. 发出中断信号(也叫中断请求irq)的设备称为中断源。
  3. 中断发生后,计算机处理中断事件称为中断处理,处理中断的程序代码成为中断服务程序(ISP)。
  4. 中断发生后计算机首先会自动中断当前程序执行,保存当前系统状态(保存中断现场),然后跳转中断服务程序处(中断向量,中断地址)并执行,执行完毕后,然后计算机自动跳转到中断处,恢复现场(中断恢复),开始执行正常的流程。
  5. 不同的中断用不同的数字表示和区分(中断号)。

中断的作用

  1. 引入中断以后,当处理器发出设备请求后就可以立即返回以处理其他任务,而当设备完成动作后,发送中断信号给处理器,后者就可以再回过头获取处理结果。这样,在设备进行处理的周期内,处理器可以执行其他一些有意义的工作,而只需要付出一些很小的切换所引发的时间代价。

  2. 在多线程编程中,为了保证数据操作的一致性,操作系统引入了锁机制,用于保证临界区代码的安全。通过锁机制,能够保证在多核多线程环境中,在某一个时间点上,只能有一个线程进入临界区代码,从而保证临界区中操作数据的一致性。

  3. 锁机制的一个特点是它的同步原语都是原子操作。

  4. 操作系统之所以能构建锁之类的同步原语,是因为硬件已经提供了一些原子操作,比如:中断禁止和启用(interrupt enable/disable),内存加载和存入(load/store)测试与设置(test and set)指令。禁止中断这个操作是一个硬件步骤,中间无法插入别的操作。同样,中断启用,测试与设置均为一个硬件步骤的指令。在这些硬件原子操作之上,便可以构建软件原子操作:锁,睡觉与叫醒,信号量等。

原子操作

  1. 百度百科:“原子操作(atomic operation)是不需要synchronized”,这是多线程编程的老生常谈了。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch [1] (切换到另一个线程)。

  2. 原子操作即是进行过程中不能被中断的操作,针对某个值的原子操作在被进行的过程中,CPU绝不会再去进行其他的针对该值的操作。

  3. 为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成。

  4. 原子操作是无锁的,常常直接通过CPU指令直接实现。

语言支持

Go 语言的sync/atomic包提供了对原子操作的支持,用于同步访问整数和指针。

原子操作中的比较并交换简称CAS(Compare And Swap),在sync/atomic包中,这类原子操作由名称以CompareAndSwap为前缀的若干个函数提供

CAS

  1. CAS是compare and swap的缩写。
  2. cas是一种基于锁的操作,而且是乐观锁。
  3. 悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
  4. CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。
  5. 如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。
  6. CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

CAS操作基于CPU提供的原子操作指令实现。对于Intel X86处理器,可通过在汇编指令前增加LOCK前缀来锁定系统总线,使系统总线在汇编指令执行时无法访问相应的内存地址。而各个编译器根据这个特点实现了各自的原子操作函数。

  1. C语言,C11的头文件<stdatomic.h>。由GNU提供了对应的__sync系列函数完成原子操作。 [6][7]
  2. C++11,STL提供了atomic系列函数。[8][7]
  3. JAVA,sun.misc.Unsafe提供了compareAndSwap系列函数。[9]
  4. C#,通过Interlocked方法实现。[10]
  5. Go, 通过import "sync/atomic"包实现。[11]

一个CAS操作等价于以下c代码的原子实现:

int cas(long *addr, long old, long new)
{
    /* Executes atomically. */
    if(*addr != old)
        return 0;
    *addr = new;
    return 1;
}

在使用上,通常会记录下某块内存中的旧值,通过对旧值进行一系列的操作后得到新值,然后通过CAS操作将新值与旧值进行交换。如果这块内存的值在这期间内没被修改过,则旧值会与内存中的数据相同,这时CAS操作将会成功执行 使内存中的数据变为新值。如果内存中的值在这期间内被修改过,则一般[2]来说旧值会与内存中的数据不同,这时CAS操作将会失败,新值将不会被写入内存。

比较交换(compare-and-swap,CAS)和上锁(locking)是两种不同的并发控制机制,它们在处理共享资源时有一些重要的区别:

  1. 机制:

    • CAS: CAS 是一种基于硬件层面的原子操作。它比较内存中的值与给定的期望值,如果相等,则将该内存位置的值更新为新值。这是一个非阻塞操作,意味着它不会等待其他线程,而是立即返回结果。
    • 上锁: 上锁使用互斥锁(mutex)或信号量等同步原语,以确保在任何时刻只有一个线程可以访问共享资源。当一个线程获取到锁时,其他线程会被阻塞,直到锁被释放。
  2. 性能:

    • CAS: CAS 操作通常比锁定操作更轻量级,因为它是非阻塞的,不会引起线程的切换和上下文切换。
    • 上锁: 上锁操作可能涉及到线程的切换和上下文切换,这可能会导致一定的性能开销。
  3. 死锁:

    • CAS: 由于CAS是非阻塞的,不会发生死锁情况。
    • 上锁: 如果线程A持有锁并且在释放之前尝试获取另一个锁,而线程B持有了线程A需要的锁并尝试获取线程A持有的锁,就可能导致死锁。
  4. 适用场景:

    • CAS: 适用于高并发环境下,特别是当竞争较少时,CAS 可以提供更好的性能。它通常用于实现乐观锁机制,例如在无锁数据结构(比如无锁队列)中。
    • 上锁: 当需要确保一段代码或资源只能被一个线程访问时,上锁是一个更为直接的选择。例如,在对共享资源进行读写时,需要保证数据的一致性时,就可以使用锁。

总的来说,CAS 更适合于并发度高、竞争少的情况,而上锁适用于需要强制同步和保护共享资源的情况。在实际应用中,通常会根据具体情况选择使用CAS还是锁,或者结合两者以取得最佳的性能和数据一致性。

ABA问题

CAS 的使用使得在一些情况下可以避免使用显式的锁,从而提高了并发性能。然而,它也有一些限制和注意事项,比如在高并发场景下可能会出现ABA问题,需要特殊的处理。

ABA问题是无锁结构实现中常见的一种问题,可基本表述为:

  1. 进程P1读取了一个数值A
  2. P1被挂起(时间片耗尽、中断等),进程P2开始执行
  3. P2修改数值A为数值B,然后又修改回A
  4. P1被唤醒,比较后发现数值A没有变化,程序继续执行。

对于P1来说,数值A未发生过改变,但实际上A已经被变化过了,继续使用可能会出现问题。在CAS操作中,由于比较的多是指针,这个问题将会变得更加严重。

试想如下情况:

   top
    |
    V   
  0x0014
| Node A | --> |  Node X | --> ……

有一个栈(先入后出)中有top和节点A,节点A目前位于栈顶top指针指向A。现在有一个进程P1想要pop一个节点,因此按照如下无锁操作进行

pop()
{
  do{
    ptr = top;            // ptr = top = NodeA
    next_ptr = top->next; // next_ptr = NodeX
  } while(CAS(top, ptr, next_ptr) != true);
  return ptr;   
}

而进程P2在执行CAS操作之前打断了P1,并对栈进行了一系列的pop和push操作,使栈变为如下结构:

   top
    |
    V  
  0x0014
| Node C | --> | Node B | --> |  Node X | --> ……

进程P2首先pop出NodeA,之后又push了两个NodeB和C,由于内存管理机制中广泛使用的内存重用机制,导致NodeC的地址与之前的NodeA一致。

这时P1又开始继续运行,在执行CAS操作时,由于top依旧指向的是NodeA的地址(实际上已经变为NodeC),因此将top的值修改为了NodeX,这时栈结构如下:

                                   top
                                    |
   0x0014                           V
 | Node C | --> | Node B | --> |  Node X | --> ……

经过CAS操作后,top指针错误地指向了NodeX而不是NodeB。

原子操作与互斥锁的区别

  1. atomic操作的优势是更轻量,CAS可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。这可以大大的减少同步对程序性能的损耗。

  2. 使用CAS操作的做法趋于乐观,总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换,那么在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。而使用互斥锁的做法则趋于悲观,我们总假设会有并发的操作要修改被操作的值,并使用锁将相关操作放入临界区中加以保护。

临界区的概念和互斥的理解

在这里插入图片描述

  1. 临界区:一段代码需要访问的资源是共享资源。访问共享资源的代码称为临界区。
  2. 互斥:临界区的进程只有一个,不允许出现多个进程进入临界区访问。多个进程访问会出现不确定性
  3. 死锁:当两个进程都拥有一定的资源且还需要其他资源时,有可能相互等待。A等B、B等A,谁也无法继续执行。
  4. 饥饿:一个进程持续等待,而且可能长期存在而无限期等待。

临界区

在这里插入图片描述

临界区的特性

1.互斥:任何时候只有一个线程或者进程可以访问临界区
2.前进(Progress):一个进程或者线程想进入临界区,则最终他会进入临界区,不会一直等待。
3.有限等待:如果一个进程只需等待有限的时间段就能确保它可以进入临界区去执行。
4.无忙等待【可选】:在临界区之外等着,如果是在做死循环这种忙等,如果不能确保很快进入临界区,则过于消耗CPU资源。

临界资源

  1. 一次仅允许一个进程使用的共享资源。

  2. 临界区不是内核对象,而是系统提供的一种数据结构,程序中可以声明一个该类型的变量,之后用它来实现对资源的互斥访问。当欲访问某一临界资源时,先将该临界区加锁(若临界区不空闲则等待),用完该资源后,将临界区释放。

  3. 临界区也是代码的称呼,所以一个进程可能有多个临界区,分别用来访问不同的临界资源。

调度规则

  1. 如果有若干进程请求进入空闲的临界区(空闲即0进程访问),一次仅允许一个进程进入。
  2. 任何时候,处于临界区内的进程不可多于一个(0 或 1),若已有进程进入自己的临界区,则其它想进入自己临界区的进程必须等待。
  3. 进行临界区的进程要在有限时间内退出,以便其它进程能及时进入自己的临界区。
  4. 如果其它进程不能进入自己的临界区,则应让出 CPU,避免进程出现 “忙等” 现象

并发安全和锁

互斥锁

var x int64
var wg sync.WaitGroup
var lock sync.Mutex

func add() {
    for i := 0; i < 5000; i++ {
        lock.Lock() // 加锁
        x = x + 1
        lock.Unlock() // 解锁
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。

读写互斥锁

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。

读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。

var (
    x      int64
    wg     sync.WaitGroup
    lock   sync.Mutex
    rwlock sync.RWMutex
)

func write() {
    // lock.Lock()   // 加互斥锁
    rwlock.Lock() // 加写锁
    x = x + 1
    time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
    rwlock.Unlock()                   // 解写锁
    // lock.Unlock()                     // 解互斥锁
    wg.Done()
}

func read() {
    // lock.Lock()                  // 加互斥锁
    rwlock.RLock()               // 加读锁
    time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
    rwlock.RUnlock()             // 解读锁
    // lock.Unlock()                // 解互斥锁
    wg.Done()
}

func main() {
    start := time.Now()
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go write()
    }

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go read()
    }

    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

Sync

sync.WaitGroup

方法名功能
(wg * WaitGroup) Add(delta int)计数器+delta
(wg *WaitGroup) Done()计数器-1
(wg *WaitGroup) Wait()阻塞直到计数器变为0

sync.Once

var icons map[string]image.Image

func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
    if icons == nil {
        loadIcons()
    }
    return icons[name]
}

多个goroutine并发调用Icon函数时不是并发安全的,现代的编译器和CPU可能会在保证每个goroutine都满足串行一致的基础上自由地重排访问内存的顺序。

func loadIcons() {
    icons = make(map[string]image.Image)
    icons["left"] = loadIcon("left.png")
    icons["up"] = loadIcon("up.png")
    icons["right"] = loadIcon("right.png")
    icons["down"] = loadIcon("down.png")
}

在这种情况下就会出现即使判断了icons不是nil也不意味着变量初始化完成了。考虑到这种情况,我们能想到的办法就是添加互斥锁,保证初始化icons的时候不会被其他的goroutine操作,但是这样做又会引发性能问题。

var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 是并发安全的
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。

sync.Map

Go语言中内置的map不是并发安全的。

Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。

var m = sync.Map{}

func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 20; i++ {
        wg.Add(1)
        go func(n int) {
            key := strconv.Itoa(n)
            m.Store(key, n)
            value, _ := m.Load(key)
            fmt.Printf("k=:%v,v:=%v\n", key, value)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

原子操作(atomic包)

代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法它在用户态就可以完成,因此性能比加锁操作更好。Go语言中原子操作由内置的标准库sync/atomic提供。

atomic包

方法解释
func LoadInt32(addr int32) (val int32)
func LoadInt64(addr int64) (val int64)
func LoadUint32(addruint32) (val uint32)
func LoadUint64(addruint64) (val uint64)
func LoadUintptr(addruintptr) (val uintptr)
func LoadPointer(addrunsafe.Pointer) (val unsafe.Pointer)
读取操作
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
写入操作
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
修改操作
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
交换操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
比较并交换操作
var x int64
var l sync.Mutex
var wg sync.WaitGroup

// 普通版加函数
func add() {
    // x = x + 1
    x++ // 等价于上面的操作
    wg.Done()
}

// 互斥锁版加函数
func mutexAdd() {
    l.Lock()
    x++
    l.Unlock()
    wg.Done()
}

// 原子操作版加函数
func atomicAdd() {
    atomic.AddInt64(&x, 1)
    wg.Done()
}

func main() {
    start := time.Now()
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        // go add()       // 普通版add函数 不是并发安全的
        // go mutexAdd()  // 加锁版add函数 是并发安全的,但是加锁性能开销大
        go atomicAdd() // 原子操作版add函数 是并发安全,性能优于加锁版
    }
    wg.Wait()
    end := time.Now()
    fmt.Println(x)
    fmt.Println(end.Sub(start))
}

原子操作

原子操作依赖硬件指令的支持,但同时还需要运行时调度器的配合。以 atomic.CompareAndSwapPointer 为例,介绍 sync/atomic 包提供的同步模式。

CompareAndSwapPointer 它在包中只有函数定义,没有函数体:

func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)

其本身由运行时实现。

我们简单看过了两个属于公共包的方法 atomic.Value 和 atomic.CompareAndSwapPointer, 我们来看一下运行时实现:

//go:linkname sync_atomic_CompareAndSwapUintptr sync/atomic.CompareAndSwapUintptr
func sync_atomic_CompareAndSwapUintptr(ptr *uintptr, old, new uintptr) bool

//go:linkname sync_atomic_CompareAndSwapPointer sync/atomic.CompareAndSwapPointer
//go:nosplit
func sync_atomic_CompareAndSwapPointer(ptr *unsafe.Pointer, old, new unsafe.Pointer) bool {
	if writeBarrier.enabled {
		atomicwb(ptr, new)
	}
	return sync_atomic_CompareAndSwapUintptr((*uintptr)(noescape(unsafe.Pointer(ptr))), uintptr(old), uintptr(new))
}

可以看到 sync_atomic_CompareAndSwapUintptr 函数在运行时中也是没有方法本体的, 说明其实现由编译器完成。

可以使用 Go 提供的编译工具来生成汇编代码。可以通过 go tool compile 和 go tool objdump 来查看详细的汇编代码。以下是具体步骤:

1. 编译生成汇编代码
可以使用以下命令直接生成汇编代码,来查看程序的汇编过程:
go build -gcflags '-S' main.go
这会输出像你所展示的汇编代码,包含 TEXT sync/atomic.CompareAndSwapUintptr(SB) 以及相应的地址。

2. 使用 go tool objdump
如果希望更详细地查看二进制文件的汇编内容,可以编译程序并使用 go tool objdump 工具。
go build -o main main.go
go tool objdump -s "main.main" ./main
这将会展示 main.main 函数的汇编指令。可以通过 -s 选项来指定需要查看的函数。

3. 使用 go tool compile 查看编译后的汇编
如果想查看某个具体源文件的汇编,可以使用:
go tool compile -S -l main.go
这将会输出整个程序的汇编过程,适用于查看每个函数的指令。
package main

import (
	"sync/atomic"
	"unsafe"
)

func main() {
	var p unsafe.Pointer
	newP := 42
	atomic.CompareAndSwapPointer(&p, nil, unsafe.Pointer(&newP))

	v := (*int)(p)
	println(*v)
}

编译结果:

TEXT sync/atomic.CompareAndSwapUintptr(SB) /usr/local/Cellar/go/1.11/libexec/src/sync/atomic/asm.s
  asm.s:31		0x1001070		e91b0b0000		JMP runtime/internal/atomic.Casuintptr(SB)	
  :-1			0x1001075		cc			INT $0x3					
  (...)

TEXT runtime/internal/atomic.Casuintptr(SB) /usr/local/Cellar/go/1.11/libexec/src/runtime/internal/atomic/asm_amd64.s
  asm_amd64.s:44	0x1001b90		e9dbffffff		JMP runtime/internal/atomic.Cas64(SB)	
  :-1			0x1001b95		cc			INT $0x3				
  (...)

可以看到 atomic.CompareAndSwapUintptr 本质上转到了 runtime/internal/atomic.Cas64:

// bool	runtime∕internal∕atomic·Cas64(uint64 *val, uint64 old, uint64 new)
// Atomically:
//	if(*val == *old){
//		*val = new;
//		return 1;
//	} else {
//		return 0;
//	}
TEXT runtime∕internal∕atomic·Cas64(SB), NOSPLIT, $0-25
	MOVQ	ptr+0(FP), BX
	MOVQ	old+8(FP), AX
	MOVQ	new+16(FP), CX
	LOCK
	CMPXCHGQ	CX, 0(BX)
	SETEQ	ret+24(FP)
	RET

可以看到,实现的本质是使用 CPU 的 LOCK+CMPXCHGQ 指令:首先将 ptr 的值放入 BX,将假设的旧值放入 AX, 要比较的新值放入 CX。然后 LOCK CMPXCHGQ 与累加器 AX 比较并交换 CX 和 BX。

因此原子操作本质上均为使用 CPU 指令进行实现(理所当然)。

原子值

原子值需要运行时的支持,在原子值进行修改时,Goroutine 不应该被抢占,因此需要锁定 MP 之间的绑定关系:

//go:linkname sync_runtime_procPin sync.runtime_procPin
//go:nosplit
func sync_runtime_procPin() int {
	return procPin()
}
//go:nosplit
func procPin() int {
	_g_ := getg()
	mp := _g_.m

	mp.locks++
	return int(mp.p.ptr().id)
}
//go:linkname sync_atomic_runtime_procUnpin sync/atomic.runtime_procUnpin
//go:nosplit
func sync_atomic_runtime_procUnpin() {
	procUnpin()
}
//go:nosplit
func procUnpin() {
	_g_ := getg()
	_g_.m.locks--
}

原子值 atomic.Value 提供了一种具备原子存取的结构。其自身的结构非常简单, 只包含一个存放数据的 interface{}:

type Value struct {
	v interface{}
}

它仅仅只是对要存储的值进行了一层封装。要对这个值进行原子的读取,依赖 Load 方法:

func (v *Value) Load() (x interface{}) {
	// 获得 interface 结构的指针
	// 在 go 中,interface 的内存布局有类型指针和数据指针两部分表示
	vp := (*ifaceWords)(unsafe.Pointer(v))
	// 获得存储值的类型指针
	typ := LoadPointer(&vp.typ)
	if typ == nil || uintptr(typ) == ^uintptr(0) {
		return nil
	}
	// 获得存储值的实际数据
	data := LoadPointer(&vp.data)

	// 将复制得到的 typ 和 data 给到 x
	xp := (*ifaceWords)(unsafe.Pointer(&x))
	xp.typ = typ
	xp.data = data
	return
}
// ifaceWords 定义了 interface{} 的内部表示。
type ifaceWords struct {
	typ  unsafe.Pointer
	data unsafe.Pointer
}

从这个 Load 方法实际上使用了 Go 运行时类型系统中的 interface{} 这一类型本质上由 两段内容组成,一个是类型 typ 区域,另一个是实际数据 data 区域。 这个 Load 方法的实现,本质上就是将内部存储的类型和数据都复制一份并返回。

再来看 Store。存储的思路与读取其实是类似的,但由于类型系统的两段式表示(typ 和 data) 的存在,存储操作比读取操作的实现要更加小心,要考虑当两个不同的 Goroutine 对两段值进行写入时, 如何才能避免写竞争:

func (v *Value) Store(x interface{}) {
	if x == nil {
		panic("sync/atomic: store of nil value into Value")
	}
	// Value 存储值的指针和要存储的 x 的指针
	vp := (*ifaceWords)(unsafe.Pointer(v))
	xp := (*ifaceWords)(unsafe.Pointer(&x))

	for {
		typ := LoadPointer(&vp.typ)

		// v 还未被写入过任何数据
		if typ == nil {
			// 禁止抢占当前 Goroutine 来确保存储顺利完成
			runtime_procPin()
			// 先存一个标志位,宣告正在有人操作此值
			if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(^uintptr(0))) {
				// 如果没有成功,取消不可抢占,下次再试
				runtime_procUnpin()
				continue
			}

			// 如果标志位设置成功,说明其他人都不会向 interface{} 中写入数据
			StorePointer(&vp.data, xp.data)
			StorePointer(&vp.typ, xp.typ)
			// 存储成功,再标志位可抢占,直接返回
			runtime_procUnpin()
			return
		}

		// 有其他 Goroutine 正在对 v 进行写操作
		if uintptr(typ) == ^uintptr(0) {
			continue
		}

		// 如果本次存入的类型与前次存储的类型不同
		if typ != xp.typ {
			panic("sync/atomic: store of inconsistently typed value into Value")
		}

		// 类型已经写入,直接保存数据
		StorePointer(&vp.data, xp.data)
		return
	}
}

可以看到 atomic.Value 的存取通过 unsafe.Pointer(^uintptr(0)) 作为第一次存取的标志位, 当 atomic.Value 第一次写入数据时,会将当前 Goroutine 设置为不可抢占, 并将要存储类型进行标记,再存入实际的数据与类型。当存储完毕后,即可解除不可抢占,返回。

在不可抢占期间,且有并发的 Goroutine 再此存储时,如果标记没有被类型替换掉, 则说明第一次存储还未完成,形成 CompareAndSwap 循环进行等待。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Generalzy

文章对您有帮助,倍感荣幸

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值