0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

处理器架构下并发编程里必须掌握的基础概念

lhl545545 来源:Linuxer 作者:Linuxer 2020-06-09 15:02 次阅读

现代计算机体系结构上,CPU执行指令的速度远远大于CPU访问内存的速度,于是引入Cache机制来加速内存访问速度。除了Cache以外,分支预测和指令预取也在很大程度上提升了CPU的执行速度。随着SMP的出现,多线程编程模型被广泛应用,在多线程模型下对共享变量的访问变成了一个复杂的问题。于是我们有必要了解一下内存模型,这是多处理器架构下并发编程里必须掌握的一个基础概念。

1. 什么是内存模型?

到底什么是内存模型呢?看到有两种不同的观点:

A:内存模型是从来描述编程语言在支持多线程编程中对共享内存访问的顺序。

B:内存模型的本质是指在单线程情况下CPU指令在多大程度上发生指令重排(reorder)[1]。

实际上A,B两种说法都是正确的,只不过是在尝试从不同的角度去说明memory model的概念。个人认为,内存模型表达为“内存顺序模型”可能更加贴切一点。

一个良好的memory model定义包含3个方面:

Atomic Operations

Partial order of operations

Visable effects of operations

这里要强调的是:我们这里所说的内存模型和CPU的体系结构、编译器实现和编程语言规范3个层面都有关系。

首先,不同的CPU体系结构内存顺序模型是不一样的,但大致分为两种:

ArchitectureMemory Model

x86_64Total Store Order

SparcTotal Store Order

ARMv8Weakly Ordered

PowerPCWeakly Ordered

MIPSWeakly Ordered

x86_64和Sparc是强顺序模型(Total Store Order),这是一种接近程序顺序的顺序模型。所谓Total,就是说,内存(在写操作上)是有一个全局的顺序的(所有人看到的一样的顺序), 就好像在内存上的每个Store动作必须有一个排队,一个弄完才轮到另一个,这个顺序和你的程序顺序直接相关。所有的行为组合只会是所有CPU内存程序顺序的交织,不会发生和程序顺序不一致的地方[4]。TSO模型有利于多线程程序的编写,对程序员更加友好,但对芯片实现者不友好。CPU为了TSO的承诺,会牺牲一些并发上的执行效率。

弱内存模型(简称WMO,Weak Memory Ordering),是把是否要求强制顺序这个要求直接交给程序员的方法。换句话说,CPU不去保证这个顺序模型(除非他们在一个CPU上就有依赖), 程序员要主动插入内存屏障指令来强化这个“可见性”[4]。ARMv8,PowerPC和MIPS等体系结构都是弱内存模型。每种弱内存模型的体系架构都有自己的内存屏障指令,语义也不完全相同。弱内存模型下,硬件实现起来相对简单,处理器执行的效率也高, 只要没有遇到显式的屏障指令,CPU可以对局部指令进行reorder以提高执行效率。

对于多线程程序开发来说,对并发的数据访问我们一般到做同步操作, 可以使用mutex,semaphore,conditional等重量级方案对共享数据进行保护。但为了实现更高的并发,需要使用内存共享变量做通信(Message Passing), 这就对程序员的要求很高了,程序员必须时时刻刻必须很清楚自己在做什么, 否则写出来的程序的执行行为会让人很是迷惑!值得一提的是,并发虽好,如果能够简单粗暴实现,就不要搞太多投机取巧!要实现lock-free无锁编程真的有点难。

其次,不同的编程语言对内存模型都有自己的规范,例如:C/C++Java等不同的编程语言都有定义内存模型相关规范。

2011年发布的C11/C++11 ISO Standard为我们带来了memory order的支持, 引用C++11里的一段描述:

The memory model means that C++ code now has a standardized

library to call regardless of who made the compiler and on

what platform it‘s running. There’s a standard way to control

how different threads talk to the processor‘s memory.[7]

memory order的问题就是因为指令重排引起的, 指令重排导致 原来的内存可见顺序发生了变化, 在单线程执行起来的时候是没有问题的, 但是放到 多核/多线程执行的时候就出现问题了, 为了效率引入的额外复杂逻辑的的弊端就出现了[8]。

C++11引入memory order的意义在于我们现在有了一个与运行平台无关和编译器无关的标准库, 让我们可以在high level languange层面实现对多处理器对共享内存的交互式控制。我们的多线程终于可以跨平台啦!我们可以借助内存模型写出更好更安全的并发代码。真棒,简直不要太优秀~

