文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

关于多线程的一切:原子操作

2024-12-01 16:54

关注

接上篇《​​关于多线程同步的一切:伪共享​​》

原子,意味着不可切分的最小单元,程序中的原子操作指任务不可切分到更小的步骤。

原子性(atomic)是一个可见性的概念:

当我们称一个操作是atomic的,实际上隐含了一个对什么atomic的上下文。

注意:我们说的是从线程视角观察不到完成一半的状态,而并非不存在物理上的进度状态,它取决于你的观察视角。

比如说一个线程中被互斥锁保护的区域,对另一个线程是atomic的,因为从另一个线程视角来看,它没法进入临界区读到数据中间状态,但是对kernel而言却不是atomic的。

从线程视角只能观察到未做和已做两种状态,观察不到完成一半的状态,任务执行不会被中断,也不会穿插进其他操作。

原子性对多线程操作是一个非常重要的属性,因为它不可切分,所以,一个线程没法在另一个线程执行原子操作的时候穿插进去。

比如一个线程原子的写入共享数据,那么其他线程没有办法读到“半修改的数据”;同样,如果一个线程原子读取共享数据,那么它读取的是共享变量在那个瞬间的值,因此原子的读和写没有数据竞争(Data Race)。

原子操作常用于与顺序无关的场景。

原子指令

原子指令指单一的不可再分的不可中断的被硬件直接执行的机器指令,原子指令是无锁编程的基石。

原子指令常被分成两类:

Store/Load指令

通常,一条简单的store/load机器指令是原子的,比如数据复制指令(mov)可以把内存位置的数据读取到CPU寄存器,相当于Load数据。

x86架构读/写“按数据类型对齐要求对齐的长度不大于机器字长的数据”是原子的。

那什么是数据类型对齐要求呢?

比如在x86_64架构LLP64系统上(LLP64指long、long long和pointer类型是64位的),只要int32类型数据满足放置在起始地址除4为0,int64/long类型数据满足起始地址除8为0,则该数据就是满足类型对齐要求,那么对它的读和写,都是原子的。

一字节的数据读写一定是原子的。

其实,Intel新CPU架构确保读写放置在一个Cache Line的数据(不大于机器字长),跨Cache Line的数据访问无法保证原子性。

C/C++编程中,变量和结构体会自动满足对齐要求,比如:

int i;

void f() {
long y;
}

struct Foo {
int x;
short s;
void* ptr;
} foo;

全局变量i会被放置在起始地址可以被4整除的内存位置,局部变量y会被放置在起始地址可以被8整除的内存位置,而结构体内的x成员会被放置在起始地址可以被4整除的内存位置。

为了把ptr安置在起始地址可以被8整除的内存位置,编译器会在s后加入填充,从而使得ptr也满足对齐要求。

通过C malloc()接口动态分配的内存,其返回值一般也会对齐到8/16字节,如果有更高的内存对齐要求,可以通过aligned_alloc(alignment, size)接口。C++中的alignas关键字用于设置结构或变量的对齐要求。

对一个满足对齐要求的不大于机器字长的类型变量赋值是原子的,不会出现半完成(即只完成一半字节的赋值),读的情况亦如此。

注意:对长度大于机器字长的数据读写,是不符合原子操作特征的,比如在x86_64系统上,对下面结构体变量的读写都是非原子的:

struct Foo {
int a;
int b;
int c;
} foo1;

void set_foo1(const Foo& f) {
foo1 = f;
}

foo1包含3个int成员共12字节,大于机器字长8字节,所以对`foo1 = f`不是原子的。

基于以上知识,我们便知道,一些getter/setter接口,即使在多线程环境下,也可以不用加锁,比如:

struct Foo {
size_t get_x() const { // OK
return x;
}

void set_y(float y) { // OK
this->y = y;
}

size_t x;
float y;
};

int main() {
char buf[8];
Foo* f = (Foo*)buf;
f->set(3.14); // dang
}

但是,如果你把一块buf,强转成Foo,然后调用它的getter/setter,则是危险的,有可能破坏前述的对齐要求。

如果你把一个int变量编码进一个buf,则最好使用memcpy,而不是强转+赋值。

Read-Modify-Write指令

但有时候,我们需要更复杂的操作指令,而不仅仅是单独的读或写,它需要把几个动作组合在一起完成某项任务。

比如语句`++count`对应到“读+修改+写”三个操作,但这3个操作不是一个原子操作。所以,多线程程序中使用`++count`,多个执行流会交错执行,会导致计数错误(通常结果比预期数值小)。

考虑另一个情况:读+判断,来我们看一下经典单件实现:

