C++之线程同步


我们承担ROS,FastDDS等通信中间件,C++,cmake等技术的项目开发和专业指导和培训,有10年+相关工作经验,质量有保证,如有需要请私信联系。

无锁操作:是指在多线程环境中,对数据的操作不需要使用互斥锁(mutex)或其他同步机制,而是直接使用特定的CPU指令(如CAS,Compare-and-Swap)来保证操作的原子性。这样可以避免锁的开销和潜在的死锁问题。

线程同步

  • 哪些情况下可能出错:
    • 未同步化的数据访问:并行运行的两个线程读和写同一笔数据,不知道哪一个语句先来
    • 写至半途的数据:某个线程正在读数据,另一个线程改动它,于是读取中的线程可能读到改了一半的数据,读到一个半新半旧值
    • 重新安排的语句:语句和操作有可能重新安排次序,因为C++只要求编译所得的代码在单一线程内的可观测行为正确,所以很有可能重新安排数据,只要单一线程的可视效果相同
  • 解决问题所需要的性质:
    • 不可切割性:读写一个变量或语句,其行为是独占的
    • 次序:保证按具体指定语句次序执行
    • 解决方法(由高级到低级排序):
      • future和promise都保证不可切割性和次序:一定是在形成结果之后才设定shared state
      • mutex和lock:授予独占权

互斥量

  • std::mutex:独占式访问资源,同一时间只可以被一个线程锁定,同一个锁多次锁定会造成死锁;通过成员函数lock()进行上锁,unlock进行解锁
  • std::recursive_mutex:这是递归互斥量,允许同一时间多次被同一线程获得这个互斥量而不会死锁,允许同一线程多次锁定,只要解锁的次数和锁定的次数相同,互斥量就会被释放。
#include <mutex>
#include <thread>

std::recursive_mutex recursiveMutex;

void recursiveLock(int i) {
    if (i <= 0) return;
    recursiveMutex.lock();
    std::cout << "Locked " << i << " times.\n";
    recursiveLock(i - 1);
    recursiveMutex.unlock();
}

int main() {
    std::thread t1(recursiveLock, 5);
    std::thread t2(recursiveLock, 5);
    t1.join();
    t2.join();
    return 0;
}
  • std::time_mutex:额外允许传递一个时间段或时间点,用来定义多长时间内它可以尝试捕捉一个lock;为此它提供了try_lock_for()和try_lock_until()
  • std::recursive_timed_mutex:允许同一线程多次取得lock,可以指定期限

lock

  • try_lock():想获得一个lock,但不成功的话不想阻塞;该函数成功锁定返回true,否则返回false

    • 为了能够使用lock_guard,可以传一个额外实参adopt_lock给其构造函数,注意:
      • try_lock有可能假失败,即lock并未被拿走但也有可能失败
      • 不要将受保护数据的指针或引用
  • 为了等待特定长度的时间,可以使用time mutex: timed_mutex和resursive_timed_mutex,允许调用try_lock_for和try_lock_until,用以等待某个时间段或达到某个时间点

  • 尝试性lock:尝试获得一个lock,如果不能成功的话不想被永远阻塞住,try_lock,为了能够使用lock_guard,可以传递一个额外实参adopt_lock给构造函数

  • 异常:

  • 第二次lock抛出异常std::system_error,并带差错码resource_deadlock_would_occur

    • lock:可以一次性锁住多个互斥量,并且没有副作用