C11/C++11使用memory order来描述memory model, 而用来联系memory order的是atomic变量, atomic操作可以用load()和release()语义来描述。一个简单的atomic变量赋值可描述为:

atomic_var1.store (atomic_var2.load()); // atomic variables

vs

var1 = var2; // regular variables

为了更好地描述内存模型,有4种关系术语需要了解一下。

sequenced-before

同一个线程之内,语句A的执行顺序在语句B前面,那么就成为A sequenced-before B。它不仅仅表示两个操作之间的先后顺序,还表示了操作结果之间的可见性关系。两个操作A和操作B,如果有A sequenced-before B,除了表示操作A的顺序在B之前,还表示了操作A的结果操作B可见。例如:语句A是sequenced-before语句B的。

r2 = x.load(std::memory_order_relaxed); // A

y.store(42, std::memory_order_relaxed); // B

happens-before

happens-before关系表示的不同线程之间的操作先后顺序。如果A happens-before B,则A的内存状态将在B操作执行之前就可见。happends-before关系满足传递性、非自反性和非对称性。happens before包含了inter-thread happens before和synchronizes-with两种关系。

synchronizes-with

synchronizes-with关系强调的是变量被修改之后的传播关系(propagate), 即如果一个线程修改某变量的之后的结果能被其它线程可见,那么就是满足synchronizes-with关系的[9]。另外synchronizes-with可以被认为是跨线程间的happends-before关系。显然,满足synchronizes-with关系的操作一定满足happens-before关系了。

Carries dependency

同一个线程内,表达式A sequenced-before 表达式B,并且表达式B的值是受表达式A的影响的一种关系, 称之为“Carries dependency”。这个很好理解,例如:

int *a = &var1;

int *b = &var2;

c = *a + *b;

了解了上面一些基本概念,下面我们来一起学习一下内存模型吧。

2. C11/C++11内存模型

C/C++11标准中提供了6种memory order,来描述内存模型[6]:

enum memory_order {

memory_order_relaxed,

memory_order_consume,

memory_order_acquire,

memory_order_release,

memory_order_acq_rel,

memory_order_seq_cst

};

每种memory order的规则可以简要描述为:

枚举值定义规则

memory_order_relaxed不对执行顺序做任何保证

memory_order_consume本线程中,所有后续的有关本原子类型的操作,必须在本条原子操作完成之后执行

memory_order_acquire本线程中,所有后续的读操作必须在本条原子操作完成后执行

memory_order_release本线程中,所有之前的写操作完成后才能执行本条原子操作

memory_order_acq_rel同时包含memory_order_acquire和memory_order_release标记

memory_order_seq_cst全部存取都按顺序执行

下面我们来举例一一说明,扒开内存模型的神秘面纱。

2.1 memory order releaxed

relaxed表示一种最为宽松的内存操作约定,Relaxed ordering 仅仅保证load()和store()是原子操作, 除此之外,不提供任何跨线程的同步[5]。

std::atomic《int》 x = 0; // global variable

std::atomic《int》 y = 0; // global variable

Thread-1: Thread-2:

r1 = y.load(memory_order_relaxed); // A r2 = x.load(memory_order_relaxed); // C

x.store(r1, memory_order_relaxed); // B y.store(42, memory_order_relaxed); // D

上面的多线程模型执行的时候,可能出现r2 == r1 == 42。要理解这一点并不难,因为CPU在执行的时候允许局部指令重排reorder,D可能在C前执行。如果程序的执行顺序是 D -》 A -》 B -》 C,那么就会出现r1 == r2 == 42。

如果某个操作只要求是原子操作,除此之外,不需要其它同步的保障,那么就可以使用 relaxed ordering。程序计数器是一种典型的应用场景:

#include 《cassert》

#include 《vector》

#include 《iostream》

#include 《thread》

#include 《atomic》

std::atomic《int》 cnt = {0};

void f()

{

for (int n = 0; n 《 1000; ++n) {

cnt.fetch_add(1, std::memory_order_relaxed);

}

}

int main()

{

std::vector《std::thread》 v;

for (int n = 0; n 《 10; ++n) {

v.emplace_back(f);

}

for (auto& t : v) {

t.join();

}

assert(cnt == 10000); // never failed

return 0;

}

