当前位置:首页 > 公众号精选 > CPP开发者
[导读]↓推荐关注↓本文是《C并发编程》一文的姊妹篇。将着重介绍C11标准引入的内存模型。前言在《C并发编程》一文中,我们已经介绍了C11到C17在并发编程方面的新增API。借助那篇文章中的知识,你应该已经可以开发一个完善的C并发系统。这对绝大部分人来说,是足够的了。但在一些情况下,我们...

推荐关注↓



本文是《C 并发编程》一文的姊妹篇。将着重介绍C 11标准引入的内存模型

前言

在《C 并发编程》一文中,我们已经介绍了C 11到C 17在并发编程方面的新增API。

借助那篇文章中的知识,你应该已经可以开发一个完善的C 并发系统。这对绝大部分人来说,是足够的了。

但在一些情况下,我们可能还需要走得更远。

回顾一下,上文中提到的知识是以互斥体为中心的。为了避免竞争条件,是保证任何时候只有一个线程可以进入临界区。这就存在两个问题:

  1. 可能会出现死锁
  2. 并发的效率不够
这其中,死锁的问题在上文中已经说过。而对于第二点,之所以并发效率不够高,是因为那些策略都是基于锁(lock-based)的:一旦有一个线程进入临界区,其他线程只能等待。

那有没有一种策略可以让其他线程不用等待,实现更好的并发呢?

答案是肯定的,这称之为免锁(lock-free)策略。不过实现这种策略要更麻烦一些,你要对C 内存模型有更深入的理解,而这也是本文所要讲解的内容。

关于免锁策略编程,读者也可以阅读Jeff Preshing大牛的这篇文章:《An Introduction to Lock-Free Programming》[1]

关于C 内存模型

2004年,Java 5.0引入了适用于多线程环境的内存模型[2]JSR-133[3]。但C 直到2011标准才引入了内存模型。

Java内存模型在很大程度上影响了C 内存模型,但后者走得更远。因为它允许开发者打破顺序一致性(Sequential Consistency,我们会在下文中讲解),以获得更好的控制。

之所以这么做是因为C 是一门系统编程语言,它的设计意图之一就是:不需要另外一个更底层的语言,而是直接提供给开发者以”接近机器“的方式编程。

即便大多数程序员不用在意内存模型,但是当你以“接近机器”的方式工作时,了解这些原理就很重要了。

内存模型是多线程环境能够可靠工作的基础,因为内存模型需要对多线程环境的运作细节进行完备的定义。

简单来讲,可以认为内存模型是一种契约。它定义一套操作手法以及这些操作手法背后的详细含义。开发者利用这套操作完成数据的同步以避免竞争条件,而系统(包括:编译器,操作系统和处理器)保证执行的逻辑符合内存模型对于相关操作的定义 – 即实现契约。

内存模型主要包含了下面三个部分:

  • 元子操作:顾名思义,这类操作一旦执行就不会被打断,你无法看到它的中间状态,它要么是执行完成,要么没有执行。
  • 操作的局部顺序:一系列的操作不能被乱序。
  • 操作的可见性:定义了对于共享变量的操作如何对其他线程可见。

为什么需要内存模型?

在C 11标准出来之前,C 环境没有多线程的概念。编译器和处理器认为系统中只有一个执行流。引入了多线程之后,情况就会变得非常复杂。这是因为:现代计算机系统为了加快执行效率,自动的包含了很多的优化。这些优化虽然保证了在单线程环境下不破坏原来的逻辑。但是一旦到了多线程之后,情况就不一样了。

事实上,开发者编写的代码和最终运行的程序往往会存在较大的差异,而运行结果与开发者预想一致,只是一种“假象”罢了。

之所以会产生差异,原因主要来自下面三个方面:

  • 编译器优化
  • CPU out-of-order执行
  • CPU Cache不一致性
下面我们来逐个介绍。

Memory Reorder

以下面这段伪代码为例:

X = 0, Y = 0;