lock_guard和unique_lock

  • 优点:析构时mutex被锁住其析构会自动调用unlock(),如果没有锁住mutex则析构不做任何事
  • 与lock_guard相比,unique_lock添加了三个构造:
    • try_to_lock:企图锁住mutex但不希望被锁住 unique_lock<mutex> lock(mutex, std::try_to_lock)
    • 传递一个时间点或时间段给构造,尝试在一个明确的时间周期内锁定 unique_lock<timed_mutex> lock(mutex, std::chrono::seconds(1))
    • 传递defer_lock,表示初始化这一个lock object但尚未打算被锁住mutex unique_lock<mutex>
    • unique_lock提供release来释放mutex,或将mutex的拥有权转移给另一个lock
  • unique_lock和条件变量配合使用,先用lock锁住,接着条件变量的wait会去检查这些条件,当条件满足时返回,如果条件不满足,wait会解锁互斥量,将当前线程置于等待状态;当准备数据的线程调用notify_one/norify_all通知条件变量时,处理数据的线程从睡眠状态中苏醒,重新获取互斥量并且对条件再次检查
  • 使用心得:
    • 单独使用时unique_lock和lock_guard一样都是加锁,释放锁,并不存在unique_lock更消耗资源(只是多一个bool字段)。unique_lock可以配合条件变量使用
    • wait( std::unique_lock<std::mutex>& lock )函数源码跳转不进去
  • call_once:第一实参必须是相应的once_flag,确保传入的机能只被执行一次,下一实参是可调用对象;比起锁住互斥量,并显示的检查指针,每个线程只需要使用是他的std::call_once,在std::call_once结束时,就能安全的知道指针已经被其他的线程初始化了。使用是std::call_once比显示使用互斥量消耗的资源更少

条件变量

#include<condition_variable>, 包含两个条件变量对象:condition_variablecondition_variable_any

  • 运作如下:
    a. 声明一个mutex和一个condition_variable
    b. 激发条件终于满足的线程必须调用notify_onenotify_all
    c. 等待的线程必须调用std::unique_lock<mutex> l(readyMutex); readyCondVar.wait(1)
    d. condition_variable:仅限于与std::mutex一起工作,多个线程可以等待某特定条件发生,一旦条件满足,如果无法建立condition_variable,构造函数会抛出std::system_error异常
  • 方法:
    • wait(ul)/wait(ul, pred):使用unique lock来等待通知/直到pred在一次苏醒之后结果为true。等待条件被满足的线程必须调用wait;wait内部会明确对mutex进行解锁和锁定
    • wait_for(ul, duration) / wait_for(ul, duration, pred):使用unique lock ul来等待通知,等待期限是duration/或知道pred在一次苏醒之后结果为true
    • wait_until(ul, timepoint)/wait_until(ul, timepoint, pred):使用unique lock ul来等待通知,直到时间点timepoint/或直到pred在一次苏醒之后结果为true
    • notify_one()/notify_all():激发条件满足的线程必须调用notify_one或notify_all
  • 注意:condition变量有可能有假醒,也就是wait动作有可能在condition尚未被notified时便返回所以在唤醒之后还要验证条件是否已达成
  • condition_variable_any:可以和任何满足最低标准的互斥量一起工作,但是会产生额外的开销

原子操作

原子操作是指不可分割的操作,即在执行过程中不会被其他线程打断。原子操作通常用于对单个内存位置进行读取、写入或修改。由于这些操作是不可分割的,因此它们不需要使用锁,开销比较小。然而,原子操作不能保证更大范围的操作序列的原子性。原子变量的修改并不需要显式地读入寄存器。原子操作通常由硬件直接支持,并通过特殊的机器语言指令来实现,这些指令可以直接在内存中操作数据,而无需先将数据读入寄存器。

互斥量,或者叫做互斥锁,是用于保护一段代码区域,使得这段代码区域在同一时间只能被一个线程执行。当一个线程获得互斥量后,其他试图获取该互斥量的线程将会阻塞,直到互斥量被释放。互斥量可以保护任意长度的代码区域,但它的开销比原子操作大,因为需要进行锁的获取和释放操作。

所以尽管可以使用互斥量模拟原子操作的行为,但本质上它们是不同的。

#include<atomic>,原子操作是一类不可分割的操作,不可以再分解为基本类型,包括整型,实型等。当这样的操作在进行到一半的时候,你是不能查看的,它的状态要不是完成,要不就是未完成。
标准的原子操作都有一个成员函数(除了std::atomic_flag):is_lock_free(),函数返回一个布尔值,如果返回true,则表示该std::atomic对象使用了无锁操作;如果返回false,则表示可能使用了锁。
需要注意的是,是否使用无锁操作通常取决于实现和硬件。有些类型的std::atomic对象在某些平台上可能是无锁的,而在其他平台上可能需要使用锁。因此,is_lock_free函数提供了一种方法,让程序可以在运行时查询当前环境下std::atomic对象的行为。

