首页 > 编程语言 >C++11原子操作

C++11原子操作

时间:2024-06-07 11:35:05浏览次数:26  
标签:11 std C++ 原子 flag 线程 atomic 操作

目录

1.什么是原子操作

2.为什么需要原子操作?

3.C++中的原子操作

4.原子操作使用及注意

5.应用场景

6.使用原子操作的最佳实践

7.原子操作与锁机制的比较

8.总结


1.什么是原子操作

        原子操作是一种不可分割的操作,即在多线程环境中,这些操作要么全部执行完成,要么根本没有执行,中间不会被其他线程打断。这种特性使得原子操作在保证数据一致性和线程安全方面具有显著优势。

        原子操作是在多线程程序中“最小的且不可并行化的”操作,意味着多个线程访问同一个资源时,有且仅有一个线程能对资源进行操作。通常情况下原子操作可以通过互斥的访问方式来保证,如 Linux下的互斥锁(mutex)和 Windows 下的临界区(Critical Section)等。

        说白了原子操作就是不可中断的操作,要么被执行要不不被执行。

2.为什么需要原子操作?

        多线程编程的一个核心问题是如何在多个线程间安全地共享数据。传统的解决方案是使用锁(Lock)机制,例如互斥锁(Mutex)和读写锁(Read-Write Lock),但锁机制存在以下缺点:

        性能开销大:锁机制会引入额外的上下文切换和系统调用,导致性能下降。

        死锁风险:不当的锁管理可能导致死锁,进而影响程序的稳定性。

        复杂性高:在复杂的多线程环境中,正确管理锁非常困难,容易出错。

        原子操作通过硬件支持,提供了一种轻量级的同步机制,有效避免了上述问题。通过原子操作,我们可以确保在并发环境中对共享数据的访问是安全的,从而避免数据竞争和其他并发问题。

3.C++中的原子操作

        C++11引入了标准库头文件,其中包含了原子操作相关的类和函数。最常用的原子操作类是通过模板std::atomic<T>来定义,它封装了基本的原子操作,并提供了一组易于使用的接口。比如atomic_int64_t是通过typedef atomic<int64_t> atomic_int64_t实现的,使用时需包含头文件<atomic>。除了提供atomic_int64_t,还提供了其它的原子类型。常见的原子类型有:

原子类型名称对应内置类型
atomic_boolbool
atomic_charchar
atomic_ucharunsigned char
atomic_shortshort
atomic_ushortunsigned short
atomic_intint
atomic_uintunsigned int
atomic_longlong
atomic_ulongunsigned long
atomic_llonglong long 
atomic_ullongunsigned long long
atomic_char16_tchat16_t
atomic_char32_tchat32_t
atomic_wchar_twchar_t

        原子操作是平台相关的,原子类型能够实现原子操作是因为 C++11 对原子类型的操作进行了抽象,定义了统一的接口,并要求编译器产生平台相关的原子操作的具体实现。C++11 标准将原子操作定义为 atomic 模板类的成员函数,包括读(load)、写(store)、交换(exchange)等。对于内置类型而言,主要是通过重载一些全局操作符来完成的。比如对上文total+=i的原子加操作,是通过对operator+=重载来实现的。使用g++ 编译的话,在 x86_64 的机器上,operator+=() 函数会产生一条特殊的以 lock 为前缀的 x86_64 指令,用于控制总线及实现 x86_64平台上的原子性加法。下面我们通过几个示例代码来了解std::atomic的基本用法:

#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

// 原子整数
std::atomic<int> atomicInt(0);

void incrementAtomic() {
    for (int i = 0; i < 1000; ++i) {
        ++atomicInt; // 原子加法
    }
}

int main() {
    std::vector<std::thread> threads;

    for (int i = 0; i < 10; ++i) {
        threads.push_back(std::thread(incrementAtomic));
    }

    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Final value: " << atomicInt << std::endl; // 期望输出10000

    return 0;
}

        在上述示例中,我们使用std::atomic定义了一个原子整数atomicInt,并在多个线程中对其进行原子加法操作。由于原子操作的特性,无论有多少个线程同时执行,最终的结果都是正确的(10000),而不会出现数据竞争问题。

        有一个比较特殊的原子类型是 atomic_flag,因为 atomic_flag 与其他原子类型不同,它是无锁(lock_free)的,即线程对其访问不需要加锁,而其他的原子类型不一定是无锁的。因为atomic<T>并不能保证类型T是无锁的,另外不同平台的处理器处理方式不同,也不能保证必定无锁,所以其他的类型都会有 is_lock_free() 成员函数来判断是否是无锁的。atomic_flag 只支持 test_and_set() 以及 clear() 两个成员函数,test_and_set()函数检查 std::atomic_flag 标志,如果 std::atomic_flag 之前没有被设置过,则设置 std::atomic_flag 的标志;如果之前 std::atomic_flag 已被设置,则返回 true,否则返回 false。clear()函数清除 std::atomic_flag 标志使得下一次调用 std::atomic_flag::test_and_set()返回 false。可以用 atomic_flag 的成员函数test_and_set() 和 clear() 来实现一个自旋锁(spin lock):

