C++并发编程之二 在线程间共享数据

文章目录

    • 1.1 互斥锁(mutex)保护共享数据
      • 1.1.1 std::mutex 的成员函数 std::mutex::lock() 和std::mutex::unlock() (不推荐使用)
      • 1.1.2 使用std::lock_guard保护共享数据
      • 1.1.3 使用std::unique_lock保护共享数据
    • 1.2 保护共享数据的其他方式
      • 1.2.1 初始化过程中保护共享数据
      • 1.2.2 保护甚少更新的数据结构
      • 1.2.3 递归加锁

前情回顾: 在前一篇文章中,我们了解了创建线程、向线程中传递参数、移交线程的归属权,识别线程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)保护共享数据!

1.1 互斥锁(mutex)保护共享数据

1.1.1 std::mutex 的成员函数 std::mutex::lock() 和std::mutex::unlock() (不推荐使用)

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() 方法导致的代码错误。

1.1.2 使用std::lock_guard保护共享数据

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 的所有权并解锁它。

1.1.3 使用std::unique_lock保护共享数据

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() 方法再次加锁。

  1. 关于std::unique_lock的延迟锁定
    std::unique_lock可以指定锁的defer_lock属性,这意味着构造函数并不会立即锁住锁,而是在后面需要锁住锁的地方进行锁定。这对于某些情况非常有用,例如在多个条件变量上等待的线程需要获得同一个锁的所有权,但是直到等待之前才能获得锁。通过使用std::unique_lock的defer_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;
}
  1. 关于锁定超时
    std::unique_lock支持超时,可以指定一个时间段,在这段时间内如果无法获得锁则会放弃锁并返回。这对于需要限制锁的持有时间的情况非常有用,可以防止死锁和资源争用的问题。

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() 唤醒消费者线程。

1.2 保护共享数据的其他方式

1.2.1 初始化过程中保护共享数据

比如当我们在多线程开发的初始化过程中用到一些共享数据,但是我们只想让这个初始化过程只运行一次,那么该怎么协调让多个线程对初始化函数只运行一次呢?

在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对象。

1.2.2 保护甚少更新的数据结构

但也有这样一种情况,比如有个数据表,这个数据表里面的数据很少会进行修改,那么如果还使用std::mutex的话,当一个线程进行访问的时候,其他线程需要等待访问线程释放锁了之后才能进行访问。这样未免也太浪费时间了。但不用着急,也有解决的办法!

std::shared_mutex和std::shared_timed_mutex是C++11标准库中引入的多线程同步原语,用于实现读写锁。当一个线程需要对某个共享资源进行读取操作时,可以使用std::shared_mutex或std::shared_timed_mutex进行加锁,以允许其他线程也能够同时读取该资源。而当一个线程需要对该共享资源进行写入操作时,则需要进行排它访问,此时必须等待其他所有线程释放读取锁和写入锁,才能获得写入锁进行写入。

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. 读多写少的情况,例如在多线程的服务器应用程序中,多个线程需要读取某些共享数据,但只有少数线程需要修改这些数据。

  2. 需要对共享数据进行时间限制的情况,例如在超时机制中,需要对共享数据进行读取和写入,并且需要限制读取和写入操作的时间。

需要注意的是,std::shared_mutex和std::shared_timed_mutex适用于读多写少的场景,如果读写操作的比例接近1:1,建议使用std::mutex或std::timed_mutex等其他的同步原语。

  • 使用方法
  1. 通常情况下,std::shared_lock 和 std::shared_mutex 是一起使用的。
  2. std::shared_mutex 是 C++标准库中提供的一种多读单写的互斥量,它允许多个线程同时获取共享锁,但只允许一个线程获取独占锁。因此,std::shared_mutex 适用于多个线程同时读取同一个数据结构,但只有一个线程写入数据结构的场景。
  3. 而 std::shared_lock 则是用于获取共享锁的一种锁类型,它可以让多个线程同时持有共享锁,而不会互相阻塞。当共享锁被多个线程持有时,其它线程只能获取共享锁,而不能获取独占锁。
  4. 因此,通常情况下,当我们需要保护一个数据结构时,可以使用 std::shared_mutex 作为互斥量,并使用 std::shared_lock 来获取共享锁,以实现多线程的读操作。同时,当我们需要写入数据结构时,可以使用 std::unique_lock 来获取独占锁,以保证数据的一致性和正确性。
  5. 综上所述,std::shared_lock 和 std::shared_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 秒钟。

  1. lock.owns_lock() 是一个成员函数,用于判断当前线程是否拥有一个互斥量的所有权。

  2. 在使用 std::unique_lock 或 std::shared_lock 时,可以使用该函数来检查当前线程是否持有锁,从而避免出现未拥有锁的情况下的操作。该函数返回一个 bool类型的值,如果当前线程持有锁,则返回 true,否则返回 false。

  3. 在多线程环境下,如果线程试图对一个已经被其他线程持有的锁进行操作,可能会出现死锁等问题,使用 owns_lock()函数可以避免这种情况的发生。例如,在使用 std::unique_lock 时,可以使用如下代码来检查当前线程是否持有锁:

std::unique_lock<std::mutex> lock(mtx);
if (lock.owns_lock()) {
    // 当前线程持有锁,可以进行相关操作 }
}

如果当前线程没有持有锁,则不能对锁进行相关操作。

1.2.3 递归加锁

递归锁(recursive lock)是一种特殊的锁类型,它允许同一个线程多次获取锁而不会出现死锁的情况。当线程尝试再次获取已经拥有的递归锁时,锁的计数器会增加,当线程释放锁时,计数器会减少,只有当计数器为 0 时,锁才会被完全释放。

递归锁通常用于以下情况:

  1. 嵌套调用:在一个函数中调用了另一个函数,这两个函数都需要获取同一个锁。如果不使用递归锁,那么在第二个函数中获取同一个锁时就会出现死锁的情况。而使用递归锁则可以避免这种情况。

  2. 递归算法:在一些递归算法中,同一个线程需要多次获取同一个锁,如果不使用递归锁,就会出现死锁的情况。而使用递归锁则可以避免这种情况。

需要注意的是,递归锁虽然可以避免死锁问题,但是由于它需要维护锁的计数器,所以会增加一些额外的开销。因此,在使用递归锁时需要考虑这种额外开销是否会影响性能。
如下是一个实例:

#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这个锁:

  1. 当多个线程需要访问共享资源时,通常需要使用锁来保护共享资源。在 C++11 中,标准库提供了 std::mutex类来实现互斥锁的功能。但是,如果在一个线程中多次获取同一个互斥锁,就会出现死锁的情况,因为同一个线程在持有锁的同时,又试图获取这个锁,这种情况下,我们需要使用递归锁(std::recursive_mutex)。std::recursive_mutex是一个可递归的互斥锁,它可以在同一个线程中多次获取锁而不会出现死锁的情况。每个递归锁对象内部有一个计数器来记录这个锁的拥有者是同一个线程,还是不同的线程。如果同一个线程多次获取锁,计数器会递增,而每次释放锁,计数器会递减,只有当计数器降为0 时,锁才被真正释放,其他线程才能够获取到这个锁。
  2. 递归锁的特点是可以在同一个线程中多次获取和释放锁,但是要注意,每次获取锁的次数和释放锁的次数必须相同,否则会出现死锁的情况。
  3. 使用递归锁的代码和使用普通锁的代码类似,不同的是,如果需要在同一个线程中多次获取锁,就需要使用 std::recursive_mutex类来创建递归锁对象。

你可能感兴趣的:(C++中的并发线程,c++,开发语言,算法)