C++并发编程系列总结-锁

C++并发编程系列:关于锁的使用总结

Posted by YuCong on February 6, 2021

C++ 并发编程系列,关于锁的使用总结。


Created 2021.02.15 by William Yu; Last modified: 2022.09.16-v1.1.0

Contact: windmillyucong@163.com

Copyleft! 2022 William Yu. Some rights reserved.


lock

References

1. Concepts

锁类型 操作
互斥锁(mutex) - .lock()
- .unlock()
- .try_lock()
- std::lock_guard<>
- std::unique_lock<adopt_lock/defer_lock>
自旋锁(spinlock) - std::lock_guard<>
- std::unique_lock<adopt_lock/defer_lock>
读写锁(shared_mutex) - std::unique_lock<> (写锁)
- std::shared_lock<> (读锁)
递归锁(recursive_mutex) - std::lock_guard<>
- std::unique_lock<>
   

1.1 互斥锁 mutex

互斥锁

  • 在某一时刻,只有一个线程可以获取互斥锁
  • 在释放互斥锁之前其他线程都不能获取该互斥锁
  • 如果其他线程想要获取这个互斥锁,那么这个线程只能以阻塞方式进行等待
1
2
3
#include <mutex>

std::mutex some_mutex;

1.2 自旋锁 Spinlock

原理

  • 互斥锁是一种sleep-waiting 的锁
    • 流程为:假设线程T1获取互斥锁并且正在处理器core1上运行时,此时线程T2也想要获取互斥锁,但是由于T1正在使用互斥锁使得T2被阻塞。当T2处于阻塞状态时,T2被放入到等待队列中去,处理器core2会去处理其他任务而不必一直等待(忙等)。也就是说处理器不会因为线程阻塞而空闲着,它会去处理其他事务去。
  • 自旋锁是一种busy-waiting 的锁
    • 流程为:假设线程T1获取互斥锁并且正在处理器core1上运行时,此时线程T2也想要获取互斥锁,但是由于T1正在使用互斥锁使得T2被阻塞。此时运行T2的处理器core2会一直不断地循环检查锁是否可用,直到获取到锁为止。
    • 当发生阻塞时,互斥锁可以让CPU去处理其他的任务;而自旋锁让CPU一直不断循环请求获取这个锁。通过两个含义的对比可以我们知道”自旋锁”是比较耗费CPU的。

如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该使用自旋锁,否则使用互斥锁。 Q: 实际项目使用中,差别大吗?

实现一个自旋锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * @brief A spinlock implemented using an atomic_flag.
 * @remark Reference: https://en.cppreference.com/w/cpp/atomic/atomic_flag
 */
class Spinlock {
 public:
  Spinlock() = default;
  ~Spinlock() = default;

  void lock() {
    while (lock_.test_and_set(std::memory_order_acquire));
  }

  bool try_lock() {
    return !lock_.test_and_set(std::memory_order_acquire);
  }

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

 private:
  std::atomic_flag lock_ = ATOMIC_FLAG_INIT;
};

使用方法

1
2
3
4
5
6
7
Spinlock user_data_lock_;
UserData user_data_;
  
int main() {
    std::lock_guard<Spinlock> lock(user_data_lock_);
    user_data_ //balabala
}

1.3 读写锁 shared_mutex

  • “读者-写者”问题。

    • 计算机中某些数据被多个进程共享,对数据的操作有两种:

      • 一种是读操作,就是从数据库中读取数据不会修改数据库中内容;

      • 另一种就是写操作,写操作会修改数据库中存放的数据。

    • 我们允许对数据同时执行多个”读”操作

    • 但是某一时刻只能在数据库上有一个”写”操作来更新数据。

    • 这就是一个简单的读者-写者模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream> 
#include <thread> 
#include <shared_mutex> 
#include <vector> 
std::shared_mutex rwMutex; 

std::vector<int> sharedData; 

// 读操作函数 
void readData(int id) { 
	std::shared_lock<std::shared_mutex> readLock(rwMutex);
	std::cout << "Thread " << id << " is reading data: "; 
	for (int val : sharedData) { 
		std::cout << val << " "; 
	} 
	std::cout << std::endl; 
} 

// 写操作函数 
void writeData(int value) {
	std::unique_lock<std::shared_mutex> writeLock(rwMutex); 
	std::cout << "Writing data: " << value << std::endl; 
	sharedData.push_back(value); 
} 