cnt是共享的全局变量,多个线程并发地对cnt执行RMW(Read Modify Write)原子操作。这里只保证cnt的原子性,其他有依赖cnt的地方不保证任何的同步。

2.2 memory order consume

consume要搭配release一起使用。很多时候,线程间只想针对有依赖关系的操作进行同步, 除此之外线程中其他操作顺序如何不关心,这时候就适合用consume来完成这个操作。例如:

b = *a;

c = *b

第二行的变量c依赖于第一行的执行结果,因此这两行代码是“Carries dependency”关系。显然,由于consume是针对有明确依赖关系的语句来限定其执行顺序的一种内存顺序, 而releaxed不提供任何顺序保证, 所以consume order要比releaxed order要更加地Strong。

#include 《thread》

#include 《atomic》

#include 《cassert》

#include 《string》

std::atomic《std::string*》 ptr;

int data;

void producer()

{

std::string* p = new std::string(“Hello”);

data = 42;

ptr.store(p, std::memory_order_release);

}

void consumer()

{

std::string* p2;

while (!(p2 = ptr.load(std::memory_order_consume)))

;

assert(*p2 == “Hello”); // never fires: *p2 carries dependency from ptr

assert(data == 42); // may or may not fire: data does not carry dependency from ptr

}

int main()

{

std::thread t1(producer);

std::thread t2(consumer);

t1.join();

t2.join();

}

assert(*p2 == “Hello”)永远不会失败,但assert(data == 42)可能会。原因是:

p2和ptr直接有依赖关系,但data和ptr没有直接依赖关系,

尽管线程1中data赋值在ptr.store()之前,线程2看到的data的值还是不确定的。

2.3 memory order acquire

acquire和release也必须放到一起使用。 release和acquire构成了synchronize-with关系,也就是同步关系。在这个关系下:线程A中所有发生在release x之前的值的写操作, 对线程B的acquire x之后的任何操作都可见。

#include 《thread》

#include 《atomic》

#include 《cassert》

#include 《string》

#include 《iostream》

std::atomic《bool》 ready{ false };

int data = 0;

std::atomic《int》 var = {0};

void sender()

{

data = 42; // A

var.store(100, std::memory_order_relaxed); // B

ready.store(true, std::memory_order_release); // C

}

void receiver()

{

while (!ready.load(std::memory_order_acquire)) // D

;

assert(data == 42); // never failed // E

assert(var == 100); // never failed // F

}

int main()

{

std::thread t1(sender);

std::thread t2(receiver);

t1.join();

t2.join();

}

上面的例子中:

sender线程中data = 42是sequence before原子变量ready的

sender和receiver在C和D处发生了同步

线程sender中C之前的所有读写对线程receiver都是可见的 显然, release和acquire组合在一起比release和consume组合更加Strong!

2.4 memory order release

release order一般不单独使用,它和acquire和consume组成2种独立的内存顺序搭配。

这里就不用展开啰里啰嗦了。

2.5 memory order acq_rel

acq_rel是acquire和release的叠加。中文不知道该咋描述好:

A read-modify-write operation with this memory order is both an acquire operation and a release operation. No memory reads or writes in the current thread can be reordered before or after this store. All writes in other threads that release the same atomic variable are visible before the modification and the modification is visible in other threads that acquire the same atomic variable.

大致意思是:memory_order_acq_rel适用于read-modify-write operation, 对于采用此内存序的read-modify-write operation,我们可以称为acq_rel operation, 既属于acquire operation 也是release operation. 设有一个原子变量M上的acq_rel operation:自然的,该acq_rel operation之前的内存读写都不能重排到该acq_rel operation之后, 该acq_rel operation之后的内存读写都不能重排到该acq_rel operation之前。 其他线程中所有对M的release operation及其之前的写入都对当前线程从该acq_rel operation开始的操作可见, 并且截止到该acq_rel operation的所有内存写入都对另外线程对M的acquire operation以及之后的内存操作可见[13]。

这里是一个例子,关于为什么要有acq_rel可以参考一下:

#include 《thread》

#include 《atomic》

#include 《cassert》

#include 《vector》

std::vector《int》 data;

std::atomic《int》 flag = {0};

void thread_1()

{

data.push_back(42);

flag.store(1, std::memory_order_release);

}

void thread_2()