Thread 1: 
X = 1; // ①
r1 = Y; // ②

Thread 2: 
Y = 1;
r2 = X;
你可能会觉得,在这个程序执行完成之后,r1r2怎么都不可能同时为0。但事实并非如此[4]

这是因为“Memory Reorder”的存在,“Memory Reorder”包含了编译器和处理器两种类型的乱序。

img
这就导致:线程1中事件发生的顺序虽然是先①后②,但是对于线程2来说,它看到结果可能却是先②后①。当然,线程1看线程2也是一样的。

甚至,当今的所有硬件平台,没有任何一个会提供完全的顺序一致(sequentially consistent)内存模型,因为这样做效率太低了。

不同的编译器和处理器对于Memory Reorder有不同的偏好,但它们都遵循一定的原则,那就是:不能修改单线程的行为Thou shalt not modify the behavior of a single-threaded program.[5])。在这个基础上,它们可以做各种类型的优化。

编译器优化

以gcc为例,该编译器提供了-o参数来控制非常多的优化选项[6]

以下面这段代码为例:

int A, B;

void foo()
{
    A = B   1;
    B = 0;
}
在编译优化后,可能会变成下面这样:

int A, B;

void foo()
{
    int temp = B;
    B = 0;
    A = temp   1;
}
请注意,编译器只要保证:在单线程环境下,执行的结果和原先一样就可以了。所以,这样做是可以的。

对于编译器来说,它知道的是:当前线程中,数据的读写以及数据之间的依赖关系。但是,编译器并不知道哪些数据是在线程间共享,而且是有可能会被修改的。这就需要开发者在软件层面做好控制。

对于编译器的乱序优化来说,开发者并非完全不能控制。编译器会提供称之为内存栅栏(Memory Barrier)[7]的工具给开发者,让开发者告诉编译器:这部分代码编译的时候不能乱序。

gcc的内存栅栏写法如下:

int A, B;

void foo()
{
    A = B   1;
    asm volatile("" ::: "memory");
    B = 0;
}

Out-of-order执行

不仅仅是编译器,处理器也可能会乱序执行指令。

下面是维基上给出的一张表格,列出了不同类型的CPU可能会执行的乱序类别。

img
从这个表格中可以看出,不同架构的CPU会有不同类型的Memory Reorder偏好。

我们使用的台式机和笔记本电脑基本上都是x86架构的CPU,而手机或者平板之类的移动设备一般用的是ARM架构的CPU。相较而言,前者的乱序类型要比后者少很多。

x86的内存模型叫做x86-TSO(Total Store Order),这可能是目前处理器中最强的内存模型之一。

下面这幅图是Preshing on Programming[8]一篇文章中给出的对比关系图。

img
由此我们可以推算,在多线程环境下,假设我们写的代码包含了未定义行为,那么这些问题在手机上将比在电脑上更容易暴露出来。

关于硬件的的内存模型,有兴趣的可以继续看下面几个链接:

  • Weak vs. Strong Memory Models[9]
  • This Is Why They Call It a Weakly-Ordered CPU[10]
  • A Tutorial Introduction to the ARM and POWER Relaxed Memory Models[11]
  • x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors[12]
类似的,处理器也会提供指令给开发者进行避免乱序的控制。例如,x86,x86-64上的fence指令:

lfence (asm), void _mm_lfence(void)
sfence (asm), void _mm_sfence(void)
mfence (asm), void _mm_mfence(void)
由此提醒我们:如果我们只以单线程的思维来开发并发系统,一旦引入了Memory Reorder之后就可能会发生问题。例如:以上面的AB两个变量为例,在编译器将其乱序后,虽然对于当前线程是没问题的。但是如果在此时刚好有另外一个线程使用这两个变量,并且依赖于它们的更新顺序,那么就会出现问题。

Cache Coherency

事情还不只这么简单。现代的主流CPU几乎都会包含多个核以及多级Cache,下图是我的MacBook Pro上的CPU Cache信息。