int main() {
	// 创建写线程 
	std::thread writer(writeData, 42); 
	// 创建多个读线程
	std::vector<std::thread> readers; 
	for (int i = 0; i < 3; ++i) { 
		readers.emplace_back(readData, i); 
	} 
	// 等待写线程完成
	writer.join(); 
	// 等待所有读线程完成 
	for (auto& reader : readers) { 
		reader.join(); 
	} 
	return 0; 
}

1.4 递归锁 recursive_mutex

常用于递归函数中上锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>
#include <mutex>
#include <thread>

class RecursiveExample {
private:
    std::recursive_mutex mutex_;
    int value_ = 0;

public:
    // 递归函数示例
    void recursiveFunction(int depth) {
        std::lock_guard<std::recursive_mutex> lock(mutex_);
        
        if (depth <= 0) {
            return;
        }
        
        value_ += depth;
        std::cout << "Depth " << depth << ", value: " << value_ << std::endl;
        
        // 递归调用,会再次获取同一个锁
        recursiveFunction(depth - 1);
    }

    // 普通函数示例
    void normalFunction() {
        std::lock_guard<std::recursive_mutex> lock(mutex_);
        value_ += 1;
        std::cout << "Normal function, value: " << value_ << std::endl;
    }
};

int main() {
    RecursiveExample example;
    
    // 测试递归函数
    std::cout << "Testing recursive function:" << std::endl;
    example.recursiveFunction(3);
    
    // 测试普通函数
    std::cout << "\nTesting normal function:" << std::endl;
    example.normalFunction();
    
    return 0;
}