{

int expected=1;

while (!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel)) {

expected = 1;

}

}

void thread_3()

{

while (flag.load(std::memory_order_acquire) 《 2)

;

assert(data.at(0) == 42); // will never fire

}

int main()

{

std::thread a(thread_1);

std::thread b(thread_2);

std::thread c(thread_3);

a.join(); b.join(); c.join();

}

2.6 memory order seq_cst

seq_cst表示顺序一致性内存模型,在这个模型约束下不仅同一个线程内的执行结果是和程序顺序一致的, 每个线程间互相看到的执行结果和程序顺序也保持顺序一致。显然,seq_cst的约束是最强的,这意味着要牺牲性能为代价。

atomic int x (0); atomic int y (0);

x. store (1, seq cst ); || y. store (1, seq cst );

int r1 = y.load( seq cst ); || int r2 = x.load( seq cst );

assert (r1 == 1 || r2 == 1);

下面是一个seq_cst的实例:

#include 《thread》

#include 《atomic》

#include 《cassert》

std::atomic《bool》 x = {false};

std::atomic《bool》 y = {false};

std::atomic《int》 z = {0};

void write_x()

{

x.store(true, std::memory_order_seq_cst);

}

void write_y()

{

y.store(true, std::memory_order_seq_cst);

}

void read_x_then_y()

{

while (!x.load(std::memory_order_seq_cst))

;

if (y.load(std::memory_order_seq_cst)) {

++z;

}

}

void read_y_then_x()

{

while (!y.load(std::memory_order_seq_cst))

;

if (x.load(std::memory_order_seq_cst)) {

++z;

}

}

int main()

{

std::thread a(write_x);

std::thread b(write_y);

std::thread c(read_x_then_y);

std::thread d(read_y_then_x);

a.join(); b.join(); c.join(); d.join();

assert(z.load() != 0); // will never happen

}

2.7 Relationship with volatile

人的一生总是充满了疑惑。

可能你会思考?volatile关键字能够防止指令被编译器优化,那它能提供线程间(inter-thread)同步语义吗?答案是:不能!!!

尽管volatile能够防止单个线程内对volatile变量进行reorder,但多个线程同时访问同一个volatile变量,线程间是完全不提供同步保证。

而且,volatile不提供原子性!

并发的读写volatile变量是会产生数据竞争的,同时non volatile操作可以在volatile操作附近自由地reorder。

看一个例子,执行下面的并发程序,不出意外的话,你不会得到一个为0的结果。

#include 《thread》

#include 《iostream》

volatile int count = 0;

void increase() {

for (int i = 0; i 《 1000000; i++) {

count++;

}

}

void decrease() {

for (int i = 0; i 《 1000000; i++) {

count--;

}

}

int main() {

std::thread t1(increase);

std::thread t2(decrease);

t1.join();

t2.join();

std::cout 《《 count 《《 std::endl;

}

3. Reference

The C/C++ Memory Model: Overview and Formalization

知乎专栏:如何理解C++的6种memory order

理解 C++ 的 Memory Order

理解弱内存顺序模型

当我们在谈论 memory order 的时候,我们在谈论什么

https://en.cppreference.com/w/cpp/atomic/memory_order

Youtube: Atomic’s memory orders, what for? - Frank Birbacher [ACCU 2017]

C++11中的内存模型下篇 - C++11支持的几种内存模型

memory ordering, Gavin’s blog

c++11 内存模型解读

memory barriers in c, MariaDB FOUNDATION, pdf

C++ memory order循序渐进

Memory Models for C/C++ Programers

Memory Consistency Models: A Tutorial
责任编辑:pj

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • cpu
    cpu
    +关注

    关注

    68

    文章

    10850

    浏览量

    211518
  • 计算机
    +关注

    关注

    19

    文章

    7484

    浏览量

    87841
  • 内存模型
    +关注

    关注

    0

    文章

    7

    浏览量

    6133
