当前位置: 首页 > 科技观察

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

时间:2023-03-14 10:33:19 科技观察

接上篇《??关于多线程同步的一切:伪共享??》原子是指不可分割的最小单位。程序中的原子操作意味着任务不能被分割成更小的步骤。原子性是一个可见性的概念:当我们称一个操作为原子时,它实际上暗示了一个什么是原子的上下文。注意:我们说的是线程视角观察不到的半完成状态,不是说没有物理进度状态,要看你的观察视角。例如,一个线程中互斥量保护的区域对于另一个线程来说是原子的,因为从另一个线程的角度来说,它不能进入??临界区读取数据的中间状态,但是对于内核来说就不是原子的了。从线程的角度来看,只能观察到未完成和完成两种状态,无法观察到半完成状态。任务执行不会被打断,也不会穿插其他操作。原子性对于多线程操作来说是一个非常重要的属性,因为它是不可分割的,所以一个线程不能穿插在另一个线程执行原子操作的时候。例如,如果一个线程原子地写入共享数据,其他线程无法读取“修改一半的数据”;类似地,如果一个线程原子地读取共享数据,它读取的是那一刻共享变量的值,所以原子读写不存在数据竞争。原子操作通常用于与顺序无关的场景。原子指令原子指令是指由硬件直接执行的单个不可分割、不可中断的机器指令。原子指令是无锁编程的基石。原子指令通常分为两类:store/loadread-modify-write(RMW)Store/Load指令store:将数据存储到内存,对应变量写入(赋值)load:从内存加载数据,对应变量读取通常,一个简单存储/加载机器指令是原子的。例如数据复制指令(mov)可以将内存位置的数据读到CPU寄存器中,相当于Loaddata。x86架构读/写“按照数据类型对齐且长度不大于机器字长的数据”是原子的。那么什么是数据类型对齐要求呢?比如在x86_64架构的LLP64系统上(LLP64是指long、longlong和指针类型都是64位的),只要int32类型的数据满足首地址除以4为0,int64/long类型的数据就满足首地址除以8为0,数据满足类型对齐要求,读写是原子的。一个字节数据的读写必须是原子的。事实上,Intel新的CPU架构保证了存储在一个CacheLine中的数据被读取和写入(不大于机器字长),跨CacheLine的数据访问不能保证原子性。在C/C++编程中,变量和结构会自动满足对齐要求,例如:inti;voidf(){longy;}structFoo{intx;短裤;void*ptr;}foo;全局变量i会被放置在起始地址能被4整除的内存位置,局部变量y会被放置在起始地址能被8整除的内存位置,结构体中的x成员会被放置在其起始地址可被4整除的内存Location。为了将ptr放在一个起始地址能被8整除的内存位置,编译器会在s后面加上padding,这样ptr也满足对齐要求。通过Cmalloc()接口动态分配的内存的返回值一般是8/16字节对齐。如果有更高的内存对齐要求,可以使用aligned_alloc(alignment,size)接口。C++中的alignas关键字用于设置结构或变量的对齐要求。对满足对齐要求且不大于机器字长的类型变量的赋值是原子的,不会有半完成(即只完成半字节赋值),对于阅读。注意:读写长度大于机器字长的数据不符合原子操作的特点。例如,在x86_64系统上,以下结构变量的读写是非原子的:structFoo{inta;诠释乙;intc;}foo1;voidset_foo1(constFoo&f){foo1=f;}foo1包含3个int成员,共12个字节,比机器字长大8个字节,所以它对`foo1不是原子的=f`。基于以上知识,我们知道有些getter/setter接口即使在多线程环境下也不需要加锁,如:structFoo{size_tget_x()const{//OKreturnx;}voidset_y(floaty){//OKthis->y=y;}size_tx;floaty;};intmain(){charbuf[8];Foo*f=(Foo*)buf;f->设置(3.14);//dang}但是,如果你将一块buf投射到Foo中,然后调用它的getter/setter,这是很危险的,可能会破坏上述对齐要求。如果将一个int变量编码成一个buf,最好使用memcpy而不是cast+assignment。Read-Modify-Write指令但有时,我们需要更复杂的操作指令,不仅仅是单一的读或写,它需要组合几个动作来完成某个任务。比如语句`++count`就对应了“读+修改+写”三个操作,但这三个操作并不是一个原子操作。因此,在多线程程序中使用++count,会导致多个执行流交错,导致计数错误(通常是结果小于预期值)。考虑另一种情况:读取+判断,我们来看看经典的单例实现:classSingleton{staticSingleton*instance;public:staticSingleton*get_instance(){if(instance==nullptr){instance=newSingleton;}返回实例;}};因为instance的判断和`instance=newSingleton`不是原子的,所以需要加锁:classSingleton{staticSingleton*instance;静态std::mutex互斥量;public:staticSingleton*get_instance(){mutex.lock();if(instance==nullptr)instance=newSingleton;mutex.unlock();返回实例;}};但是为了性能,更好的解决方案是添加双重检查,代码变成如下:staticSingleton*get_instance(){if(instance==nullptr){mutex.lock();if(instance==nullptr){//仔细检查instance=newSingleton;}mutex.unlock();返回实例;}returninstance;}首先检查,如果instance不为空,则直接返回instance,大多数时候会碰到这种情况,因为instance一旦创建,就不再为空了。如果实例为空,则加锁,然后第二次检查实例是否为空。为什么需要仔细检查?因为前面的检查通过后,有可能其他线程创建了实例,导致实例不再为空。一切似乎都进展顺利,高效周到。但是双重检查真的安全吗?这其实是一个很经典的问题。它有两个风险:首先,编写者没有告诉编译器它必须假设该实例可能被其他线程修改,因此编译器可能认为保留两个if之一就足够了。当然也可能不做这个优化,取决于编译器的策略,必须把实例改成volatile,告诉编译器两次读取都必须从内存中加载,避免double-check优化。就是上面说的原子性。实例指针不能保证存放在8字节对齐的地方,所以需要用std::atomic代替。从逻辑上讲,需要几个操作是一个不可分割的整体。现代CPU通常直接提供对此类原子指令的支持。此类RMW原子指令通常包括:test-and-set(TAS),将1写入Memory位置并返回旧值;如果原始内存位置为1,则返回1,否则自动写入1并返回0;只能识别0和1两种情况fetch_and_add,增加一个内存位置的值,返回旧值;可用作原子后递增比较和交换(CAS),将内存位置的值与指定值进行比较,如果相等,则将新值写入内存位置,否则,什么也不做;强于tas以上所有操作都是在一个内存位置执行多个动作,但是这些操作都是原子单步的,不会被打断,也不会穿插其他操作。这个重要的属性使得RMW指令非常适合实现无锁编程。虽然CPU在执行机器指令时会把它分成更小粒度的微操作(micro-operations),但是程序员应该关注微指令之上的原子指令。原子操作上面提到的原子指令是硬件层面的。不同的架构甚至不同类型的CPU都有不同的原子指令。是CPU级别的东西,跨平台特性很差。用它编写的代码不可移植,所以你应该尽量避免直接使用原子。操作说明。回到软件层面,软件层面的原子操作包括三个层面:(1)在操作系统层面,linux操作系统提供了atomic等原子类型,与相关的编程接口结合使用。大部分都是简单的原子指令的封装。但它屏蔽了硬件差异并且比原子指令更容易使用:atomic_read(atomic_t*v)atomic_set(atomic_t*v,inti)atomic_inc(atomic_t*v)atomic_dec(atomic_t*v)atomic_add(inti,atomic_t*v)atomic_sub(inti,atomic_t*v)atomic_inc_and_test(atomic_t*v)atomic_dec_and_test(atomic_t*v);atomic_sub_and_test(inti,atomic_t*v)(2)编译级,gcc提供原子操作内建函数,用gccc/c++代码编译,可以直接使用://其中type对应8/16/32/64位整数type__sync_fetch_and_add(type*ptr,typevalue,...)type__sync_fetch_and_sub(type*ptr,typevalue,...)type__sync_fetch_and_or(type*ptr,typevalue,...)type__sync_fetch_and_and(type*ptr,typevalue,...)type__sync_fetch_and_xor(type*ptr,typevalue,...)type__sync_fetch_and_nand(type*ptr,typevalue,...)type__sync_add_and_fetch(type*ptr,typevalue,...)type__sync_sub_and_fetch(type*ptr,typevalue,...)type__sync_or_and_fetchch(type*ptr,typevalue,...)type__sync_and_and_fetch(type*ptr,typevalue,...)type__sync_xor_and_fetch(type*ptr,typevalue,...)type__sync_nand_and_fetch(type*ptr,typevalue),...)gcc在现实C++11之后,新的原始接口,以__atomic为前导,推荐使用下面这些接口:type__atomic_add_fetch(type*ptr,typeval,intmemorder)type__atomic_sub_fetch(type*ptr,typeval,intmemorder)类型__atomic_and_fetch(type*ptr,typeval,intmemorder)*ptr,typeval,intmemorder)类型__atomic_fetch_add(type*ptr,typeval,intmemorder)type__atomic_fetch_sub(type*ptr,typeval,intmemorder)type__atomic_fetch_and(type*ptr,typeval,intmemorder)type__atomic_fetch_xor(type*ptr,typeval,intmemorder)类型__atomic_fetch_or(type*ptr,typeval,intmemorder)type__atomic_fetch_nand(type*ptr,typeval,intmemorder)(3)在编程语言层面,通常会提供原子操作类型和接口。这也是使用原子操作的推荐方式。具有良好的跨平台性和可移植性,程序员优先使用:C11增加了原子操作库,通过stdatomic.h头文件提供atomic_fetch_add/atomic_compare_exchange_weak等接口。C++11还增加了原子操作库,提供了std::atomic类型的类模板,提供了++/--/+=/-=/fetch_sub/fetch_add等原子操作接口。原子操作常用于与顺序无关的场景,比如统计错误。用原子变量改写后,会输出预期的值。原子操作是编写Lock-free多线程程序的基础。原子操作只保证原子性,不保证操作顺序。在Lock-free多线程程序中,仅有原子操作是不够的。需要结合原子操作和MemoryBarrier来实现无锁。