使用递归锁的注意事项:

  1. 性能考虑
    • 递归锁比普通互斥锁有更多的开销
    • 如果不需要递归获取锁,应该使用普通的 std::mutex
  2. 使用场景
    • 递归函数中需要保护共享资源
    • 同一个线程需要多次获取同一个锁
    • 复杂的类层次结构中,基类和派生类都需要获取同一个锁
  3. 替代方案
    • 如果可能,考虑重构代码以避免递归获取锁
    • 使用其他同步机制,如条件变量或信号量
  4. 最佳实践
    • 尽量限制递归深度
    • 确保每次获取锁都有对应的释放
    • 使用 RAII 风格的锁管理(如 std::lock_guard

1.5 条件锁 condition_variable

  • 条件锁 即 条件变量
  • 当某个条件满足时,以”信号量”的方式唤醒因为该条件而被阻塞的线程
  • 最为常见的使用场景就是: 在线程池中,起初没有任务时任务队列为空,此时线程池中的线程因为”任务队列为空”这个条件处于阻塞状态。一旦有任务进来,就会以信号量的方式唤醒一个线程来处理这个任务。

头文件:

1
#include <condition_variable>

类型:

  • std::condition_variable(只和std::mutex一起工作)
  • std::condition_variable_any(符合类似互斥元的最低标准的任何东西一起工作)

主要成员函数:

  • wait(): 阻塞当前线程,直到条件变量被唤醒
  • wait_for(): 阻塞当前线程,直到条件变量被唤醒或超时
  • wait_until(): 阻塞当前线程,直到条件变量被唤醒或到达指定时间点
  • notify_one(): 唤醒一个等待的线程
  • notify_all(): 唤醒所有等待的线程

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::mutex Mtx;
std::condition_variable Cv;
std::queue<int> DataQueue;
bool Done = false;

// 生产者线程
void Producer() {
    for (int i = 0; i < 5; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::lock_guard<std::mutex> Lock(Mtx);
        DataQueue.push(i);
        std::cout << "Produced: " << i << std::endl;
        Cv.notify_one();  // 通知消费者
    }
    
    std::lock_guard<std::mutex> Lock(Mtx);
    Done = true;
    Cv.notify_all();  // 通知所有消费者结束
}

// 消费者线程
void Consumer() {
    while (true) {
        std::unique_lock<std::mutex> Lock(Mtx);
        // 等待直到队列非空或Done为true
        Cv.wait(Lock, []{ return !DataQueue.empty() || Done; });
        
        if (Done && DataQueue.empty()) {
            break;
        }
        
        int Value = DataQueue.front();
        DataQueue.pop();
        std::cout << "Consumed: " << Value << std::endl;
    }
}

int main() {
    std::thread ProducerThread(Producer);
    std::thread ConsumerThread(Consumer);
    
    ProducerThread.join();
    ConsumerThread.join();
    
    return 0;
}

这个示例展示了条件变量的典型用法:

  1. 生产者线程向队列中添加数据,并通过notify_one()通知消费者
  2. 消费者线程使用wait()等待数据,直到队列非空或收到结束信号
  3. 使用unique_lock而不是lock_guard,因为wait()需要能够解锁和重新锁定互斥量

注意事项:

  1. 条件变量通常与互斥锁配合使用
  2. 使用wait()时应该总是检查条件,避免虚假唤醒
  3. 在修改共享数据时应该持有锁
  4. 在调用notify_one()notify_all()之前应该确保数据已经准备好

2. 上锁操作

2.1 mutex.lock() unlock()

对于互斥量,可以使用互斥量的 lock() 和 unlock() 方法上锁和解锁。

  • 需要手动调用
1
2
3
4
5
6
7
std::vector<long> some_list;  // 共享的数据
std::mutex some_mutex;  // 互斥量
void some_thread_function(){
	some_mutex.lock();
    // 一些操作
    some_mutex.unlock();
}

2.2 std::lock_guard<>

std::lock_guard

自动

  • 自动上锁,自动解锁
  • 而对互斥锁的lock()和unlock()需要手动调用
  • 构造时自动上锁
  • 离开局部作用域,析构函数自动解锁
1
2
3
4
5
6
std::vector<long> some_list;  // 共享的数据
std::mutex some_mutex;  // 互斥量
void some_thread_function(){
	std::lock_guard<std::mutex> lock(some_mutex);    // 修改数据之前上锁
    // 一些操作
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <mutex>   // 头文件

std::vector<long> some_list;  // 共享的数据
std::mutex some_mutex;  // 互斥量

void add_to_list(int new_value) {
  std::lock_guard<std::mutex> lock(some_mutex);    // 修改数据之前上锁
  some_list.push_back(new_value);
}

bool list_contains(int value_to_find) {
  std::lock_guard<std::mutex> lock(some_mutex);    // 访问数据之前上锁
  return std::find(some_list.begin(),some_list.end(),value_to_find) != some_list.end();
}

对自旋锁也是一样的上锁方法。

1
2
3
4
5
6
7
Spinlock user_data_lock_;
UserData user_data_;
  
int main() {
    std::lock_guard<Spinlock> lock(user_data_lock_);
    user_data_ //balabala
}

2.3 std::unique_lock<>

std::unique_lock

轻度灵活锁

  • 可以提供第二参数:
    • std::adopt_lock 假定当前线程已经获得锁,不再请求
    • std::defer_lock 表示在构造锁的时候并不上锁,使互斥量保持在解锁状态
    • std::try_to_lock 尝试请求锁,但不阻塞线程,锁不可用时也会立即返回
  • 如果不提供第二参数,表示构造时同时也上锁需要手动解锁

    1
    2
    3
    
    std::unique_lock<std::mutex> some_lock(some_mutex);
    // do something
    some_lock.unlock();
    
特点
  • std::unique_lockstd::lock_guard体积大,所以后者如果够用,建议优先使用后者
std::adopt_lock
  • 假定当前线程已经获得锁,不再请求

    1
    
       std::unique_lock<std::mutex> some_lock(some_mutex, std::adopt_lock);
    
std::defer_lock
  • 表示在构造锁的时候并不上锁,使互斥量保持在解锁状态
  • std::unique_lock锁对象可以传给lock()对象作为参数

    1
    2
    3
    4
    5
    
       std::unique_lock<std::mutex> some_lock(some_mutex, std::defer_lock);
       std::lock(some_lock);
       some_lock.lock();
       some_lock.unlock();
       some_lock.try_lock();
    

2.4 std::shared_lock<>

std::shared_lock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 读操作函数 
void readData(int id) { 
	std::shared_lock<std::shared_mutex> readLock(rwMutex);
	std::cout << "Thread " << id << " is reading data: "; 
	for (int val : sharedData) { 
		std::cout << val << " "; 
	} 
	std::cout << std::endl; 
} 

// 写操作函数 
void writeData(int value) {
	std::unique_lock<std::shared_mutex> writeLock(rwMutex); 
	std::cout << "Writing data: " << value << std::endl; 
	sharedData.push_back(value); 
} 

Contact

Feel free to contact me windmillyucong@163.com anytime for anything.


License

CC0