收藏 人收藏

    评论

    相关推荐

    Linux应用编程的基本概念

    Linux应用编程涉及到在Linux环境开发和运行应用程序的一系列概念。以下是一些涵盖Linux应用编程的基本概念
    的头像 发表于 10-24 17:19 220次阅读

    迅为3A6000_7A2000核心主板龙芯全国产处理器LoongArch架构

    ,也证明了国内有能力在自研 CPU 架构上做出一流的产品。 龙芯 3A6000 处理器采用龙芯自主指令系统龙架构(LoongArch),是龙芯第四代微架构的首款产品,主频达到 2.5G
    发表于 10-12 11:25

    对称多处理器和非对称多处理器的区别

    随着计算需求的日益增长,单处理器系统已经无法满足高性能计算的需求。多处理器系统应运而生,它们通过将多个处理器集成到一个系统中来提高计算能力。在多处理器系统中,有两种主要的
    的头像 发表于 10-10 15:58 815次阅读

    简述微处理器的指令集架构

    处理器的指令集架构(Instruction Set Architecture,ISA)是计算机体系结构中的核心组成部分,它定义了计算机能够执行的指令集合、数据类型、寄存、内存访问方式等,是连接
    的头像 发表于 10-05 14:59 414次阅读

    ARM处理器和CISC处理器的区别

    ARM处理器和CISC(复杂指令集计算机)处理器在多个方面存在显著的区别。这些区别主要体现在架构原理、性能与功耗、设计目标、应用领域以及市场生态等方面。
    的头像 发表于 09-10 11:10 445次阅读

    X86架构处理器有哪些优点和缺点

    X86架构处理器作为计算机领域的重要组成部分,具有多个显著的优点和一定的缺点。以下是对X86架构处理器优缺点的详细分析。
    的头像 发表于 08-22 11:25 1913次阅读

    处理器的指令集架构介绍

    和执行。指令集架构不仅影响微处理器的性能,还决定了其兼容性、可编程性和应用场景。以下是对微处理器指令集架构的详细探讨,内容将围绕其定义、主要
    的头像 发表于 08-22 10:53 1106次阅读

    嵌入式微处理器的分类 嵌入式微处理器的种类和型号

    的分类可以从多个角度进行划分,比如架构、指令集、用途等。下面将从这些角度详细介绍嵌入式微处理器的种类和型号。 一、架构分类 嵌入式微处理器架构
    的头像 发表于 05-04 16:31 2189次阅读

    嵌入式微处理器的分类特点 嵌入式微处理器作用

    领域,包括消费电子、工业控制、医疗设备、汽车电子等。 嵌入式微处理器可以根据其架构、指令集和性能等特点进行分类。常见的分类方法包括架构、工作频率、指令集和功能等。 从架构的角度来看,嵌
    的头像 发表于 04-21 14:52 1250次阅读

    嵌入式微处理器架构可分为

    嵌入式微处理器架构是指用于嵌入式系统的微处理器的设计和组织方式。嵌入式系统是指内嵌在其他电子设备中的计算机系统,它们通常用于实时控制、通信、处理数据等任务。嵌入式微
    的头像 发表于 04-21 14:39 1173次阅读

    嵌入式微处理器的基础是什么

    要求的硬件和软件技术。 在嵌入式微处理器的基础上,我们可以考虑以下几个方面进行详细讨论: 处理器架构和指令集:嵌入式微处理器通常在特定的指令集架构
    的头像 发表于 04-21 14:34 1089次阅读

    嵌入式微处理器有哪两种架构? 嵌入式微处理器技术的优缺点

    嵌入式微处理器是一种专门设计用于嵌入式系统应用的微处理器,通常集成了计算、控制和通信等功能。目前市场上存在两种主要的嵌入式微处理器架构,分别是CISC(复杂指令集计算机)和RISC(精
    的头像 发表于 04-21 09:54 1053次阅读

    dsp和嵌入式微处理器的区别和联系 嵌入式微处理器应用领域分析

    嵌入式微处理器(Embedded Microprocessor)和数字信号处理器(Digital Signal Processor,简称DSP)是两种常见的微处理器架构。它们在应用领域
    的头像 发表于 04-21 09:50 1604次阅读

    嵌入式微处理器有哪两种架构?区别是什么?

    嵌入式微处理器是一种专门设计用于控制嵌入式系统的微处理器。它集成了处理器核心、存储、输入输出接口等功能,可以实现对嵌入式系统的控制和运算。嵌入式微
    的头像 发表于 04-21 09:41 622次阅读

    嵌入式软件开发应该掌握哪些知识?

    -服务模型:了解基于客户端-服务模型的网络应用程序开发,包括并发服务的设计和实现。 3.高阶知识 3.1ARM 架构 ARM
    发表于 02-19 11:23