原子操作和互斥锁的区别

这个系列的文章里介绍了很多并发编程里经常用到的技术,除了Context、计时器、互斥锁还有通道外还有一种技术--原子操作在一些同步算法中会被用到。今天的文章里我们会简单了解一下Go语言里对原子操作的支持,然后探讨一下原子操作和互斥锁的区别。

文章的主要话题如下:

  • 原子操作

  • Go对原子操作的支持

  • 原子操作和互斥锁的区别

原子操作

原子操作即是进行过程中不能被中断的操作,针对某个值的原子操作在被进行的过程中,CPU绝不会再去进行其他的针对该值的操作。为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成。原子操作是无锁的,常常直接通过CPU指令直接实现。事实上,其它同步技术的实现常常依赖于原子操作。

Go对原子操作的支持

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

  • Go语言提供的原子操作都是非入侵式的。

  • 这些函数提供的原子操作共有五种:增减、比较并交换、载入、存储、交换。

  • 原子操作支持的类型类型包括int32、int64、uint32、uint64、uintptr、unsafe.Pointer。

下面的示例演示如何使用AddInt32函数对int32值执行添加原子操作。在这个例子中,main goroutine创建了1000个的并发goroutine。每个新创建的goroutine将整数n加1。

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

func main() {
    var n int32
      var wg sync.WaitGroup
      for i := 0; i < 1000; i++ {
            wg.Add(1)
            go func() {
                  atomic.AddInt32(&n, 1)
                  wg.Done()
            }()
      }
      wg.Wait()

    fmt.Println(atomic.LoadInt32(&n)) // output:1000
}

上面的例子里你们可以自己试验一下,如果我们不使用atomic.AddInt32(&n, 1)而是简单的对变量n进行自增的话得到结果并不是我们预期的1000,这就是我们在文章《Go并发编程里的数据竞争以及解决之道》里提到过的数据竞争问题,原子操作可确保这些goroutine之间不存在数据竞争。

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

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

调用函数后,CompareAndSwap函数会先判断参数addr指向的操作值与参数old的值是否相等,仅当此判断得到的结果是true之后,才会用参数new代表的新值替换掉原先的旧值,否则操作就会被忽略。

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

package main

import (
    "fmt"
    "sync/atomic"
)

var value int32 = 1

func main()  {
    fmt.Println("======old value=======")
    fmt.Println(value)
    fmt.Println("======New value=======")
    fmt.Println(value)

}

//不断地尝试原子地更新value的值,直到操作成功为止
func addValue(delta int32){
    for {
        v := value
        if atomic.CompareAndSwapInt32(&value, v, (v + delta)){
            break
        }
    }
}

上面的比较并交换案例中 v:= value为变量v赋值,但要注意,在进行读取value的操作的过程中,其他对此值的读写操作是可以被同时进行的,那么这个读操作很可能会读取到一个只被修改了一半的数据。所以 我们要使用sync/atomic代码包中为我们提供的以Load为前缀的函数,来避免这样的糟糕事情发生。

竞争条件是由于异步的访问共享资源,并试图同时读写该资源而导致的,使用互斥锁和通道的思路都是在线程获得到访问权后阻塞其他线程对共享内存的访问,而使用原子操作解决数据竞争问题则是利用了其不可被打断的特性。

关于atomic包更详细的使用介绍可以访问官方的sync/atomic中文文档:https://go-zh.org/pkg/sync/atomic/

原子操作与互斥锁的区别

互斥锁是一种数据结构,使你可以执行一系列互斥操作。而原子操作是互斥的单个操作,这意味着没有其他线程可以打断它。那么就Go语言里atomic包里的原子操作和sync包提供的同步锁有什么不同呢?

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

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

所以总结下来原子操作与互斥锁的区别有:

  • 互斥锁是一种数据结构,用来让一个线程执行程序的关键部分,完成互斥的多个操作。

  • 原子操作是针对某个值的单个互斥操作。

  • 可以把互斥锁理解为悲观锁,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

atomic包提供了底层的原子性内存原语,这对于同步算法的实现很有用。这些函数一定要非常小心地使用,使用不当反而会增加系统资源的开销,对于应用层来说,最好使用通道或sync包中提供的功能来完成同步操作。

针对atomic包的观点在Google的邮件组里也有很多讨论,其中一个结论解释是:

应避免使用该包装。或者,阅读C ++ 11标准的“原子操作”一章;如果您了解如何在C ++中安全地使用这些操作,那么你才能有安全地使用Go的sync/atomic包的能力。

推荐阅读:

上周并发题的解题思路以及介绍Go语言调度器