img
如果画成结构图,结构大概会像下面这样:

img
每个CPU核在运行的时候,都会优先考虑离自己最近的Cache,一旦命中就直接使用Cache中的数据。这是因为Cache相较于主存(RAM)来说要快很多。但是每个核之间的Cache,每一层之间的Cache,数据常常是不一致的。而同步这些数据是需要消耗时间的。

这就会造成一个问题,那就是:某个CPU核修改了一个数据,没有同步的让其他核知道,于是就存在了数据不一致的情况。

综上这些原因让我们知道,CPU所运行的程序和我们编写的代码可能是不一致的。甚至,对于同一次执行,不同线程感知到其他线程的执行顺序可能都是不一样的。

因此内存模型需要考虑到所有这些细节,以便让开发者可以精确控制。因为所有未定义的行为都可能产生问题。

对象和内存位置

C 内存模型中的基本存储单位是字节。一个字节至少足够大,能够包含基本执行字符集的任何成员以及Unicode UTF-8编码形式的八位代码单元,并且由连续的位序列组成。

C 中所有数据都是由对象组成的。

这里的对象包括了简单基本类型(如intdouble),也包括了指针类型(如my_class*)。当然,也包括各种class定义的类的对象。

无论是什么类型,一个对象均包含了一个或多个内存位置。每个内存位置一定是下面两种情况中的一种:

  • 标量类型(Scalar Type)

    的对象,标量类型包括下面几种:

    • 数字类型:整数或者浮点数
    • T *指针类型
    • 枚举类型
    • 指向成员的指针
    • nullptr_t
  • 相邻位域(Bit field)[13]的最大序列

位域

位域声明具有以“位”为单位的明确大小的类数据成员。相邻的位域成员可以打包成共享和跨过各个字节。

例如这样:

struct S {
 // 三位的无符号位域,
 // 允许值为 0...7
 unsigned int b : 3;
};
位域的值必须大于等于0。值0比较特殊,它仅允许使用在无名位域上。并且它具有特殊含义:它指定类定义中的下个位域将始于分配单元的边界。

由此,请看一下下面的例子:

struct S {
    char a;         // 内存位置 #1
    int  b : 5;     // 内存位置 #2
    int  c : 11,    // 内存位置 #2 (接续,相邻位域占用同一个内存位置)
           : 0,     // 无名位域,分隔了下一个位域
         d : 8;     // 内存位置 #3 (由于存在0值无名位域,这里是一个新的内存位置)
    struct {
        int ee : 8; // 内存位置 #4
    } e;
} obj;
可以看到,这个结构包含了4个内存位置。

之所以介绍内存位置,是因为这与内存模型密切相关。

如果多个线程各自访问的是不同的内存位置,那么就不会有什么问题。但是,如果它们同时访问了相同的内存位置,那就要小心了。

**当多个线程访问同一个内存位置,并且其中只要有一个线程包含了写操作,如果这些访问没有一致的修改顺序,那么结果就是未定义的。**也就是说:可能会发生bug。

修改顺序

我们已经知道,C 中的数据都是由对象组成。一个对象包含了若干个内存位置。

每个对象从初始化开始,直到最终销毁,在其生命周期的范围内,对它进行的访问必须有一个确定的修改顺序,这个顺序包含了所有线程的访问操作。

虽然程序的每一次运行,这个顺序可能是不一样的(例如:CPU资源的变化,调度器的影响),但是针对其中具体的某一次来说,必须有一个“一致的顺序”,这个顺序要被所有的线程认可,并且可见。

例如:一旦某个线程修改了一个数据,这个操作必须要让所有线程知道,在修改操作之后,所有线程都应该得到修改后的值。

从数据类型的角度来说,有两种情况:

  • 对于原子类型(见下文):由编译器保证数据的同步。
  • 对于非原子类型:由开发者保证。
《C 并发编程》[14]一文中,就是通过互斥体来对非原子类型数据进行数据同步的。

