首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >内存序不再晦涩!用“锁语义”给你讲透

内存序不再晦涩!用“锁语义”给你讲透

作者头像
程序员的园
发布2025-05-15 13:40:33
发布2025-05-15 13:40:33
20000
代码可运行
举报
运行总次数:0
代码可运行

随着多核处理器的发展,多线程编程成为了一种常见的编程方式。但是,多线程编程必然面临数据同步问题,锁作为常见且易用的同步机制,在多线程编程中扮演着重要的角色。但是,锁的开销大,性能较差。原子变量作为一种低开销的同步机制,在多线程编程中也扮演着重要的角色。

但是原子变量尤其是内存序晦涩难懂,无形中制造了学习障碍。我之前也写过很多关于原子变量的文章:

内存模型的内存序选择技巧

原子变量——内存模型

原子变量——原子操作

原子变量一

但是回过头来看,也仅仅停留在了介绍怎么用的基础上,并不能帮助理解。所以,我决定抛砖引玉,写这一篇文章,用于理解性记忆并使用原子变量。

代码执行顺序 ≠ 编写顺序

不要想当然的认为代码的书写顺序就是代码的执行顺序,代码的书写顺序和代码的执行顺序是两个不同的概念。 书写的代码到运行中的从程序,避免经过BuildRun两个阶段。而这两个阶段均涉及到代码顺序的调整:

  • Build:源代码经历编译、汇编、链接等过程,最终生成可执行程序。这个过程中编译器会对源代码进行一系列的优化(尤其是开启优化时),这些优化中就包含了指令重排。所谓指令重排就是编译器在不改变代码逻辑的情况下,调整指令的执行顺序,以提高程序的性能。
  • Run:处理器也可以对代码的执行顺序进行调整,即乱序执行。在多核处理器上,某个线程看到的的顺序结果可能和另一个线程看到的不一样。

虽然指令重排和乱序执行是为了提高程序性能,不会乱来,但是也带来了困扰——无法确认代码的执行顺序。尤其是在多线程的情况下,这个影响变得更加严重。

在多线程环境下,为了保证变量更改的可见性,避免数据竞态,通常会对共享数据加锁,以保证同一时间只有一个线程可以访问共享数据。

代码语言:javascript
代码运行次数:0
运行
复制
std::mutex mtx;
int value = 0;

void thread_func() {
    mtx.lock();
    value++;
    mtx.unlock();
}

int main() {
    std::thread t1(thread_func);
    std::thread t2(thread_func);
    t1.join();
    t2.join();
    std::cout << "value: " << value << std::endl;
    return0;
}

锁通过加锁lock解锁unlock操作(RAII原理的各种guard类似),来保证同一时间只有一个线程可以访问共享数据。那么它是如何做到的呢?

  • lock()方法:lock()方法之后的代码不会重排到lock()方法之前,同时,其他线程unlock之前的所有操作对本线程lock之后的代码是可见的;这就是获得语义
  • unlock()方法:unlock()方法之前的代码不会重排到unlock()方法之后,同时,其他线程lock之后的代码可以看到本线程unlock前的所有操作。这就是释放语义

所得学习成本低,很多同学必定知道如上的两个流程,那么如上的流程是否可以迁移到我们理解原子变量,尤其是原子序呢?答案是可以的。

原子变量

原子变量只能够保证操作的原子性,如果需要进行多线程同步,则需要借助内存序,接下来将分别介绍原子操作和内存序。

原子操作

原子变量支持的操作有三类:读-改-写。原子操作只能保证操作的原子性——不可被打断,同时保证同一线程对同一原子变量的访问顺序不会重排。

  • :读取变量的值,在读取的过程中,其他线程不能对变量进行修改。
  • :写入变量的值,在写入的过程中,其他线程不能看到部分写入的结果。
  • 读-改-写:读取、修改、写入变量的值,确保读、改和写不会被中断,且具备完整的同步语义。

内存序

锁实现的同步是借助获得——释放语义实现的,原子变量的线程同步则依赖内存序——获得语义释放语义。C++11标准中定义了六种内存序,分别是:

  • memory_order_relaxed:宽松内存序,不提供获得——释放语义,只提供基本保证——原子性和同一线程对同一原子变量的访问顺序。个人不推荐使用
  • memory_order_consume:消费者内存序,C++17明确建议不要使用,C++26中已被废弃。个人建议永远不要用
  • memory_order_acquire:获得语义,在读原子变量时,当前线程的所有后续操作不会重排到加载操作之前,同时,其他线程释放同一原子变量之前的所有操作对本线程可见。
  • memory_order_release:释放语义,在写原子变量时,当前线程的所有之前的操作不会重排到写操作之后,同时,当前线程写之前的操作对其他加载同一原子变量的线程可见。
  • memory_order_acq_rel:获得——释放语义,在读-改-写原子变量时,当前线程的前后任何读写操作不会跟该操作重排,并且,其他线程释放同一原子变量之前的所有写入操作对本线程可见(acquire),当前线程释放之前的所有写入操作对其他加载同一原子变量的线程可见(release)。
  • memory_order_seq_cst:顺序一致内存序,它是最严格的语义,也是默认语义。在加载时相当于memory_order_acquire,在存储时相当于memory_order_release,在加载——修改——存储时相当于memory_order_acq_rel。

如上的内存序都与获得和/或释放语义有关,我们又知道如何使用锁,那我们就可以借助锁的获得——释放逻辑来应用原子变量的内存序。

代码语言:javascript
代码运行次数:0
运行
复制
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> x{0};
std::atomic<int> y{0};

void write_x_then_y() {
    x.store(1, std::memory_order_relaxed); // 先写x
    y.store(1, std::memory_order_release); // 再写y,并用release语义发布
}

void read_y_then_x() {
    while (y.load(std::memory_order_acquire) != 1) {
        // 自旋等待y被写入
    }
    // 由于acquire语义,读到y==1后,保证此线程能看到x==1
    if (x.load(std::memory_order_relaxed) == 1) {
        std::cout << "观察到x==1,顺序被保证" << std::endl;
    } else {
        std::cout << "未观察到x==1,顺序未被保证" << std::endl;
    }
}

int main() {
    x = 0;
    y = 0;
    std::thread t1(write_x_then_y);
    std::thread t2(read_y_then_x);
    t1.join();
    t2.join();
    return0;
}
//output
// 观察到x==1,顺序被保证

如上代码借助原子变量y的memory_order_releasememory_order_acquire,实现了获得——释放语义,进行保证了变量x的同步。

使用建议

对于原子变量的使用建议很简单,就2条:

  • 如果不是非必要,不要用原子变量;
  • 如果不知道使用哪个原子序,就用默认的。

总结

本文从锁的lock和unlock方法开始,引入了获得——释放语义,然后介绍了原子变量的原子操作和内存序,期望能够在理解锁的基础上,理解原子变量的内存序。最后提出了原子变量的使用建议。

参考文献

  • 《C++实战 核心技术与最佳实践》吴咏炜著
  • 《C++并发编程实战》 Anthony Williams著 吴天明 译
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-05-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 程序员的园 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 代码执行顺序 ≠ 编写顺序
  • 原子变量
    • 原子操作
    • 内存序
  • 使用建议
  • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档