std::atomic_flag:简单的布尔标识,在这种类型的操作上都是无锁的,不需要is_lock_free函数去判断。可以在两个状态之间进行切换:设置和清除,没有赋值,拷贝构造等操作。
其他的通过使用std::atomic<>模板的类型功能可能更为全面一些,但不能保证是lock-free。在大多数流行的平台上,所有内置类型的原子变量(如std::atomic和std::atomic<void*>)预计都是无锁的,但这并不是必须的,C++标准并没有要求std::atomic必须支持无锁操作。这是因为不是所有的硬件平台都支持无锁操作。所以,当你在编写依赖于无锁操作的代码时,最好先检查你的目标平台是否真的支持无锁操作。你可以通过调用std::atomic的is_lock_free成员函数来检查一个原子变量是否支持无锁操作。

每个atomic操作都有一个可选的内存顺序参数,用于指定内存顺序。memory-ordering有三种:

  1. storce操作,有 memory_order_relaxed, memory_order_release, memory_order_seq_cst
  2. load操作,有 memory_order_relaxed, memory_order_consumememory_order_acquire, memory_order_seq_cst
  3. Read-modify-write操作:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst

默认顺序为memory_order_seq_cst

std::atomic_flag

  • 特点:
    • 是一个Boolean flag,这个对象只有两个状态:set或clear, 不支持赋值操作,也就是说,不能直接使用 = 来将其设置为 true 或 false。
    • 在C++中,std::atomic_flag是最低层级的原子类型,是故意设计得非常基础的,它只提供了最基础的原子操作,如测试和设置(test-and-set)操作。它主要用作构建更复杂的同步机制的基石,或构建更高级别的同步机制,如自旋锁(spinlock)或者读写锁(reader-writer lock)。因此一般不太可能直接看到它的使用。
    • std::atomic_flag在首次使用时必须被值ATOMIC_FLAG_INIT初始化,表示清除状态(clear state),即flag总是以clear状态开始。它是唯一一个保证lock-free的类型。

有了一个已经初始化了的std::atomic_flag对象,可以做以下三件事:

  1. 销毁,对应析构
  2. clear,对应clear()函数
  3. 查询先前的值,对应test_and_set()成员函数。test_and_set 函数的作用是设置 std::atomic_flag 的值为 true,并返回它之前的值。如果之前的值已经是 true,那么这个操作将会被阻塞(在多线程环境中),直到其它线程将其值改为 false。

不能通过前一个对象拷贝构造第二个对象,也不能将一个复制给另一个。这是atomic类型的共同特性,因为拷贝构造或拷贝运算符需要首先读出原来的值,然后写入另一个对象,这是两次分离的操作,所以不是原子操作,不能被允许。

这些特性使得原子操作非常适合实现自旋锁。
所以std::atomic_flag的局限性强,没有非修改查询操作,不能像bool标识那样使用,所以最好使用std::atomic<bool>代替std::atomic_flag

自旋锁

在这里插入图片描述

  • 与互斥锁相比,自旋锁在获取锁的时候不会使得线程阻塞而是一直自旋尝试获取锁,当线程等待自旋锁的时候CPU不能做其他事情,而是一直处于忙等待状态
  • 主要适用场景:主要适用于被持有时间短,线程不希望在重新调度上花过多时间的情况
  • 使用自旋锁要注意:由于自旋时不释放CPU,如果在持锁时间很长的场景下使用自旋锁,会导致CPU在这个线程的时间片用尽之前一直消耗在无意义的忙等上,造成浪费;因而持有自旋锁的线程应该尽快释放自旋锁

以下为使用std::atomic_flag实现的自旋锁

