目录
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_bool | bool |
atomic_char | char |
atomic_uchar | unsigned char |
atomic_short | short |
atomic_ushort | unsigned short |
atomic_int | int |
atomic_uint | unsigned int |
atomic_long | long |
atomic_ulong | unsigned long |
atomic_llong | long long |
atomic_ullong | unsigned long long |
atomic_char16_t | chat16_t |
atomic_char32_t | chat32_t |
atomic_wchar_t | wchar_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.应用场景
原子操作广泛应用于以下几个场景:
-
计数器:多线程环境下的计数操作,如网站访问量统计、资源请求计数等。
-
标志位:用于控制程序流转的标志位操作,如任务完成标志、自旋锁等。
-
锁自由数据结构:实现锁自由的队列、栈等数据结构,提高并发性能。
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