并发编程的难点之一就在于:识别出系统中那些在线程共享且可能会被修改的数据,并对它们做“合理”的保护。之所以强调这一点,是因为对于共享数据的保护本质上是在对抗编译器和处理器的优化,所以保护不能过度(在讲解并发编程的时候我们提到了锁的粒度)。

我们必须在保证正确性的基础上尽可能少的干扰编译器和处理器的优化:对于那些没有访问共享数据,或者对于所有线程来说都是只读的数据来说,这部分代码就任由编译器和处理器优化好了。

另外还有一点需要说明的是,这里说的是:对于每一个变量来说,要有明确的修改顺序。但是这并不要求所有的变量存在一个全局的一致顺序。这意味着,当将多个变量的访问顺序放在一起看的时候,不同线程看到的顺序可能是不一样的。

你现在可能会觉得这很难理解,在随着下文的讲解,相信你会更明白其中的含义。

关系术语

下面先来介绍C 内存模型中的几个关系术语。

sequenced-before

sequenced-before是一种单线程上的关系,这是一个非对称,可传递的成对关系。

对于两个操作A和B,如果A sequenced-before B,则A的执行应当在B的前面,并且A执行后的结果B也能看到,它引入了一个局部有序性。

同一个线程中的多个语句之间就是sequenced-before关系,例如:

int i = 7; // ①
i ;       // ②
这里的 ① sequenced-before ② 。

但是同一个语句中的多个子表达式上没有这个关系的。特别极端的,对于下面这个语句:

i = i    i;
由于等号右边的两个子表达式无法确定先后关系,因此这个语句的行为是未定义的。这意味着,你永远不应该写这样的代码。

happens-before

happens-before关系是sequenced-before关系的扩展,因为它还包含了不同线程之间的关系。

如果A happens-before B,则A的内存状态将在B操作执行之前就可见,这就为线程间的数据访问提供了保证。

同样的,这是一个非对称,可传递的关系。

如果A happens-before B,B happens-before C。则可推导出A happens-before C。

synchronizes-with

synchronizes-with描述的是一种状态传播(propagate)关系。如果A synchronizes-with B,则就是保证操作A的状态在操作B执行之前是可见的。

下文中我们将看到,原子操作的acquire-release具有synchronized-with关系。

除此之外,对于锁和互斥体的释放和获取可以达成synchronized-with关系,还有线程执行完成和join操作也能达成synchronized-with关系。

最后,借助 synchronizes-with 可以达成 happens-before 关系。

原子类型与原子操作

要理解内存模型,首先需要掌握C 11提供的原子类型(atomic types)和原子操作(atomic operation)。

原子类型不是一个类,而是一系列类,它们都位于头文件中。原子类型中包含了原子操作。但也有一些原子类型之外的原子操作。

下面是基本类型对应的原子类型。第一列是类型的别名(为了方便使用),第二列是类型的原始定义。

关于volatile和原子类型:Java和C 都有volatile关键字。但同样的关键字在不同的语言中有着不同的含义。Java中的volatile和C 的原子类型是类似的含义。而C 中的volatile是禁止编译器对这个变量进行优化。

类型别名类型定义
atomic_boolstd::atomic
atomic_charstd::atomic
atomic_scharstd::atomic
atomic_ucharstd::atomic
atomic_intstd::atomic
atomic_uintstd::atomic
atomic_shortstd::atomic
atomic_ushortstd::atomic
atomic_longstd::atomic
atomic_ulongstd::atomic
atomic_llongstd::atomic
atomic_ullongstd::atomic
atomic_char16_tstd::atomic
atomic_char32_tstd::atomic
atomic_wchar_tstd::atomic
除了上面这些,还有更多关于整形类型的原子类型,详见:cppreference std::atomic[15]

“原子操作”正如其名称所示:该操作要么是执行完了,要么是没有执行,从任何一个线程中,都无法观察到中间状态。以原子读操作为例:如果有其他线程进行了原子写操作,那么原子读操作,要么获取到的是修改前的值,要么是修改后的,不会是修改了一半的值。