#include <unistd.h>
#include <atomic>
#include <thread>
#include <iostream>

std::atomic_flag lock = ATOMIC_FLAG_INIT;

void func1() {
	while (lock.test_and_set(std::memory_order_acquire))    // 在主线程中设置为true,需要等待t2线程clear
    {
        std::cout << "func1 wait" << std::endl;
    }
    std::cout << "func1 do something" << std::endl;
}

void func2() {
    std::cout << "func2 start" << std::endl;
    lock.clear();
}

int main() {
    lock.test_and_set();             // 设置状态
    std::thread t1(func1);
    usleep(1);					 	//睡眠1us
    std::thread t2(func2);

    t1.join();
    t2.join();

    return 0;
}

        以上代码中,定义了一个 atomic_flag 对象 lock,使用初始值 ATOMIC_FLAG_INIT 进行初始化,即处于 false 的状态。线程 t1 调用 test_and_set() 一直返回 true(因为在主线程中被设置过),所以一直在等待,而等待一段时间后当线程 t2 运行并调用了 clear(),test_and_set() 返回了 false 退出循环等待并进行相应操作。这样一来,就实现了一个线程等待另一个线程的效果。当然,可以封装成锁操作的方式,比如:

void Lock(atomic_flag& lock){ while ( lock.test_and_set()); }
void UnLock(atomic_flag& lock){ lock.clear(); }

这样一来,就可以通过Lock()和UnLock()的方式来互斥地访问临界区。

        自旋锁使用的时候虽然占用CPU资源(线程在获取锁时会一直循环检查锁是否可用,这会导致线程不断占用CPU时间),但是也有一定的优点:适用于锁被占用时间非常短暂的情况,因为在这种情况下,线程不需要长时间等待锁的释放,使用自旋锁可以避免线程切换带来的开销,提高性能。

