在多线程开发中,经常会遇到数据同步,很多情况下用锁都是一个很好的选择。C++中常用的锁主要有下面几种:
常见的锁
互斥锁(std::mutex)
- 这是最基本的一种锁。它用于保护共享资源,在任意时刻,最多只有一个线程可以获取该锁,从而访问被保护的资源。当一个线程获取了互斥锁后,其他试图获取该锁的线程会被阻塞,直到持有锁的线程释放它。
- 例如,在一个多线程程序中,如果多个线程需要访问和修改同一个全局变量,就可以使用互斥锁来确保在同一时间只有一个线程能够进行修改操作,避免数据竞争导致的错误结果。
#include <iostream>
#include <mutex>
#include <thread>
std::mutex m;
int counter = 0;
void increment()
{
m.lock();
counter++;
std::cout << "Counter value in thread " << std::this_thread::get_id() << " is " << counter << std::endl;
m.unlock();
}
int main()
{
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
return 0;
}
递归互斥锁(std::recursive_mutex)
- 递归互斥锁允许同一个线程多次获取该锁。它内部会记录锁的获取次数,每获取一次,计数加 1,每次释放锁时,计数减 1,当计数为 0 时,锁才真正被释放,可供其他线程获取。
- 假设在一个复杂的函数调用链中,函数 A 调用函数 B,函数 B 又调用函数 A,并且这些函数都需要访问同一个受保护的资源。如果使用普通互斥锁,就会出现死锁,而递归互斥锁就可以避免这种情况,因为它允许同一线程多次获取锁。
#include <iostream>
#include <mutex>
#include <thread>
std::recursive_mutex rm;
void recursiveFunction(int count)
{
rm.lock();
if (count > 0)
{
std::cout << "Recursive call with count = " << count << std::endl;
recursiveFunction(count - 1);
}
rm.unlock();
}
int main()
{
std::thread t(recursiveFunction, 3);
t.join();
return 0;
}
读写锁(std::shared_mutex) C++17开始才有
- 读写锁主要用于区分对共享资源的读操作和写操作。它有两种获取模式:共享模式(读模式)和独占模式(写模式)。
- 多个线程可以同时以共享模式获取读写锁,这意味着它们可以同时读取共享资源,而不会相互干扰。但是,当一个线程要以独占模式获取读写锁(进行写操作)时,其他线程(无论是读操作还是写操作)都不能获取该锁,直到写操作完成并释放锁。这种机制在有大量读操作和少量写操作的场景下,可以提高程序的并发性能。例如,在一个缓存系统中,多个线程可能经常读取缓存中的数据,只有在缓存数据需要更新时才会进行写操作,使用读写锁可以很好地处理这种情况。
#include <iostream>
#include <shared_mutex>
#include <thread>
#include <vector>
std::shared_mutex smtx;
int shared_data = 0;
void read_data()
{
std::shared_lock<std::shared_mutex> lock(smtx);
std::cout << "Read data: " << shared_data << std::endl;
}
void write_data(int new_value)
{
std::unique_lock<std::shared_mutex> lock(smtx);
shared_data = new_value;
std::cout << "Wrote data: " << shared_data << std::endl;
}
int main()
{
std::vector<std::thread> read_threads;
for (int i = 0; i < 5; i++)
{
read_threads.push_back(std::thread(read_data));
}
std::thread write_thread(write_data, 10);
for (auto& t : read_threads)
{
t.join();
}
write_thread.join();
return 0;
}
自旋锁(通常用std::atomic_flag实现)
- 自旋锁是一种忙等待的锁机制。当一个线程尝试获取自旋锁而锁已经被占用时,这个线程不会进入阻塞状态,而是会不断地检查(“自旋”)锁是否已经被释放。
- 自旋锁在等待时间较短的情况下可能会有比较好的性能表现,因为它避免了线程切换的开销。但是,如果等待时间过长,由于线程一直在占用 CPU 资源进行检查,会导致 CPU 资源的浪费。一般在底层代码或者对性能要求极高、等待时间预计很短的场景下使用。
#include <iostream>
#include <atomic>
#include <thread>
std::atomic_flag spinLock = ATOMIC_FLAG_INIT;
void criticalSection()
{
while (spinLock.test_and_set())
{
// 自旋等待
}
std::cout << "Entered critical section" << std::endl;
// 临界区操作
spinLock.clear();
}
int main()
{
std::thread t1(criticalSection);
std::thread t2(criticalSection);
t1.join();
t2.join();
return 0;
}
定时互斥锁(std::timed_mutex)
- 定时互斥锁是std::mutex的扩展。除了具备std::mutex的基本功能外,它还允许线程在尝试获取锁时设置一个超时时间。
- 如果在规定的超时时间内无法获取锁,线程不会一直等待,而是可以执行其他操作或者返回错误信息。这在一些对时间敏感的场景中非常有用,比如在一个实时系统中,线程不能因为等待一个锁而无限期地阻塞,需要在一定时间后放弃获取并进行其他处理。
#include <iostream>
#include <chrono>
#include <thread>
#include <mutex>
std::timed_mutex tm;
void tryLockFunction()
{
if (tm.try_lock_for(std::chrono::seconds(1)))
{
std::cout << "Acquired lock" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(2));
tm.unlock();
}
else
{
std::cout << "Could not acquire lock in time" << std::endl;
}
}
int main()
{
std::thread t1(tryLockFunction);
std::thread t2(tryLockFunction);
t1.join();
t2.join();
return 0;
}
递归定时互斥锁(std::recursive_timed_mutex)
- 这是结合了递归互斥锁和定时互斥锁特点的一种锁。它允许同一线程多次获取锁,并且在获取锁时可以设置超时时间。
- 当一个线程多次获取这种锁后,需要释放相同次数的锁,锁才会真正被释放,并且在获取锁的过程中,如果在超时时间内无法获取,线程可以采取相应的措施。
#include <iostream>
#include <chrono>
#include <thread>
#include <mutex>
std::recursive_timed_mutex rtm;
void recursiveTryLockFunction(int count)
{
if (rtm.try_lock_for(std::chrono::seconds(1)))
{
std::cout << "Recursive acquired lock, count = " << count << std::endl;
if (count > 0)
{
recursiveTryLockFunction(count - 1);
}
rtm.unlock();
}
else
{
std::cout << "Could not recursively acquire lock in time" << std::endl;
}
}
int main()
{
std::thread t(recursiveTryLockFunction, 3);
t.join();
return 0;
}
条件变量(std::condition_variable)配合互斥锁用于同步(严格来说条件变量不是锁,但常一起用于线程同步场景)
- 条件变量本身不是一种锁,但它通常和互斥锁一起使用,用于实现线程间的同步。它可以让一个线程等待某个条件满足后再继续执行。
- 例如,一个生产者 - 消费者模型中,消费者线程在缓冲区为空时可以使用条件变量等待,直到生产者线程生产出产品并通知消费者线程,这个过程中互斥锁用于保护缓冲区这个共享资源,条件变量用于实现线程间的通信和同步。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> buffer;
const int bufferSize = 5;
void producer()
{
for (int i = 0; i < 10; ++i)
{
std::unique_lock<std::mutex> lock(mtx);
while (buffer.size() == bufferSize)
{
cv.wait(lock);
}
buffer.push(i);
std::cout << "Produced: " << i << std::endl;
cv.notify_all();
}
}
void consumer()
{
for (int i = 0; i < 10; ++i)
{
std::unique_lock<std::mutex> lock(mtx);
while (buffer.empty())
{
cv.wait(lock);
}
int data = buffer.front();
buffer.pop();
std::cout << "Consumed: " << data << std::endl;
cv.notify_all();
}
}
int main()
{
std::thread producerThread(producer);
std::thread consumerThread(consumer);
producerThread.join();
consumerThread.join();
return 0;
}
信号量
- 信号量是一种特殊的变量,是操作系统层面的,可以被增加或减少。信号量用于保护一段资源,使其每次只能被有限的线程访问到。
- 对信号量的访问被保证是原子操作,不需要加锁。如果一个程序中有多个线程试图改变一个信号量的值,系统保证所有的操作都将依次进行。
- 信号量通常和互斥锁配合使用,互斥锁用于保护共享资源,防止多个线程同时访问同一资源导致的竞争问题。而信号量则用于控制对共享资源的并发访问数量,以此实现某些同步机制
函数接口
功能: 初始化信号量
返回值:失败返回-1,成功返回0
入参: pshared:为真表示为多个进程间共享,为0表示是当前进程的局部信号量
value: sem的初始值,信号量的大小
int sem_init(sem_t *psem, int pshared, unsigned int value);
功能: 获取信号量
返回值:失败返回-1,成功返回0
入参:
int sem_wait(sem_t *sem);
功能: 释放信号量
返回值:失败返回-1,成功返回0
入参:
int sem_post(sem_t *sem);
功能: 销毁信号量
返回值:失败返回-1,成功返回0
入参:
int sem_destory(sem_t *sem);
如果不销毁信号量,则线程退出时可能会产生问题,如果你的程序创建了大量的信号量而没有及时销毁,可能会将操作系统的资源耗尽。
使用示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
sem_t semDownload;
void func(void *arg)
{
sem_wait(&semDownload);
unsigned int *taskType = (unsigned int*)arg;
printf("============== Downloading taskType %d ============== \n", *taskType);
sleep(*taskType*2);
printf("============== Finished taskType %d ============== \n", *taskType);
sem_post(&semDownload);
}
int main()
{
if(sem_init(&semDownload, 0, 2) == -1){
printf("sem_init error\n");
return 0;
}
unsigned int taskTypeId;
pthread_t a_thread, b_thread, c_thread;
while (scanf("%d", &taskTypeId) != EOF)
{
unsigned int para1,para2,para3;
switch (taskTypeId)
{
case 1:
para1 = taskTypeId;
pthread_create(&a_thread, NULL, (void*)func, (void*)¶1);
break;
case 2:
para2 = taskTypeId;
pthread_create(&b_thread, NULL, (void*)func, (void*)¶2);
break;
case 3:
para3 = taskTypeId;
pthread_create(&c_thread, NULL, (void*)func, (void*)¶3);
break;
default:
printf("!!! error taskTypeId %d !!!\n", taskTypeId);
break;
}
}
sem_destroy(&semDownload);
return 0;
}
锁管理
锁管理遵循RAII机制来处理资源。锁管理器在构造函数中自动绑定它的互斥体,并在析构函数中释放它。这大大减少了死锁的风险,因为运行时会处理互斥体。。
锁管理器在C++ 11中有两种:
用于简单的std::lock_guard,以及用于高级用例的std::unique_lock。
std::lock_guard
-
std::lock_guard 是 C++ 标准库中的一个模板类,它用于简化互斥体(mutex)的管理,确保在 std::lock_guard 的生命周期内,互斥体被正确锁定和解锁。这有助于防止由于程序员忘记解锁互斥体而导致的死锁和其他并发问题。
-
简单的锁管理:是一种简单的互斥锁包装器,在构造函数中获取互斥锁,在析构函数中自动释放互斥锁,不提供手动获取或释放锁的功能,也不支持延迟加锁和尝试加锁等操作。
-
轻量级:由于其功能相对简单,实现较为轻量级,在不需要复杂锁管理的情况下,性能开销相对较小。
-
不可复制和移动:不允许复制和移动,这保证了一个std::lock_guard对象始终与一个特定的互斥锁绑定,并且在其生命周期内独占该锁。
-
创建即加锁,作用域结束自动析构并解锁,无需手工解锁
-
不能中途解锁,必须等作用域结束才解锁
std::lock_guard 的使用非常简单,你只需要提供一个互斥锁(std::mutex 或兼容的锁类型)作为构造函数的参数。当std::lock_guard 的构造函数接收一个互斥锁作为参数,尝试立即对其进行锁定。如果互斥锁已经被其他线程锁定,当前线程将阻塞,直到能够获取到锁。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 定义一个互斥锁
void print_block(int n, char c)
{
// 使用lock_guard来自动管理互斥锁的生命周期
std::lock_guard<std::mutex> guard(mtx); //获取锁
for (int i = 0; i < n; ++i)
{
std::cout << c;
}
std::cout << '\n';
} // guard 对象在这里被销毁,互斥锁会被自动释放
int main()
{
std::thread t1(print_block, 50, '*');
std::thread t2(print_block, 50, '$');
t1.join();
t2.join();
return 0;
}
std::lock_guard 适用于以下场景:
- 当我们需要保护一个简单的代码块,并且没有复杂的锁定逻辑时。
- 在不需要手动控制锁定时序的情况下,例如不需要实现懒锁定(延迟锁定)或尝试锁定。
- 当我们需要确保即使在异常情况下锁也能被释放时。
- 对于需要更多控制或与条件变量配合使用的场景,std::unique_lock 通常是更好的选择。
- std::unique_lock 提供了更多的灵活性,如支持延迟锁定、可重入锁定、条件变量等待以及可移动语义。
std::unique_lock
- std::unique_lock 是 C++11 提供的一个用于管理互斥锁的类,它提供了更灵活的锁管理功能,适用于各种多线程场景。
- 灵活的锁管理:提供了更灵活的锁操作,可以在构造函数中获取锁,也可以在之后的任何时间手动获取或释放锁,还支持延迟加锁、尝试加锁等功能。
- 所有权转移:具有独占所有权语义,即一个std::unique_lock对象在任何时刻只能与一个互斥锁相关联,并且可以通过移动语义将锁的所有权转移给其他std::unique_lock对象,但不能复制。
- 支持条件变量:与std::condition_variable配合使用时,能够在等待条件满足时自动释放锁,并在条件满足后重新获取锁,无需手动干预。
创建 std::unique_lock 对象
std::unique_lock<std::mutex> lock(mutex); // 创建 std::unique_lock 并关联互斥锁 mutex
你可以在构造函数中传入一个互斥锁(std::mutex 或其它互斥锁类型)来创建 std::unique_lock 对象,并且会在构造时获取互斥锁的所有权。此时,互斥锁被锁住,其他线程无法获得锁。
自动加锁和解锁
{
std::unique_lock<std::mutex> lock(mutex); // 自动加锁
// 临界区代码
} // 自动解锁
使用 std::unique_lock 创建的对象,当其生命周期结束时(通常是在大括号的作用域结束时),会自动解锁互斥锁,以确保互斥锁在不再需要时被释放。
延迟加锁与手动加解锁
std::unique_lock 还支持在初始化时不立即加锁,而是在需要时延迟加锁。这种特性对于一些多线程场景非常有用,允许你在获得锁之前执行一些非临界区的操作,从而减少锁的持有时间。
创建 std::unique_lock 对象时,传入互斥锁但不加锁:
std::unique_lock<std::mutex> lock(mutex, std::defer_lock);
在需要时手动加锁:
lock.lock(); // 手动加锁
// 临界区代码
lock.unlock(); // 手动解锁
你可以使用 lock() 手动加锁,然后在互斥锁保护的临界区内执行代码,最后使用 unlock() 手动解锁。这种方式可以让你更灵活地控制锁的生命周期。
尝试加锁
std::unique_lock 还提供了 try_lock() 方法,用于尝试加锁,如果锁不可用,则返回 false,如果锁成功获取,则返回 true。
std::unique_lock<std::mutex> lock(mutex, std::defer_lock);
if (lock.try_lock()) {
// 锁成功获取,执行临界区代码
lock.unlock();
} else {
// 锁不可用,执行其他逻辑
}
配合条件变量使用
condition_variable(条件变量)是 C++11 中提供的一种多线程同步机制,它允许一个或多个线程等待另一个线程发出的通知,以便能够有效地进行线程同步。
条件变量(std::condition_variable)需要与 std::unique_lock 一起使用,以实现线程的等待和通知机制。
std::unique_lock<std::mutex> lck(mutex);
while (!condition) {
conditionVariable.wait(lock); // 条件不满足并释放锁
}
// 条件满足,重新获取锁并继续执行
条件变量的成员函数 wait() 会在阻塞线程的那一刻(当线程被添加到等待队列中时),函数会自动调用 lck.unlock() 释放锁,允许其他锁定的线程继续执行。
一旦收到唤醒通知(由其他线程调用 notify_one() 或 notify_all() 通知),该函数就会解除阻塞并调用 lck.lock(),使 lck 处于与调用该函数时相同的状态,然后函数返回。请注意,返回前调用 lck.lock() 加锁可能会再次阻塞线程。
所有权转移
在 C++ 中,std::unique_lock的可移动性是指其支持移动语义,这主要体现在以下几个方面:
- 移动构造函数
std::unique_lock的移动构造函数允许将一个std::unique_lock对象的所有权从一个对象转移到另一个对象,转移之后,原对象不再拥有互斥锁的所有权,而新对象获得了该所有权。例如:
std::mutex mtx;
{
std::unique_lock<std::mutex> lock1(mtx);
// 此时lock1拥有mtx的锁
std::unique_lock<std::mutex> lock2(std::move(lock1));
// 调用移动构造函数,lock1的所有权转移给lock2,lock1不再拥有锁,lock2拥有锁
}
// 在此处,lock2析构,自动释放mtx的锁
- 移动赋值运算符
移动赋值运算符operator=也实现了类似的功能,它可以将一个std::unique_lock对象的所有权转移给另一个已存在的对象,同时释放原对象所拥有的锁。例如:
std::mutex mtx;
std::unique_lock<std::mutex> lock1(mtx);
std::unique_lock<std::mutex> lock2;
lock2 = std::move(lock1);
// lock1的所有权转移给lock2,lock1不再拥有锁,lock2拥有锁
- 函数参数和返回值传递
作为函数参数传递时,std::unique_lock可以通过移动语义将锁的所有权转移到函数内部,使得函数能够在内部使用该锁,而调用者在传递后不再拥有锁的所有权。例如:
void func(std::unique_lock<std::mutex> lock) {
// 函数内部可以使用传递进来的锁
}
std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
func(std::move(lock));
// 调用func后,lock的所有权转移到func内部,外部的lock不再拥有锁
作为函数返回值时,std::unique_lock可以将在函数内部创建并获取的锁的所有权转移到函数外部,使得调用者能够获得该锁的所有权。例如:
std::unique_lock<std::mutex> getLock() {
std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
return lock;
}
std::unique_lock<std::mutex> myLock = getLock();
// getLock函数返回的锁的所有权转移到myLock中
std::unique_lock的可移动语句使得在多线程编程中能够更灵活地管理互斥锁的所有权,方便在不同的作用域和函数之间传递和共享互斥锁,同时保证了互斥锁的正确获取和释放,避免了资源泄漏和死锁等问题。