而非原子类型就不一样了。如果尝试修改非原子类型对象,其他线程可能看到的既不是修改前的值,也不是修改后的值。关于这一点,在C 并发编程中,我们就看到了非原子类型所引起的问题。

需要注意的是,所有原子类型都不支持拷贝和赋值。因为该操作涉及了两个原子对象:要先从另外一个原子对象上读取值,然后再写入另外一个原子对象。而对于两个不同的原子对象上单一操作不可能是原子的。

不同的原子类型包含了不同的原子操作,下表将原子类型分为四类,并列出了它们所支持的操作(为了简洁,列名上类名中的atomic#代替)。

函数#_flag#_bool指针类型整形类型说明
test_and_setY


将flag设为true并返回原先的值
clearY


将flag设为false
is_lock_free
YYY检查原子变量是否免锁
load
YYY返回原子变量的值
store
YYY通过一个非原子变量的值设置原子变量的值
exchange
YYY用新的值替换,并返回原先的值
compare_exchange_weak compare_exchange_strong
YYY比较和改变值
fetch_add, =

YY增加值
fetch_sub, -=

YY减少值
, --

YY自增和自减
fetch_or, |=


Y求或并赋值
fetch_and,
本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除。
换一批
延伸阅读

9月2日消息,不造车的华为或将催生出更大的独角兽公司,随着阿维塔和赛力斯的入局,华为引望愈发显得引人瞩目。

关键字: 阿维塔 塞力斯 华为

加利福尼亚州圣克拉拉县2024年8月30日 /美通社/ -- 数字化转型技术解决方案公司Trianz今天宣布,该公司与Amazon Web Services (AWS)签订了...

关键字: AWS AN BSP 数字化

伦敦2024年8月29日 /美通社/ -- 英国汽车技术公司SODA.Auto推出其旗舰产品SODA V,这是全球首款涵盖汽车工程师从创意到认证的所有需求的工具,可用于创建软件定义汽车。 SODA V工具的开发耗时1.5...

关键字: 汽车 人工智能 智能驱动 BSP

北京2024年8月28日 /美通社/ -- 越来越多用户希望企业业务能7×24不间断运行,同时企业却面临越来越多业务中断的风险,如企业系统复杂性的增加,频繁的功能更新和发布等。如何确保业务连续性,提升韧性,成...

关键字: 亚马逊 解密 控制平面 BSP

8月30日消息,据媒体报道,腾讯和网易近期正在缩减他们对日本游戏市场的投资。

关键字: 腾讯 编码器 CPU

8月28日消息,今天上午,2024中国国际大数据产业博览会开幕式在贵阳举行,华为董事、质量流程IT总裁陶景文发表了演讲。

关键字: 华为 12nm EDA 半导体

8月28日消息,在2024中国国际大数据产业博览会上,华为常务董事、华为云CEO张平安发表演讲称,数字世界的话语权最终是由生态的繁荣决定的。

关键字: 华为 12nm 手机 卫星通信

要点: 有效应对环境变化,经营业绩稳中有升 落实提质增效举措,毛利润率延续升势 战略布局成效显著,战新业务引领增长 以科技创新为引领,提升企业核心竞争力 坚持高质量发展策略,塑强核心竞争优势...

关键字: 通信 BSP 电信运营商 数字经济

北京2024年8月27日 /美通社/ -- 8月21日,由中央广播电视总台与中国电影电视技术学会联合牵头组建的NVI技术创新联盟在BIRTV2024超高清全产业链发展研讨会上宣布正式成立。 活动现场 NVI技术创新联...

关键字: VI 传输协议 音频 BSP

北京2024年8月27日 /美通社/ -- 在8月23日举办的2024年长三角生态绿色一体化发展示范区联合招商会上,软通动力信息技术(集团)股份有限公司(以下简称"软通动力")与长三角投资(上海)有限...

关键字: BSP 信息技术
关闭
关闭