前情回顾: 在前一篇文章中,我们了解了创建线程、向线程中传递参数、移交线程的归属权,识别线程id等等。那么我们在该篇文章中将要讨论一下线程之间数据共享的问题。
当多线程同时存在访问共享资源的时候,其结果依赖于这些线程的执行顺序,这样的一种状况叫做条件竞争。但如果多线程都只是读取共享资源时,那么条件竞争并不会产生坏的影响。但当条件竞争会因执行顺序不同而结果不同,执行顺序不是程序员所期待的,这就是一种恶性的条件竞争。
恶性条件竞争:是指在多线程编程中,程序的正确性依赖于线程的执行顺序,如果线程的执行顺序不是程序员所期望的,那么就会导致程序出现错误。恶性条件竞争通常是由于锁的使用不当、共享变量的访问不同步、多线程访问同一个资源等原因导致的。这种竞争非常难以发现和修复,而且可能会导致程序崩溃或产生不可预测的结果,因此应该尽量避免。
#include
#include
int counter = 0;
void increment_counter()
{
counter++;
}
int main()
{
std::thread t1(increment_counter);
std::thread t2(increment_counter);
t1.join();
t2.join();
std::cout << "Counter = " << counter << std::endl;
return 0;
}
该程序创建了两个线程 t1 和 t2,并让它们同时执行 increment_counter 函数,该函数将全局计数器 counter 加 1。如果两个线程同时访问 counter,那么就可能出现条件竞争。在某些情况下,两个线程可能同时读取 counter 的旧值,然后将其加 1,导致最终结果不是预期的 2。
那么我们该如何防止在多线程开发中出现的恶行条件竞争呢?肯定是由办法的!
我们首先提出第一种方法:使用互斥锁(mutex)保护共享数据!
std::mutex 是 C++11 标准库提供的一个互斥锁类,用于保护多个线程共享访问的数据。其定义如下:
class mutex {
public:
void lock(); // 上锁,如果已经被上锁了则阻塞当前线程
bool try_lock(); // 尝试上锁,如果已经被上锁了则立即返回 false,否则返回 true 并上锁
void unlock(); // 解锁,如果没有被上锁则行为未定义
};
写一个成员函数上锁实例:
#include
#include
#include
std::mutex mtx;
int shared_data = 10;
void func()
{
mtx.lock();
shared_data++;
std::cout << "shared_data is " << share_data << std::endl;
mtx.unlock();
}
int main()
{
std::thread t(func);
std::thread t1(func);
t.join();
t1.join();
return 0;
}
lock() 和 unlock() 方法分别用于上锁和解锁,可以在多个线程之间进行同步。在多个线程访问共享资源的时候,需要先上锁,访问完成之后再解锁,以确保同一时间只有一个线程访问该资源。
当某个线程对共享资源上锁之后,其他线程对同一共享资源的访问就会被阻塞,直到该线程解锁。如果没有及时解锁,则其他线程将一直被阻塞,直到该线程解锁。在 C++11 中,可以使用 std::lock_guard 或 std::unique_lock 等 RAII 封装类来自动管理互斥锁的上锁和解锁,避免手动调用 lock() 和 unlock() 方法导致的代码错误。
std::lock_guard 是 C++ 标准库中提供的一个互斥锁管理类,它是一个模板类,需要传入互斥锁对象作为模板参数。它使用了 RAII(资源获取即初始化)技术,能够自动的进行加锁和解锁,从而防止忘记解锁导致的死锁问题。
当 std::lock_guard 对象被创建时,它会尝试对互斥锁进行加锁,当该对象被销毁时,它会自动调用 std::mutex 对象的 unlock 方法来解锁。
使用 std::lock_guard 可以有效地简化代码,避免手动进行加锁和解锁操作,从而减少出错的可能性。
以下是使用 std::lock_guard 进行互斥锁保护的一个示例代码:
#include
#include
#include
std::mutex mtx;
void print(std::string msg)
{
std::lock_guard<std::mutex> lock(mtx);
std::cout << msg << std::endl;
}
int main()
{
std::thread t1(print, "hello");
std::thread t2(print, "world");
t1.join();
t2.join();
return 0;
}
在上述示例中,我们使用 std::lock_guard 对象 lock 来保护了共享资源 std::cout 的访问。每当线程调用 print 函数时,就会自动加锁 mtx,并输出相应的信息。在 print 函数执行完毕后,std::lock_guard 对象 lock 会自动被销毁,从而释放 mtx 的所有权并解锁它。
std::unique_lock 是 C++11 标准中提供的一种锁类型,它提供了比 std::lock_guard 更为灵活的锁管理方式。与 std::lock_guard 类似,std::unique_lock 也是 RAII(资源获取即初始化)机制的实现方式之一,可以保证在作用域结束时自动释放锁资源。
与 std::lock_guard 不同的是,std::unique_lock 对象在构造时可以选择是否锁定关联的互斥量,同时在对象生命周期内可以多次获得或释放锁,从而提供了更为灵活的锁管理方式。另外,std::unique_lock 对象也提供了更多的锁操作方法,例如延迟锁定、锁定超时等。
下面是一个使用 std::unique_lock 的示例:
#include
#include
#include
std::mutex mtx;
void func()
{
std::unique_lock<std::mutex> lck(mtx);
std::cout << "Thread " << std::this_thread::get_id() << " is executing..." << std::endl;
// 在作用域内,unique_lock 对象已经自动加锁了,可以安全地访问共享资源
}
int main()
{
std::thread t1(func);
std::thread t2(func);
t1.join();
t2.join();
return 0;
}
在上面的示例中,std::unique_lock 对象 lck 的构造函数中传入了互斥量 mtx,在作用域内 lck 对象已经自动加锁了。可以通过 std::unique_lock 对象的 unlock() 方法手动释放锁,或者通过 std::unique_lock 对象的 lock() 方法再次加锁。
#include
#include
#include
int shared_data = 10;
void func(std::mutex& m)
{
std::unique_lock<std::mutex> lock(m, std::defer_lock);
lock.lock(); // 明确地调用lock获取锁
std::cout << "Thread " << std::this_thread::get_id() << " is doing some work" << std::endl;
// 在这里进行一些需要互斥访问的工作
shared_data++;
std::cout << "shared_data is " << shared_data << std::endl;
std::cout << "Thread " << std::this_thread::get_id() << " has released the lock" << std::endl;
lock.unlock();
}
int main()
{
std::mutex m;
std::thread t1(func, std::ref(m));
std::thread t2(func, std::ref(m));
t1.join();
t2.join();
return 0;
}
std::condition_variable::wait_for() 是 C++11中提供的等待函数,用于等待一个条件变量在一定时间内变为 true。它常常和std::unique_lock 一起使用,可以实现多线程的同步和互斥操作。 它的函数原型如下:
template< class Rep, class Period > std::cv_status wait_for( std::unique_lock<std::mutex>& lock, const std::chrono::duration<Rep,Period>& timeout_duration );
其中,lock 表示互斥锁,timeout_duration 表示等待的时间。
wait_for() 的返回值有以下几种:
std::cv_status::no_timeout: 表示条件变量在等待时间内被唤醒;
std::cv_status::timeout: 表示等待超时。
下面是一个超时锁定的实例:
#include
#include
#include
#include
std::mutex mtx;
std::condition_variable cv;
bool data_ready = false;
void consumer()
{
std::unique_lock<std::mutex> lock(mtx);
while(!data_ready)
{
if(cv.wait_for(lock, std::chrono::seconds(1)) == std::cv_status::timeout)
{
std::cout << "timeout\n";
return;
}
}
std::cout << "data ready\n";
}
void producer()
{
std::this_thread::sleep_for(std::chrono::seconds(3));
data_ready = true;
cv.notify_one();
}
int main()
{
std::thread t1(consumer);
std::thread t2(producer);
t1.join();
t2.join();
return 0;
}
上述示例中,consumer() 是消费者线程,它等待 data_ready 变为 true,如果超时则输出 timeout,否则输出 data ready。producer() 是生产者线程,它在3秒后将 data_ready 设为 true,并调用 cv.notify_one() 唤醒消费者线程。
比如当我们在多线程开发的初始化过程中用到一些共享数据,但是我们只想让这个初始化过程只运行一次,那么该怎么协调让多个线程对初始化函数只运行一次呢?
在C++11标准中,为了支持一些只需要被执行一次的线程安全操作,引入了std::call_once()函数和std::once_flag()函数。它们通常被用来实现一些全局初始化和单例模式等场景。该函数在
std::call_once()函数的声明如下:
template <class Callable, class... Args>
void call_once(std::once_flag& flag, Callable&& func, Args&&... args);
其中,std::once_flag是一个标志位类型,用于保证func只会被执行一次。Callable是一个可调用对象类型,可以是函数指针、函数对象、Lambda表达式等,args是func的参数列表。
std::call_once()函数的使用方法比较简单,只需要传入一个std::once_flag对象和一个可调用对象,就可以保证该可调用对象只会被执行一次,例如:
#include
#include
std::once_flag flag;
void initialize()
{
std::cout << "initialize" << std::endl;
}
void thread_func()
{
std::call_once(flag, initialize);
}
int main()
{
std::thread t1(thread_func);
std::thread t2(thread_func);
t1.join();
t2.join();
return 0;
}
上面的代码中,我们在initialize()函数中输出一条消息,然后在thread_func()函数中调用std::call_once()函数保证initialize()函数只会被执行一次。最后,在main()函数中创建了两个线程,并分别执行thread_func()函数。
由于std::call_once()函数的存在,我们可以放心地使用initialize()函数进行全局初始化,而不用担心它会被重复执行。
需要注意的是,std::once_flag对象只能用于一次调用std::call_once()函数。如果需要多次执行一些只需要被执行一次的操作,可以创建多个std::once_flag对象。
但也有这样一种情况,比如有个数据表,这个数据表里面的数据很少会进行修改,那么如果还使用std::mutex的话,当一个线程进行访问的时候,其他线程需要等待访问线程释放锁了之后才能进行访问。这样未免也太浪费时间了。但不用着急,也有解决的办法!
std::shared_mutex和std::shared_timed_mutex是C++11标准库中
std::shared_mutex和std::shared_timed_mutex的主要区别在于前者不支持超时等待,而后者支持。因此,在需要对共享资源进行读写访问的场景中,如果需要实现超时等待的功能,则可以使用std::shared_timed_mutex;否则,可以使用std::shared_mutex。
那什么场合下比较使用使用这种方法呢?
当多个线程需要同时读取共享数据,而只有少数线程需要写入数据时,可以使用std::shared_mutex和std::shared_timed_mutex来提高并发性能。这是因为,读取操作可以并发进行,而写入操作需要互斥执行。
读多写少的情况,例如在多线程的服务器应用程序中,多个线程需要读取某些共享数据,但只有少数线程需要修改这些数据。
需要对共享数据进行时间限制的情况,例如在超时机制中,需要对共享数据进行读取和写入,并且需要限制读取和写入操作的时间。
需要注意的是,std::shared_mutex和std::shared_timed_mutex适用于读多写少的场景,如果读写操作的比例接近1:1,建议使用std::mutex或std::timed_mutex等其他的同步原语。
使用std::shared_mutex的实例:
#include
#include
#include
#include
#include
std::vector<int> vec;
std::shared_mutex smutex;
void writer()
{
for(int i = 0; i < 100; ++i)
{
std::unique_lock<std::shared_mutex> lock(smutex);
vec.push_back(i);
}
}
void reader(int id)
{
for(int i = 0; i < 10; ++i)
{
std::shared_lock<std::shared_mutex> lock(smutex);
std::cout << "Reader " << id << " is reading: ";
for(int j = 0; j < vec.size(); ++j)
{
std::cout << vec[j] << " ";
}
std::cout << std::endl;
}
}
int main()
{
std::thread t1(writer);
std::vector<std::thread> threads;
for(int i = 0; i < 5; ++i)
{
threads.push_back(std::thread(reader, i+1));
}
t1.join();
for(int i = 0; i < 5; ++i)
{
threads[i].join();
}
return 0;
}
在上述代码中,我们定义了一个全局的std::vector类型的vec变量,同时定义了一个std::shared_mutex类型的smutex变量,用于对vec变量进行读写保护。在writer函数中,我们对vec变量进行100次写入操作,而在reader函数中,我们对vec变量进行10次读取操作。在reader函数中,我们使用std::shared_lock类型的锁来对vec进行读取保护,而在writer函数中,我们使用std::unique_lock类型的锁来对vec进行写入保护。
在 reader() 函数中,我们使用 std::shared_lock 来获取共享锁,而不使用 std::unique_lock来获取独占锁,是因为共享锁可以被多个线程同时获取,而独占锁只能被一个线程获取。
在这个示例中,我们希望多个线程可以同时读取变量 data 的值,因此我们需要使用共享锁来保护这个变量。如果我们使用独占锁来保护变量data,那么只有一个线程可以获取锁,其他线程将被阻塞,无法读取变量 data。
使用 std::shared_lock的另一个好处是它可以允许多个线程同时获取锁,从而提高并发性能。当多个线程都只需要读取数据时,使用共享锁可以避免线程之间的竞争,提高程序的效率。
因此,在这个示例中,使用 std::shared_lock 是正确的选择,可以保证多个线程可以同时读取变量 data的值,而不会导致线程之间的竞争和阻塞。
- std::shared_lock和std::unique_lock的区别:
std::shared_lock 和 std::unique_lock 是 C++标准库中两种不同类型的锁,它们之间的主要区别在于锁的所有权和线程的并发性。
1. 锁的所有权
std::unique_lock 拥有独占锁,它允许一个线程独占锁,并且可以在锁定期间多次释放和获取锁。std::shared_lock 拥有共享锁,它允许多个线程同时获取共享锁,但不能独占锁。在锁定期间,std::shared_lock 和 std::unique_lock 都可以释放和获取锁。
2. 线程的并发性 std::unique_lock 允许线程独占锁,因此只能有一个线程同时持有 std::unique_lock。而 std::shared_lock 允许多个线程同时获取共享锁,因此可以有多个线程同时持有std::shared_lock。这样可以提高程序的并发性能。
3. 另外,std::unique_lock 和 std::shared_lock 还有一些其他的区别:
- 构造函数的参数
std::unique_lock 的构造函数可以接受一个 std::defer_lock 参数,这意味着它可以在构造时不立即获取锁。而 std::shared_lock 没有这个参数,它必须在构造时获取锁。- 释放锁的方式
std::unique_lock 可以通过调用 unlock() 方法来释放锁,也可以在其作用域结束时自动释放锁。而 std::shared_lock 只能在其作用域结束时自动释放锁。总的来说,std::unique_lock 适用于需要独占锁的场景,而 std::shared_lock适用于需要共享锁的场景。在使用这两种锁时,需要根据实际需求选择合适的锁类型。
使用std::shared_timed_mutex的实例:
#include
#include
#include
#include
#include
std::queue<int> q;
std::shared_timed_mutex mutex;
void read_queue(int id)
{
std::shared_lock<std::shared_timed_mutex> lock(mutex, std::chrono::seconds(1));
if (lock.owns_lock())
{
while (!q.empty())
{
int val = q.front();
q.pop();
std::cout << "Thread " << id << " read value: " << val << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
else
{
std::cout << "Thread " << id << " failed to read queue." << std::endl;
}
}
void write_queue()
{
std::unique_lock<std::shared_timed_mutex> lock(mutex);
for (int i = 0; i < 10; ++i)
{
q.push(i);
std::cout << "Write value: " << i << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main()
{
std::thread t1(read_queue, 1);
std::thread t2(read_queue, 2);
std::thread t3(write_queue);
t1.join();
t2.join();
t3.join();
return 0;
}
我们在read_queue()函数里面使用 std::shared_timed_lock,可以设置一个超时时间。这样,如果在指定的时间内无法获得共享锁,则会返回 false,从而避免了线程长时间等待的问题。在本例中,超时时间设置为 1 秒钟。
lock.owns_lock() 是一个成员函数,用于判断当前线程是否拥有一个互斥量的所有权。
在使用 std::unique_lock 或 std::shared_lock 时,可以使用该函数来检查当前线程是否持有锁,从而避免出现未拥有锁的情况下的操作。该函数返回一个 bool类型的值,如果当前线程持有锁,则返回 true,否则返回 false。
在多线程环境下,如果线程试图对一个已经被其他线程持有的锁进行操作,可能会出现死锁等问题,使用 owns_lock()函数可以避免这种情况的发生。例如,在使用 std::unique_lock 时,可以使用如下代码来检查当前线程是否持有锁:
std::unique_lock<std::mutex> lock(mtx); if (lock.owns_lock()) { // 当前线程持有锁,可以进行相关操作 } }
如果当前线程没有持有锁,则不能对锁进行相关操作。
递归锁(recursive lock)是一种特殊的锁类型,它允许同一个线程多次获取锁而不会出现死锁的情况。当线程尝试再次获取已经拥有的递归锁时,锁的计数器会增加,当线程释放锁时,计数器会减少,只有当计数器为 0 时,锁才会被完全释放。
递归锁通常用于以下情况:
嵌套调用:在一个函数中调用了另一个函数,这两个函数都需要获取同一个锁。如果不使用递归锁,那么在第二个函数中获取同一个锁时就会出现死锁的情况。而使用递归锁则可以避免这种情况。
递归算法:在一些递归算法中,同一个线程需要多次获取同一个锁,如果不使用递归锁,就会出现死锁的情况。而使用递归锁则可以避免这种情况。
需要注意的是,递归锁虽然可以避免死锁问题,但是由于它需要维护锁的计数器,所以会增加一些额外的开销。因此,在使用递归锁时需要考虑这种额外开销是否会影响性能。
如下是一个实例:
#include
#include
#include
std::recursive_mutex recmtx;
void func(int depth)
{
std::lock_guard<std::recursive_mutex> lock(recmtx);
std::cout << "depth: " << depth << std::endl;
if (depth > 0) {
func(depth - 1);
}
}
int main()
{
func(3);
return 0;
}
在上面的例子中,我们定义了一个递归函数 func,它接受一个参数 depth 表示递归深度。在 func 中,我们使用了std::lock_guard
在 main 函数中,我们调用了 func(3),这会使 func 函数递归地调用自身三次,并在每次调用时输出当前的递归深度。
由于 func 函数在递归调用时使用了递归锁,所以即使在递归调用时多次获取同一个锁,也不会出现死锁的情况。
但是要注意的是:如果在使用递归锁时没有正确地处理计数器,也可能会导致死锁的问题。例如,在下面这个例子中:
#include
#include
#include
std::recursive_mutex mtx;
void func(int depth)
{
mtx.lock();
std::cout << "depth: " << depth << std::endl;
if (depth > 0) {
func(depth - 1);
mtx.unlock();
std::cout << " unlock" << std::endl;
}
//这里漏掉一次解锁
}
int main()
{
func(3);
std::thread t(func, 3);
t.join();
return 0;
}
在这个例子中,我们使用了 std::recursive_mutex 创建了一个递归锁,并在 func 函数中多次获取和释放锁。然而,由于在最后一次递归调用后,我们漏掉了一次解锁操作,导致锁无法完全释放,从而出现了死锁的情况。
因此,在使用递归锁时,我们需要仔细地处理锁的计数器,确保锁的计数器在每次获取和释放锁时都能正确地增加和减少。另外,在使用递归锁时,为了保证程序的可读性和可维护性,建议使用 C++11 中提供的 std::lock_guard 和 std::unique_lock 等 RAII 类型来管理锁的获取和释放,这样可以避免忘记解锁的情况。
最后再来回顾一下std::recursive_mutex这个锁: