C++并发编程中的锁的介绍

2023-11-30 00:06:29 浏览数 (1)

C 中的锁

C 中的锁机制

C 中的锁机制以下几种:

  • 互斥锁:包括std::mutex、std::recursive_mutex、std::timed_mutex、std::recursive_timed_mutex等。互斥锁用于保护共享资源,可以保证同一时刻只有一个线程访问共享资源。
  • 读写锁:包括std::shared_mutex、std::shared_timed_mutex等。读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。
  • 条件变量:包括std::condition_variable、std::condition_variable_any等。条件变量允许线程等待某个条件发生变化,只有当条件满足时才能继续执行。
  • 原子操作:包括std::atomic、std::atomic_flag等。原子操作用于保证某个操作的执行不会被其他线程中断,从而避免了数据竞争的发生。
  • 自旋锁:包括std::spin_lock、std::atomic_flag等。自旋锁在等待锁的过程中不断地循环检查锁是否可用,而不是放弃CPU,从而避免了线程上下文切换带来的开销。
  • 信号量:包括std::binary_semaphore、std::counting_semaphore等。信号量用于控制同时访问某个资源的线程数量,可以实现线程的互斥和同步。
悲观锁和乐观锁

在C 中,锁通常被分为两种类型:悲观锁和乐观锁

  • 其中悲观锁是指在访问共享资源时先获取锁,防止其他线程同时修改该资源,适用于写操作多的场景。C 中的互斥锁就是一种悲观锁。
  • 而乐观锁则是在不加锁的情况下,尝试去读取和修改共享资源,如果遇到冲突,再使用重试等机制解决冲突,适用于读操作多于写操作的场景。
代码语言:txt复制
-   在C  中,可以使用atomic类型来实现乐观锁。atomic类型提供了对基本类型的原子操作,包括读、写、比较交换等。在进行原子操作时,它使用硬件原语实现同步,避免了使用锁所带来的额外开销和死锁的问题。
-   除了atomic类型,C  11还引入了一些使用乐观锁的算法,如无锁队列和无锁哈希表等。这些算法使用原子操作来实现线程安全,同时充分利用了乐观锁的优势,避免了使用锁所带来的开销。

Mutex:互斥

所有线程间共享数据的问题,都是修改数据导致的(竞争条件) 。如果所有的共享数据都是只读的,就没问题,因为一个线程所读取的数据不受另一个线程是否正在读取相同的数据而影响

恶性条件竞争

恶性条件竞争通常发生于多线程对多于一个的数据块的修改时,产生了非预想的执行效果,即竞态条件是多个线程同时访问共享资源,导致结果依赖于线程执行的顺序和时间。

  • 竞态条件(Race Condition)指的是多个线程访问共享变量时,最终的结果取决于多个线程的执行顺序。竞态条件不一定总是错误的,但它们可能导致非预期的结果。
  • 数据竞争(Data Race)指的是多个线程同时访问同一个共享变量,并且至少有一个线程对该变量进行了写操作。数据竞争是一种错误,因为它可能导致未定义的行为。

在多线程编程中,竞态条件和数据竞争是常见的问题。解决这些问题的关键是使用同步机制。

避免恶性条件竞争:

  • 要避免恶性的条件竞争,一种方法是就使用一定的手段,对线程共享的内存区域的数据结构采用某种保护机制,如使用锁
  • 另一种就是选择对该区域的数据结构和不变量的设计进行修改,如保证该区域为原子操作,,修改完的结构必须能完成一系列不可分割的变化,但是这种无锁的方法很难一定保证线程安全
  • 另一种处理条件竞争的方式是,使用事务(transacting)的方式去处理该数据共享区域
mutex 头文件介绍

mutex 又称互斥量,C 11 中与 mutex 相关的类(包括锁类型)和函数都声明在 <mutex> 头文件中,所以如果你需要使用 std::mutex,就必须包含<mutex>头文件。

mutex系列类(四种)
  • std::mutex:基本的 mutex 类。
  • std::recursive_mutex:递归 mutex 类。
  • std::time_mutex:定时 mutex 类。
  • std::recursive_timed_mutex:定时递归 mutex 类。
函数
  • std::try_lock:尝试同时对多个互斥量上锁。
  • std::lock:可以同时对多个互斥量上锁。
  • std::call_once:如果多个线程需要同时调用某个函数,call_once 可以保证多个线程对该函数只调用一次。
mutex:C 互斥锁

C 中通过实例化 std::mutex 创建互斥量,通过调用成员函数lock()进行上锁,unlock()进行解锁。对于互斥量,必须记住在每个线程执行完毕后必须去unlock()释放已获得的锁。