4.原子操作使用及注意

        原子操作不能拷贝:只要是原子操作,都不能进行赋值和拷贝(因为调用了两个对象,破坏了原子性--拷贝构造和拷贝赋值都会将第一个对象的值进行读取,然后再写入另外一个。对于两个独立的对象,这里就有两个独立的操作了,合并这两个操作必定是不原子的。因此,操作就不被允许。如:

#include <iostream>
#include <atomic>
 
int main() {
    std::atomic<int> atomicValue(10);
 
    // 不能进行赋值操作
    std::atomic<int> anotherAtomicValue = atomicValue; // 编译错误
 
    // 不能进行拷贝操作
    // std::atomic<int> copiedAtomicValue(atomicValue); // 编译错误
 
    return 0;
}

5.应用场景

     原子操作广泛应用于以下几个场景:

  1. 计数器:多线程环境下的计数操作,如网站访问量统计、资源请求计数等。

  2. 标志位:用于控制程序流转的标志位操作,如任务完成标志、自旋锁等。

  3. 锁自由数据结构:实现锁自由的队列、栈等数据结构,提高并发性能。

6.使用原子操作的最佳实践

        虽然原子操作在性能和安全性方面具有显著优势,但在使用过程中仍需注意以下几点:

        选择合适的数据类型:std::atomic支持的基本数据类型包括bool、整数类型、指针类型等。在实际应用中,应根据具体需求选择合适的数据类型。 

        了解内存序(Memory Order):C++原子操作提供了多种内存序选项,如memory_order_relaxed、memory_order_acquire、memory_order_release等。

        正确选择和使用内存序,有助于提高程序的性能和正确性。 

        避免过度使用原子操作:虽然原子操作性能优越,但不适用于所有场景。在复杂的同步需求中,仍需要结合使用锁机制。 

        深入理解内存序 

        内存序是C++原子操作中的一个重要概念,它控制了原子操作在多线程环境中的执行顺序。内存序主要有以下几种:

        memory_order_relaxed:不保证操作的顺序,仅保证操作的原子性。适用于对顺序没有严格要求的场景,如简单的计数器。

        memory_order_acquire:保证此操作之前的所有读操作都在此操作之前完成。适用于从共享变量读取数据的场景。

        memory_order_release:保证此操作之后的所有写操作都在此操作之后完成。适用于向共享变量写入数据的场景。 

        memory_order_acq_rel:同时具有memory_order_acquire和memory_order_release的特性。适用于读-改-写操作。

        memory_order_seq_cst:最严格的内存序,保证所有操作按顺序执行。适用于对顺序有严格要求的场景。 

        理解和正确使用内存序,可以在保证程序正确性的同时,最大限度地提高并发性能。

7.原子操作与锁机制的比较

        虽然原子操作在许多场景中比锁机制更高效,但两者各有优缺点,适用的场景也有所不同。

原子操作的优点:

        性能高:原子操作由硬件直接支持,通常比锁机制更高效。 

        避免死锁:由于不使用锁,原子操作避免了死锁问题。 

原子操作的缺点:

        适用范围有限:原子操作适用于简单的同步场景,对于复杂的同步需求,可能需要借助锁机制。 

        代码复杂性:在一些情况下,使用原子操作的代码可能比使用锁机制的代码更复杂。 

锁机制的优点:

        适用范围广:锁机制可以处理复杂的同步需求,如保护复杂的数据结构、实现复杂的同步逻辑等。 

        代码简单:在某些情况下,使用锁机制的代码比使用原子操作的代码更简单直观。 

锁机制的缺点:

        性能开销大:锁机制会引入额外的上下文切换和系统调用,导致性能下降。

        死锁风险:不当的锁管理可能导致死锁,影响程序的稳定性。

8.总结

        C++原子操作提供了一种高效、安全的多线程数据访问方式,在性能和安全性方面具有显著优势。通过合理使用std::atomic类和内存序选项,开发者可以编写出高效、可靠的多线程程序。

标签:11,std,C++,原子,flag,线程,atomic,操作
From: https://blog.csdn.net/haokan123456789/article/details/139511581

相关文章

  • C++中的priority_queue和deque以及适配器
    C++中的priority_queue和deque一丶priority_queue1.1priority_queue的介绍1.2priority_queue的使用1.3priority_queue的模拟实现二丶deque2.1deque的简单介绍2.2deque的缺陷2.3为什么要选择deque作为stack和queue的迭代器三丶容器适配器3.1什么是适配器3.2S......
  • C++ Template
    一、Template什么是template?重要性如何?下面我就说道说道:无性生殖不只是存在于遗传工程中,对于程序员而言,它也是一个由来已久的动作。过去,我们只不过是以一个简单而基本的工具,也就是一个文字编辑器,重复的复制代码。今天,C++提供给我们一个更好的繁殖方法:template。复......
  • 【C++进阶】深入STL之list:高效双向链表的使用技巧
    ......
  • GE VME5565 VMIVME-5565-11000 332-015565-110000 P 反射式内存节点卡
    VME5565VMIVME-5565-11000332-015565-110000P规格:接口:VMEbus。通道数:16。模拟输入分辨率:16位。模拟输出分辨率:16位。数字I/O:32行。工作温度范围:-40℃~+85℃。输入电压:5VDC。内存配置:可配置为94MB或1108MB的SDRAM。数据传输速率:最高170兆字节/秒。系统节点数:支......
  • CSP历年复赛题-P2119 [NOIP2016 普及组] 魔法阵
    原题链接:https://www.luogu.com.cn/problem/P2119题意解读:在一组数里找出所有的Xa,Xb,Xc,Xd的组合,使得满足Xa<Xb<Xc<Xd,Xb-Xa=2(Xd-Xc),Xb-Xa<(Xc-Xb)/3,并统计出每个数作为A,B,C,D出现的次数。解题思路:1、枚举(O(n^4))首先想到的是通过4重循环枚举所有可能的Xa,Xb,Xc,Xd,然后判......
  • 如何在 Windows 10/11 上恢复不在回收站中的永久删除文件夹?
    经验丰富的Windows用户将使用Windows备份和还原或文件历史记录来恢复不在回收站中的已删除文件夹。这些工具确实有助于Windows文件夹恢复,但并不总是有效。现在,许多专用的Windows数据恢复软件和免费解决方案都可以取代它们,从而为Windows用户提供了一种成功有效地恢复永久删......
  • 适用于 Windows 11 的 10 款最佳视频转换器:快速转换高质量视频格式
    您是否遇到过由于格式不兼容而无法在设备上播放视频或电影的情况?您想随意播放从相机GoPro导入的视频,还是以最合适的格式将它们上传到媒体网站?您的房间里有一堆DVD光盘,并想将它们转换为数字格式以方便播放?...所有这些问题都可以通过一个有效的视频转换器一次性解决。实际......
  • C++Primer Plus第12章类和动态内存分配--再谈定位new运算符----12.8
    12.5.3再谈定位new运算符本书前面介绍过,定位new运算符让您能够在分配内存时能够指定内存位置。第9章从内置类型的角度讨论了定位new运算符,将这种运算符用于对象时情况有些不同,程序清单12.8使用了定位new运算符和常规new运算符给对象分配内存,其中定义的类的构造函数......
  • C++Primer Plus第12章类和动态内存分配--再谈定位new运算符----12.9
    该程序使用定位new运算符在相邻的内存单元中创建两个对象,并调用了合适的析构函数。#pragmaregion12.9placenew2.cpp//placenew2.cpp--newplacementnew,nodelete#if1#include<iostream>#include<string>#include<new>usingnamespacestd;constintBU......
  • C/C++ 枚举类型的注意事项
    枚举类型(enum)是C/C++的一种常用类型,它允许我们为一组整数值定义有意义的名称。然而,在使用枚举类型时,有几个重要的注意事项需要考虑:1.枚举的基础类型和值基础类型:默认情况下,枚举类型的基础类型是int,但你也可以明确指定其他整数类型(如enumclassColor:char{RED,GREEN,B......