class Singleton {
static Singleton* instance;
public:
static Singleton* get_instance() {
if (instance == nullptr) {
instance = new Singleton;
}
return instance;
}
};

因为对instance的判断和`instance = new Singleton`不是原子的,所以,我们需要加锁:


class Singleton {
static Singleton* instance;
static std::mutex mutex;
public:
static Singleton* get_instance() {
mutex.lock();
if (instance == nullptr)
instance = new Singleton;
mutex.unlock();
return instance;
}
};

但为了性能,更好的方案是加双检,代码变成下面这样:

static Singleton* get_instance() {
if (instance == nullptr) {
mutex.lock();
if (instance == nullptr) { // 双检
instance = new Singleton;
}
mutex.unlock();
return instance;
}
return instance;
}

第一个检查,如果instance不为空,那么直接返回instance,大多数时候命中这个情况,因为instance一旦被创建,就不再为空。

如果instance为空,那么再加锁、然后第二次检查instance是否为空,为什么要双检呢?因为前面的检查通过后,有可能其他线程创建了实例,导致instance不再为空。

看起来一切都挺好的,高效又缜密。

但双检真的安全吗?这其实是一个非常经典的问题。它有2个风险:

逻辑上,需要几个操作是一个密不可分的整体,现代CPU通常都直接提供这类原子指令的支持,这类RMW原子指令通常包括:

以上所有操作都是在一个内存位置执行多个动作,但这些操作都是原子单步的,它不会被中断,也不会穿插进其他操作,这个重要属性使得RMW指令非常适合用来实现无锁编程。

虽然CPU在执行机器指令的时候,会把它分成更小粒度的微指令(micro-operations),但程序员应把关注点放在微指令上层的原子指令上。

原子操作

前面讲的原子指令是硬件层面,不同架构甚至不同型号CPU有不同的原子指令,它是CPU层面的东西,跨平台特性差,用它编写的代码不可移植,所以应该尽量避免直接使用原子指令。

回到软件层面,软件层面的原子操作包括三个层次:

(1) 操作系统层面,linux操作系统提供atomic这种原子类型,配合相关的编程接口使用,大多数它只是对原子指令的简单封装,但它屏蔽了硬件差异,比原子指令更易用​:

   atomic_read(atomic_t *v)
atomic_set(atomic_t *v, int i)
atomic_inc(atomic_t *v)
atomic_dec(atomic_t *v)
atomic_add(int i, atomic_t *v)
atomic_sub(int i, atomic_t *v)
atomic_inc_and_test(atomic_t *v)
atomic_dec_and_test(atomic_t *v);
atomic_sub_and_test(int i, atomic_t *v)

(2) 编译器层面,gcc提供原子操作build-in函数,使用gcc编译c/c++代码,可以直接使用它们​:

   //其中type对应8/16/32/64位整数
type __sync_fetch_and_add (type *ptr, type value, ...)
type __sync_fetch_and_sub (type *ptr, type value, ...)
type __sync_fetch_and_or (type *ptr, type value, ...)
type __sync_fetch_and_and (type *ptr, type value, ...)
type __sync_fetch_and_xor (type *ptr, type value, ...)
type __sync_fetch_and_nand (type *ptr, type value, ...)
type __sync_add_and_fetch (type *ptr, type value, ...)
type __sync_sub_and_fetch (type *ptr, type value, ...)
type __sync_or_and_fetch (type *ptr, type value, ...)
type __sync_and_and_fetch (type *ptr, type value, ...)
type __sync_xor_and_fetch (type *ptr, type value, ...)
type __sync_nand_and_fetch (type *ptr, type value, ...)

gcc在实现C++11之后,新的原子接口,以__atomic为前缀,推荐使用下面这些接口:

   type __atomic_add_fetch(type *ptr, type val, int memorder)
type __atomic_sub_fetch(type *ptr, type val, int memorder)
type __atomic_and_fetch(type *ptr, type val, int memorder)
type __atomic_xor_fetch(type *ptr, type val, int memorder)
type __atomic_or_fetch(type *ptr, type val, int memorder)
type __atomic_nand_fetch(type *ptr, type val, int memorder)
type __atomic_fetch_add(type *ptr, type val, int memorder)
type __atomic_fetch_sub(type *ptr, type val, int memorder)
type __atomic_fetch_and(type *ptr, type val, int memorder)
type __atomic_fetch_xor(type *ptr, type val, int memorder)
type __atomic_fetch_or(type *ptr, type val, int memorder)
type __atomic_fetch_nand(type *ptr, type val, int memorder)

(3) 编程语言层面,也通常提供原子操作类型和接口,这也是使用原子操作的推荐方式,它有良好的跨平台性和可移植性,程序员应优先使用它们:

来源:码砖杂役内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