值得一提的是,C 标准库为互斥量提供了一个RAII语法的模板类std::lock_guardstd::unique_lock。 - std::lock_guard :其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,此时就不用手动去解锁unlock,即使发生异常也会释放,从而保证了一个已锁的互斥量总是会被正确的解锁。 - std::unique_lockunique_lock更加灵活,可以在任意的时候加锁或者解锁,因此其资源消耗也更大,通常是在有需要的时候(比如和条件变量配合使用,我们将在介绍条件变量的时候介绍这个用法)才会使用,否则用lock_guard

std::mutex 的成员函数:

  • 构造函数:std::mutex不允许拷贝构造,也不允许 move 拷贝,最初产生的 mutex 对象是处于 unlocked 状态的。
  • lock():调用线程将锁住该互斥量。线程调用该函数会发生下面3种情况:
代码语言:txt复制
-   如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock 之前,该线程一直拥有该锁;
-   如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住,直到 mutex 被释放;
-   如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
  • unlock():释放对互斥量的所有权。
  • try_lock():尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面3种情况:
代码语言:txt复制
-   如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 `unlock` 释放互斥量。
-   如果当前互斥量被其他线程锁住,则当前调用线程返回 `false`,**而并不会被阻塞掉。**
-   如果当前互斥量被当前调用线程锁住,则会产生死锁(`deadlock`)。
代码语言:javascript复制
#include <iostream>
#include <thread>
#include <list>
#include <vector>
#include <mutex>

std::list<int> my_list;
int sum = 0;
std::mutex my_mutex;

void add_to_list(int new_value)
{
	for (int i = 0; i < 10; i  ) {
	//使用std::lock_guard类模板,使用RAII机制
		std::lock_guard<std::mutex> guard(my_mutex);
		sum  ;
		my_list.push_back(new_value);
		std::cout << "线程插入值为:" << new_value << std::endl;
	}
}

int main(int argc, char* argv[])
{
	std::vector<std::thread> thr(5);
	for (int i = 0; i < 5; i  ) {
		thr[i] = std::thread(add_to_list, i);
	}
	for (auto& t:thr) {
		t.join();
	}
	std::cout << sum << std::endl;
	for (auto j : my_list)
		std::cout << j << std::endl;
}

使用互斥量保护数据,保证线程安全绝不简单,不是简简单单的在共享数据区域内加locktry_lock或者std::lock_guard那样。一个迷失的指针或引用,将会让这种保护形同虚设

  • 比如你的函数返回的是指针或者引用,这时候就得小心,
  • 同样当你成员函数指针或引用的方式来调用,也要小心
锁的粒度

锁的粒度是用来描述通过一个锁保护着的数据量大小。一个细粒度锁(a fine-grained lock)能够保护较小的数据量,一个粗粒度锁(a coarse-grained lock)能够保护较多的数据量。选择粒度对于锁来说很重要:

  • 为了保护对应的数据,保证锁有能力保护这些数据也很重要,
  • 但是锁的粒度太粗,就会导致锁竞争频繁,消耗不必要的资源,也会导致多线程并发收益不高

因此必须保证锁的粒度既可以保证线程安全也能保证并发的执行效率。

死锁

死锁是指两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

死锁引起的原因
  • 竞争不可抢占资源引起死锁(不可抢占是指没有使用完的资源,不能被抢占)
  • 竞争可消耗资源引起死锁:有p1,p2,p3三个进程,p1向p2发送消息并接受p3发送的消息,p2向p3发送消息并接受p1的消息,p3向p1发送消息并接受p2的消息,如果设置是先接到消息后发送消息,则所有的消息都不能发送,这就造成死锁。
  • 进程推进顺序不当引起死锁:有进程p1,p2,都需要资源A,B,本来可以p1运行A --> p1运行B --> p2运行A --> p2运行B,但是顺序换了,p1运行A时p2运行B,容易发生第一种死锁。互相抢占资源。
死锁的必要条件
  • 互斥条件:某资源只能被一个进程使用,其他进程请求该资源时,只能等待,直到资源使用完毕后释放资源。
  • 请求和保持条件:程序已经保持了至少一个资源,但是又提出了新要求,而这个资源被其他进程占用,自己占用资源却保持不放。
  • 不可抢占条件:进程已获得的资源没有使用完,不能被抢占。
  • 循环等待条件:必然存在一个循环链。