class spinlock_mutex
{
	std::atomic_flag flag;
public:
	spinlock_mutex() : flag(ATOMIC_FLAG_INIT) {}  // 这个初始化在VS2023中报错
	
	void lock() {
		while (flag.test_and_set(std::memory_order_acquire));
	}
	
	void unlock() {
		flag.clear(std::memory_order_release);
	}
};

std::atomic< bool >

可以使用非原子类型bool的变量来构造。与std::atomic_flag的一个区别在于它可能不是lock-free的,可以通过is_lock_free查询。
支持的操作:

  • store():写数据
  • load(): 查询
  • exchange(): read-modify-write操作
  • compare_exchange_weak(T& expected, T desired, ...)/compare_exchange_strong(T& expected, T desired, ...):这两个函数都是原子性的比较并交换函数。它们都试图将原子对象的值与expected进行比较,如果相等,则用desired的值进行更新,并返回true;如果不等,则不进行更新,将expected更新为原子变量的值,并返回false。
    compare_exchange_weak函数被称为“weak”(弱)可能会“伪失败”,即即使原子对象的值和expected相等,它也可能返回false。这种情况主要发生在多线程环境中,当其他线程同时修改了这个原子对象的值。这是为了解决某些多线程同步问题,例如ABA问题。
    "伪失败"的存在,使得compare_exchange_weak在某些平台上,特别是在处理器支持乐观锁定或者其他形式的低开销无锁协议的平台上,能够提供更好的性能。因为在这些情况下,compare_exchange_weak可以在预计会失败的情况下避免执行更昂贵的内存栅栏操作。然而,由于compare_exchange_weak可能会“伪失败”,因此通常需要在一个循环中调用它,以确保操作最终能够成功。
bool expected=false;
extern atomic<bool> b; // set somewhere else
while(!b.compare_exchange_weak(expected,true) && !expected);

compare_exchange_strong可以保证仅在原来的值不等于expected的时候返回false

在选择使用compare_exchange_weak()还是compare_exchange_strong()时,需要考虑计算待存储值的复杂性。如果计算简单,那么使用compare_exchange_weak()可能更有利,因为即使它偶尔失败,也可以快速重新计算并尝试。但是,如果计算待存储值的过程很耗时,那么使用compare_exchange_strong()可能更好,因为即使期望值没有变,它也不需要重新计算待存储值。

std::atomic<T*>

原子指针类型支持的操作有:load(),
store(), exchange(), compare_exchange_weak(), and compare_exchange_strong(),fetch_add() 和 fetch_sub()。
fetch_add() 和 fetch_sub()做原子的加减操作(效果和指针±操作一样),更新后返回操作之前的值。

int some_array[5];
std::atomic<int*> p(some_array);
int* x = p.fetch_add(2);  // 结果同 p += 2;
assert(x == some_array);
assert(p.load() == &some_array[2]);
x = (p -= 1);
assert(x == &some_array[1]);
assert(p.load() == &some_array[1]);

原子整数类型