<think>嗯,用户想了解如何使用原子操作实现互斥锁。这个问题涉及并发编程的核心机制,需要仔细解释清楚原理实现步骤。 用户可能正在学习操作系统或并发编程,需要理解底层同步原语的实现方式。从问题表述看,用户应该已经了解基本概念,现在想深入实现层面。这类知识对开发高性能并发系统很重要。 实现互斥锁的关键在于原子操作,特别是CAS(Compare-And-Swap)指令。CAS允许我们原子地检查并修改内存值,这是构建锁的基础。让我想想如何清晰地解释这个机制。 首先需要理解互斥锁的基本要求:同一时刻只能有一个线程进入临界区。这需要两个核心状态:锁定状态等待队列。但原子操作实现的简易版本通常只处理锁定状态,等待功能需要其他机制配合。 参考引用[3]中提到的自旋锁实现是个很好的起点:通过循环尝试CAS操作直到成功获取锁。不过这个实现会导致忙等待(busy-waiting),在竞争激烈时浪费CPU资源。 完整的实现应该包括以下部分: 1. 定义锁结构体,包含原子标志位 2. 加锁函数:循环尝试将标志从0改为1 3. 解锁函数:简单将标志重置为0 4. 考虑内存顺序问题,避免指令重排导致错误 用户可能还会关心性能优化。比如在加锁失败时让出CPU(使用sched_yield),或者使用更高级的队列锁算法(如MCS锁)。不过这些属于进阶内容,初次实现可以简化。 需要特别注意ABA问题:虽然CAS能保证原子性,但如果标志位在检查期间经历了0->1->0的变化,简单的CAS实现会错误地获取锁。不过对于互斥锁标志位,这个场景通常不会造成问题,因为锁被释放后其他线程可以合法获取。 最后要提醒用户,这种自旋锁适用于临界区很短的情况。对于长时间持有的锁,应该考虑使用系统提供的互斥锁,它们通常会在等待时挂起线程,避免CPU浪费。</think>### 使用原子操作实现互斥锁的方法 互斥锁的核心目标是**确保临界区的独占访问**。通过原子操作(如 **CAS**)可实现简易的自旋锁(Spinlock),其本质是一种忙等待的互斥锁。以下是实现步骤代码示例: #### 核心原理 1. **原子标志位**:使用一个原子变量(如 `flag`)表示锁状态(`0`=未锁定,`1`=锁定)。 2. **CAS 操作**:加锁时循环尝试将 `flag` 从 `0` 原子地改为 `1`,若成功则获得锁;解锁时直接将 `flag` 重置为 `0`。 #### C++ 实现示例 ```cpp #include <atomic> class SpinlockMutex { private: std::atomic_flag flag = ATOMIC_FLAG_INIT; // 原子标志位 public: void lock() { // 循环尝试直到成功获取锁 while (flag.test_and_set(std::memory_order_acquire)) { // 可选优化:减少CPU占用(如调用线程暂停指令) #ifdef __x86_64__ __builtin_ia32_pause(); // x86架构的PAUSE指令 #endif } } void unlock() { flag.clear(std::memory_order_release); // 释放锁 } }; ``` #### 关键机制详解 1. **加锁过程**: - `test_and_set()` 是原子操作:**读取当前值并同时设置为 `true`**。 - 若返回值为 `false`(表示锁原状态为未锁定),则线程成功获取锁。 - 若返回值为 `true`(表示锁已被占用),线程在循环中忙等待(自旋)。 2. **内存序参数**: - `memory_order_acquire`:确保**加锁后**的临界区操作不会被重排到加锁前。 - `memory_order_release`:确保**解锁前**的临界区操作不会被重排到解锁后。 - 二者共同构成 **happens-before** 关系,保证数据访问安全[^3][^4]。 3. **优化点**: - **PAUSE 指令**:在 x86 架构中插入暂停指令,减少自旋时的 CPU 功耗及总线争用。 - **指数退避**:在多次自旋失败后主动让出 CPU(如 `std::this_thread::yield()`),避免过度占用资源。 #### 适用场景与局限性 | **场景** | **局限性** | |-------------------|-------------------------------| | 临界区代码极短(如计数器) | 长时间自旋浪费 CPU 资源 | | 内核/无阻塞系统编程 | 线程优先级反转风险 | | 低竞争环境 | 高竞争时性能急剧下降[^1][^4] | > ⚠️ **注意**: > 1. 自旋锁在用户空间编程中应谨慎使用,系统级互斥锁(如 `std::mutex`)通常更高效且公平。 > 2. 复杂场景(如嵌套锁、条件变量)需结合队列调度机制(如 MCS 锁)[^3]。 --- ### 相关问题 1. **自旋锁与互斥锁的性能差异主要体现在哪些场景?** 2. **如何用原子操作实现支持超时功能的互斥锁?** 3. **为什么高竞争环境下自旋锁可能降低整体系统吞吐量?** 4. **CAS 操作在哪些情况下会出现 ABA 问题?对锁实现有何影响?** [^1]: 原子操作在频繁变更场景下的劣势(乐观假设易失败)。 [^3]: 基于 CAS 的自旋锁基础实现原理。 [^4]: 原子操作互斥锁的适用场景对比(简单状态 vs 复杂临界区)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值