预防死锁的思路
  • 预防死锁: 破坏死锁的四个必要条件中的一个或多个来预防死锁。
  • 避免死锁: 和预防死锁的区别就是,在资源动态分配过程中,用某种方式防止系统进入不安全的状态。
  • 检测死锁: 运行时出现死锁,能及时发现死锁,把程序解脱出来
  • 解除死锁:发生死锁后,解脱进程,通常撤销进程,回收资源,再分配给正处于阻塞状态的进程。
预防死锁的方法
  • 破坏请求和保持条件
代码语言:txt复制
-   **协议1:** 所有进程开始前,必须一次性地申请所需的所有资源,这样运行期间就不会再提出资源要求,破坏了请求条件,即使有一种资源不能满足需求,也不会给它分配正在空闲的资源,这样它就没有资源,就破坏了保持条件,从而预防死锁的发生。
-   **协议2:** 允许一个进程只获得初期的资源就开始运行,然后再把运行完的资源释放出来。然后再请求新的资源。
  • 破坏不可抢占条件:当一个已经保持了某种不可抢占资源的进程,提出新资源请求不能被满足时,它必须释放已经保持的所有资源,以后需要时再重新申请 。
  • 破坏循环等待条件: 对系统中的所有资源类型进行线性排序,然后规定每个进程必须按序列号递增的顺序请求资源。假如进程请求到了一些序列号较高的资源,然后有请求一个序列较低的资源时,必须先释放相同和更高序号的资源后才能申请低序号的资源。多个同类资源必须一起请求。

#### recursive_mutex 介绍

std::recursive_mutex 与 std::mutex 一样,也是一种可以被上锁的对象,但是和 std::mutex 不同的是,std::recursive_mutex 允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,std::recursive_mutex 释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),可理解为 lock() 次数和 unlock() 次数相同,除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。

time_mutex 介绍

std::time_mutex 比 std::mutex 多了两个成员函数,try_lock_for()try_lock_until()

  • try_lock_for:函数接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与 std::mutex 的 try_lock() 不同,try_lock() 如果被调用时没有获得锁则直接返回 false),如果在此期间其他线程释放了锁,则该线程可以获得对斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false
  • try_lock_until:函数则接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false

#### recursive_timed_mutex 介绍

和 std:recursive_mutex 与 std::mutex 的关系一样,std::recursive_timed_mutex 的特性也可以从 std::timed_mutex 推导出来,感兴趣的同鞋可以自行查阅。

自旋锁

自旋锁(spin lock)是一种多线程同步机制,它是在等待锁的过程中不断地循环检查锁是否可用,而不是放弃CPU,从而避免了线程上下文切换带来的开销。自旋锁适用于锁被占用的时间很短的场景,因为自旋锁在等待锁的过程中会一直占用CPU,当锁被占用的时间较长时,这种方式会浪费大量的CPU资源。在锁的持有时间较短的情况下,自旋锁可以在等待锁的过程中避免线程上下文切换的开销,从而提高性能。

自旋锁std::spin_mutex是C 17中的新特性,定义在<mutex>头文件中。需要使用编译器支持C 17的特性才能使用std::spin_mutex

在C 11中,可以使用std::atomic_flag来实现自旋锁,它是一个布尔类型的原子变量,但是在使用时需要注意以下几点:

  • 必须用 ATOMIC_FLAG_INIT 初始化 std::atomic_flag。
  • 由于 std::atomic_flag 只有两种状态(被设置或未被设置),所以我们可以使用 test_and_set 成员函数来实现自旋锁的加锁和解锁操作。
  • 由于自旋锁是一种忙等的锁,所以在使用 std::atomic_flag 实现自旋锁时需要避免死锁。
代码语言:javascript复制
#include <atomic>
#include <thread>

class SpinLock {
 public:
  SpinLock() : flag_(ATOMIC_FLAG_INIT) {}

  void lock() {
    while (flag_.test_and_set(std::memory_order_acquire)) {
      // 自旋等待
    }
  }

  void unlock() { flag_.clear(std::memory_order_release); }

 private:
  std::atomic_flag flag_;
};

在上述代码中,test_and_set 成员函数将 flag_ 设置为true 并返回之前的值,如果返回的值为 true,则表示自旋等待;如果返回的值为 false,则表示自旋锁已经被当前线程占用,可以执行加锁操作。clear 成员函数将 flag_ 设置为 false

需要注意的是,std::atomic_flag 对象的默认内存顺序是 std::memory_order_seq_cst,在使用时需要根据具体情况进行调整,例如在加锁时使用 std::memory_order_acquire,在解锁时使用 std::memory_order_release

我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!

0 人点赞