std::atomic<int>, std::atomic<unsigned long long>
原子整数类型有以下操作:
(load(), store(), exchange(), compare_exchange_weak(), and compare_exchange_strong();另外还有:fetch_add(), fetch_sub(), fetch_and(), fetch_or(), fetch_xor()

总是应该将atomic对象初始化,因为默认构造函数不一定完全初始化它(倒不是初值不明确,而是其lock未被初始化?)。如果只使用default构造函数,接下来唯一合法的操作是调用:
std::atomic readyFlag;
std::atomic_init(&readyFlag, false);

  • 方法:
    • a.store(val):赋予一个新值val并返回void
    • a.load():返回数值a的拷贝
    • a.exchange(val):交换val并返回旧值a的拷贝
    • atomic a; atomic_init(&a, val):初始化a
    • a.is_lock_free():如果内部不使用lock便返回true,用于判断平台是否支持原子操作
    • a.compare_exchange_strong(exp, des)
    • a.compare_exchange_weak(exp, des)
    • a.fetch_add(val)
    • a.fetch_sub(val)
    • a+=val / a-=val
    • a++/++a/a–/–a
    • a.fetch_and(val)/a.fetch_or(val)/a.fetch_xor(val)
    • a&=val/a|=val/a^=val

模板std::atomic<>

存在的目的是除了标准原子类型外,允许用户使用自定义类型创建一个原子变量。

  • 自定义模板操作仅限于load(), store(), exchange(), compare_exchange_weak(), compare_exchange_strong()
  • 自定义类型使用该模板的要求:自定义类型要求是POD类型
    • 第一,必须有trivial copy-assignment operator。这味着这个类型不能有任何虚函数或虚基类,以及必须使用编译器创建的拷贝赋值操作,并且它的父类和非静态的自定义数据成员都遵守这一规则,即trivial copy-assignment 运算符。这也意味着编译器可以使用memcpy()或者类似的操作赋值。
    • 第二,类型必须是 bitwise equality comparable。可以通过比较它们的内存表示(即它们的比特位)来判断它们是否相等。必须能够通过memcmp()函数来复制,也要保证能使用memcmp()函数能做对比。这是能够 compare/exchange操作的必要保证。
      编译器通常无法为std::atomic<>生成无锁(lock-free)代码,所以它必须对所有操作使用内部锁。无锁编程是一种避免使用互斥锁(如,mutex)而直接利用硬件提供的原子操作来保证多线程之间操作的原子性和内存可见性的编程方式。

然后,如果允许用户提供复制赋值或比较运算符,那么这将需要将受保护的数据作为参数传递给用户提供的函数。这就违反了一个指导方针,即不应将受保护的数据暴露给用户代码,因为这样可能会破坏数据的完整性或者引发数据竞争等问题。

为什么用户提供复制赋值或比较运算符不能作为原子变量的类型?
原子变量的设计初衷是为了在多线程环境下保证数据的一致性和原子性,即某个操作要么完全执行,要么完全不执行,不会被其他线程打断。这就要求对原子变量的所有操作都必须是原子的。

现在,如果允许用户为其定义的类型提供复制赋值或比较运算符,那么这些运算符的实现就可能不是原子的,例如,可能涉及到多个步骤的计算,或者调用其他非原子的函数等。在这种情况下,如果多个线程同时对同一个原子变量进行操作,就可能导致数据的不一致,从而破坏原子变量的原子性。

另外,用户提供的复制赋值或比较运算符可能会抛出异常,而原子操作是不应该抛出异常的,因为一旦抛出异常,就无法保证操作的原子性。

库完全可以自由地为所有需要的原子操作使用单一的锁。如果允许在持有该锁的情况下调用用户提供的函数,可能会导致死锁,或者由于比较操作花费了很长时间,导致其他线程被阻塞。

其次,这些限制可以增加编译器能够直接为std::atomic 使用原子指令(并因此使特定实例为无锁)的机会,因为它可以将用户定义的类型仅仅当作一组原始字节来处理

因此,出于以上原因,用户提供的复制赋值或比较运算符不能作为原子变量的类型。

  • 什么是trivial copy-assignment ?如果一个类的copy-assignment operator是trivial的,那么它满足以下几个条件:
    • 它是编译器自动生成的,而不是程序员自定义的。
    • 它执行的是逐个成员的复制,不涉及任何其他操作。
    • 它不会抛出异常。
    • 对于一个trivial的copy-assignment operator,编译器可以在复制对象时进行一些优化,从而提高运行效率。
  • 什么是"bitwise equality comparable:"bitwise equality comparable"是指一个数据类型的两个实例,如果它们的所有比特位(bitwise)都相等,那么这两个实例就应该被认为是相等的。
    • 对于一些基本类型,如整数类型(如int,long等),这通常是显然的
    • 但对于一些复杂的类型,如类(class)或结构体(struct),这就需要更明确的定义。比如,一个类可能有一些内部状态,这些状态并不直接决定对象的值(例如,缓存、引用计数器、互斥锁等)。在这种情况下,即使两个对象的比特位不完全相同,只要它们表示的"值"相同,它们就应该被认为是相等的。这种情况就不能被认为是bitwise equality comparable。
    • 在一些需要高效比较的场景,例如哈希表或者一些底层的系统编程,可能会要求数据类型是bitwise equality comparable的,以便直接比较内存中的比特位。

注意,trivial和non-trivial的区别主要在于是否涉及复杂的操作和是否可以被编译器优化,而不在于操作的结果是否正确。一个正确实现的non-trivial copy-assignment operator可以和trivial的版本有完全一样的行为,只是可能效率较低。

lock-based数据结构

需要思考,如何让序列化访问最小化,让真实并发最大化:

  1. 锁范围中的操作,是否允许在锁外执行?
  2. 数据结构中不同的区域能否被不同的互斥量所保护?
  3. 所有操作都需要同级互斥量保护吗
    基于锁的并发数据结构设计,需要确保访问线程持有锁的时间最短
    可能出现的问题:
  4. 无意中传递了保护数据的引用:不要将受保护数据的指针或引用传递到互斥锁之外,如通过返回值或参数形式传递到外面——C++并发编程57
  5. 发生在接口上的条件竞争:C++并发编程60
    C++11中最重要的新特性之一,是多线程感知的内存模型
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在 C++ 中,可以使用多种方式实现线同步,以下是其中的几种: 1. 互斥锁(Mutex) 互斥锁是一种最常见的线同步机制。它可以保证同时只有一个线程可以访问共享资源,其他线程需要等待该线程释放锁之后才能访问。C++ 中可以使用 `std::mutex` 类来创建互斥锁,使用 `lock()` 和 `unlock()` 函数来加锁和解锁。 ```c++ #include <iostream> #include <thread> #include <mutex> std::mutex mtx; // 创建互斥锁 void print(int num) { mtx.lock(); // 加锁 std::cout << num << std::endl; mtx.unlock(); // 解锁 } int main() { std::thread t1(print, 1); std::thread t2(print, 2); t1.join(); t2.join(); return 0; } ``` 在这个例子中,我们创建了一个互斥锁对象 `mtx`,并在 `print()` 函数中使用 `lock()` 和 `unlock()` 函数来加锁和解锁。在 `main()` 函数中,我们创建了两个线程 `t1` 和 `t2`,同时调用 `print()` 函数并传入不同的参数。由于互斥锁的存在,两个线程会交替输出数字 1 和 2。 2. 条件变量(Condition Variable) 条件变量是一种线同步机制,它可以让线程在某个条件满足时才继续执行。C++ 中可以使用 `std::condition_variable` 类来创建条件变量,使用 `wait()` 函数等待条件,使用 `notify_one()` 或 `notify_all()` 函数唤醒等待的线程。 ```c++ #include <iostream> #include <thread> #include <mutex> #include <condition_variable> std::mutex mtx; // 创建互斥锁 std::condition_variable cv; // 创建条件变量 bool ready = false; void print(int num) { std::unique_lock<std::mutex> ulock(mtx); while (!ready) cv.wait(ulock); std::cout << num << std::endl; } int main() { std::thread t1(print, 1); std::thread t2(print, 2); std::this_thread::sleep_for(std::chrono::seconds(1)); { std::lock_guard<std::mutex> guard(mtx); ready = true; } cv.notify_all(); t1.join(); t2.join(); return 0; } ``` 在这个例子中,我们创建了一个互斥锁对象 `mtx` 和一个条件变量对象 `cv`,并在 `print()` 函数中使用了 `wait()` 函数等待条件。在 `main()` 函数中,我们创建了两个线程 `t1` 和 `t2`,并在一秒钟后唤醒两个线程。由于条件变量的存在,两个线程会等待条件满足后才会输出数字 1 和 2。 以上是两种常见的 C++ 线同步机制,当然还有其他的同步机制,如信号量、屏障等。不同的同步机制适用于不同的场景,需要根据实际情况选择合适的机制。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值