c++多线程-CSDN博客

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

目录

一、进程与线程 

二、多线程的实现

2.1 C++中创建多线程的方法

2.2 join() 、 detach() 和 joinable() 

2.2.1 join()

2.2.2 detach()

2.2.3 joinable()

2.3 this_thread

三、同步机制同步原语

3.1 同步与互斥

3.2 互斥锁mutex

3.2.1 lock()和unlock()

3.2.2 lock_guard        

3.2.3 unique_lock

3.2.4 公平锁和非公平锁大厂爱问面试题

3.3 条件变量condition variable

3.3.1 定义 

std::condition_variable中的成员函数

3.3.2 condition_variable相关示例

3.4 原子操作atimoc operations

3.4.1 定义

3.4.2 示例 

3.5 读写锁read—write lock

3.5.1 定义

3.5.2 读写锁示例

四、总结

五、参考文献 


 

一、进程与线程 

图解进程线程的关系

进程与线程的区别

本质区别进程是操作系统资源分配的基本单位而线程是处理器任务调度和执行的基本单位。

包含关系一个进程至少有一个线程线程是进程的一部分所以线程也被称为轻权进程或者轻量级进程。

资源开销每个进程都有独立的地址空间进程之间的切换会有较大的开销线程可以看做轻量级的进程同一个进程内的线程共享进程的地址空间每个线程都有自己独立的运行栈和程序计数器线程之间切换的开销小。

影响关系一个进程崩溃后在保护模式下其他进程不会被影响但是一个线程崩溃可能导致整个进程被操作系统杀掉所以多进程要比多线程健壮。
 

二、多线程的实现

2.1 C++中创建多线程的方法

        主要是继承 thread 类C++11引入了<thread>头文件其中定义了std::thread类可以方便地创建和管理线程。如下代码所示

#include <iostream>
#include <thread>

// 线程函数
void threadFunction1() {
    std::cout << "子线程1" << std::endl;
}

int main() {
    // 创建线程并启动
    std::thread t(threadFunction1); 
    t.join();// 等待线程执行完毕
    std::cout << "主线程" << std::endl;
    return 0;
}

2.2 join() 、 detach() 和 joinable() 

        在C++中创建了一个线程时它通常被称为一个可联接(joinable)的线程可以通过调用join()函数或detach()函数来管理线程的执行。

方法说明
1join()等待一个线程完成如果该线程还未执行完毕则当前线程一般是主线程将被阻塞直到该线程执行完成主线程才会继续执行。
2detach()将当前线程与创建的线程分离使它们分别运行当分离的线程执行完毕后系统会自动回收其资源。如果一个线程被分离了就不能再使用join()函数了因为线程已经无法被联接了。
3joinable()判断线程是否可以执行join()函数返回true/false

2.2.1 join()

        在C++的std::thread类中join()方法用于等待线程执行完毕。具体来说当调用join()方法时程序会阻塞当前线程直到被调用的线程完成其执行。

join()方法有以下特点

  1. 调用join()方法的线程会一直等待直到被调用的线程执行完毕。
  2. join()方法只能被调用一次多次调用会导致编译错误。
  3. 如果在调用join()之前已经调用了detach()方法将线程分离那么调用join()方法会抛出std::system_error异常。

以下是一个简单的示例演示了join()方法的使用

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    std::thread t(threadFunction);  // 创建线程并启动

    // 主线程等待子线程执行完毕
    t.join();

    std::cout << "Thread has finished." << std::endl;

    return 0;
}

输出结果:

 

分析

        在上述示例中通过创建std::thread对象并传入线程函数threadFunction我们创建了一个新的线程。然后在主线程中调用t.join()使主线程等待子线程执行完毕。最后主线程输出"Thread has finished."。

        需要注意的是如果不调用join()方法而是直接结束程序那么子线程可能无法完成执行。因此在使用std::thread时通常建议在合适的地方调用join()方法以确保线程的正确执行和资源的释放。

2.2.2 detach()

        在C++的std::thread类中detach()方法用于将线程与std::thread对象分离使得线程可以独立执行不再与std::thread对象关联。

detach()方法有以下特点

  1. 调用detach()方法后std::thread对象不再与其所代表的线程相关联。
  2. 分离后的线程将变为守护线程即程序不会等待它执行完毕。
  3. detach()方法只能被调用一次多次调用会导致编译错误。

以下是一个简单的示例演示了detach()方法的使用

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Hello from detached thread!" << std::endl;
}

int main() {
    std::thread t(threadFunction);  // 创建线程并启动

    t.detach();  // 分离线程

    std::cout << "Thread has been detached." << std::endl;

    // 注意在这里不应该访问已分离的线程对象t

    return 0;
}

输出结果 

 

分析

        在上述示例中通过创建std::thread对象并传入线程函数threadFunction我们创建了一个新的线程。然后在主线程中调用t.detach()将线程与std::thread对象分离。最后主线程输出"Thread has been detached."。

        需要注意的是一旦线程被分离就无法再通过join()方法重新关联。因此在使用detach()方法时需要确保不再需要与std::thread对象关联的线程并且要避免访问已分离的线程对象。

        另外分离线程后如果主线程退出而分离的线程仍在执行那么程序可能会终止因此需要谨慎使用detach()方法以确保线程的正确执行和资源的释放。

2.2.3 joinable()

        在C++的std::thread类中joinable()方法用于检查线程是否可以加入join。当一个线程被创建后它可以处于可加入状态或不可加入状态。

        如果一个线程是可加入状态意味着它正在运行或已经完成但还没有被其他线程加入。此时调用join()方法可以等待该线程的执行完成并且阻塞当前线程直到目标线程执行完成。

        如果一个线程是不可加入状态意味着它已经被加入到其他线程中或者已经被分离detached。一个被分离的线程将在其执行完成后自动释放资源不需要调用join()方法等待。

        joinable()方法返回一个bool值如果线程可以加入则返回true如果线程不可加入则返回false。

        以下是一个示例代码片段展示了如何使用joinable()方法

#include <iostream>
#include <thread>

void myFunction() {
    // 线程执行的代码
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    std::thread myThread(myFunction);

    if (myThread.joinable()) {
        std::cout << "Thread is joinable." << std::endl;
        myThread.join(); // 等待线程执行完成
    } else {
        std::cout << "Thread is not joinable." << std::endl;
    }

    return 0;
}

        在上面的示例中我们首先创建了一个名为myThread的线程并检查它是否可加入。如果可加入则调用join()方法等待线程执行完成。否则我们可以根据需求采取其他操作。

        需要注意的是如果一个线程已经被加入或分离再次调用join()方法将导致程序崩溃。因此在调用join()之前始终应该使用joinable()方法进行检查。

注意

(1)、线程是在thread对象被定义的时候开始执行的而不是在调用join()函数时才执行的调用join()函数只是阻塞等待线程结束并回收资源。
(2)、分离的线程执行过detach()的线程会在调用它的线程结束或自己结束时自动释放资源。
(3)、线程会在函数运行完毕后自动释放不推荐利用其他方法强制结束线程可能会因资源未释放而导致内存泄漏。
(4)、若没有执行join()或detach()的线程在程序结束时会引发异常。

2.3 this_thread

std::this_thread类提供了以下静态成员函数

函数说明
1std::this_thread::sleep_for()当前线程休眠指定的时间
2std::this_thread::sleep_until()当前线程休眠直到指定时间点
3std::this_thread::yield()当前线程让出CPU允许其他线程运行
4std::this_thread::get_id()获取当前线程的ID

        这些函数可以帮助我们更方便地控制线程的执行和调度比如让线程等待一段时间再执行或者让出当前线程的执行权以便其他线程能够运行。

        另外需要注意的是std::this_thread类中的函数都是静态成员函数因此不需要创建该类的实例即可调用这些函数。

示例

#include <iostream>
#include <thread>
#include <chrono>

void my_thread()
{
	std::cout << "Thread " << std::this_thread::get_id() << " start" << std::endl;

	for (int i = 1; i <= 5; i++)
	{
		std::cout << "Thread " << std::this_thread::get_id() << " running: " << i << std::endl;
		std::this_thread::yield();	// 让出当前线程的时间片
		std::this_thread::sleep_for(std::chrono::milliseconds(200));  // 线程休眠200毫秒
	}

	std::cout << "Thread " << std::this_thread::get_id() << " end" << std::endl;
}

int main()
{
	std::cout << "Main thread id: " << std::this_thread::get_id() << std::endl;
	
    std::thread t1(my_thread);
	std::thread t2(my_thread);
	
	t1.join();
	t2.join();
	return 0;
}

输出结果 

分析

        这是一个使用C++的多线程编程示例。在主函数中我们创建了两个线程t1和t2它们都调用了my_thread函数。

        my_thread函数首先输出当前线程的ID然后使用一个循环打印出当前线程的运行次数然后使用std::this_thread::yield()让出当前线程的时间片使得其他线程有机会执行。接着使用std::this_thread::sleep_for(std::chrono::milliseconds(200))函数使线程休眠200毫秒模拟一些耗时操作。最后输出当前线程的结束信息。

        在主函数中我们先输出主线程的ID然后使用t1.join()和t2.join()等待两个子线程执行完毕。

        这段代码的输出结果可能因为不同的运行环境而有所不同但大致上会按照以下顺序输出

Main thread id: [主线程ID]
Thread [线程1ID] start
Thread [线程1ID] running: 1
Thread [线程2ID] start
Thread [线程2ID] running: 1
Thread [线程1ID] running: 2
Thread [线程2ID] running: 2
Thread [线程1ID] running: 3
Thread [线程2ID] running: 3
Thread [线程1ID] running: 4
Thread [线程2ID] running: 4
Thread [线程1ID] running: 5
Thread [线程2ID] running: 5
Thread [线程1ID] end
Thread [线程2ID] end

        请注意由于线程的调度是由操作系统控制的因此输出的顺序可能会有所不同。此外使用std::this_thread::yield()和std::this_thread::sleep_for(std::chrono::milliseconds(200))是为了演示多线程的并发执行和线程休眠的效果并不一定是必需的。

三、同步机制同步原语<synchronization primitives>

        同步原语synchronization primitives是用于协调多个线程或进程之间访问共享资源的机制。在多线程或多进程环境下如果没有适当的同步机制会导致数据竞争、死锁等问题从而影响程序的正确性和性能。

常见的同步原语包括

  1. 互斥锁mutex用于保护共享资源只允许一个线程或进程访问共享资源。
  2. 条件变量condition variable用于在多个线程之间进行通信当某个条件满足时唤醒等待该条件的线程。
  3. 读写锁read-write lock用于提高对共享资源的读取性能允许多个线程同时读取共享资源但只允许一个线程进行写操作。
  4. 原子操作atomic operation用于保证对共享变量的操作是原子的即不会被其他线程或进程中断。
  5. 信号量semaphore用于控制多个线程或进程之间的访问顺序可以用来实现互斥锁或条件变量等机制。

        在C++11标准中引入了std::mutexstd::condition_variablestd::shared_mutexstd::atomic等同步原语方便开发者在多线程环境下编写安全的代码。此外C++标准库还提供了一些包装类如std::lock_guardstd::unique_lock等可以方便地使用互斥锁和条件变量等同步原语。

        需要注意的是同步原语的使用需要谨慎不当的使用可能会导致死锁、竞争条件等问题。在编写多线程程序时应该根据具体情况选择合适的同步机制并遵循一些基本的同步原则如避免共享资源、尽量减少锁的持有时间、尽量避免嵌套锁等。

3.1 同步与互斥

【节选自一文搞定c++多线程同步机制_c++多线程同步等待-CSDN博客

        现代操作系统都是多任务操作系统通常同一时刻有大量可执行实体则运行着的大量任务可能需要访问或使用同一资源或者说这些任务之间具有依赖性。

        线程同步线程同步是指线程之间所具有的一种制约关系一个线程的执行依赖另一个线程的消息当它没有得到另一个线程的消息时应等待直到消息到达时才被唤醒。例如两个线程A和B在运行过程中协同步调按预定的先后次序运行比如 A 任务的运行依赖于 B 任务产生的数据。
        线程互斥线程互斥是指对于共享的操作系统资源在各线程访问时具有排它性。当有若干个线程都要使用某一共享资源时任何时刻最多只允许有限的线程去使用其它要使用该资源的线程必须等待直到占用资源者释放该资源。例如两个线程A和B在运行过程中共享同一变量但为了保持变量的一致性如果A占有了该资源则B需要等待A释放才行如果B占有了该资源需要等待B释放才行。

另一种解释【节选自【精选】线程同步的四种方式_多线程同步-CSDN博客

        同步就是协同步调按预定的先后次序进行运行。如你说完我再说。这里的同步千万不要理解成那个同时进行应是指协同、协助、互相配合。线程同步是指多线程通过特定的设置如互斥量事件对象临界区来控制线程之间的执行顺序即所谓的同步也可以说是在线程之间通过同步建立起执行顺序的关系如果没有同步那线程之间是各自运行各自的

        线程互斥是指对于共享的进程系统资源在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时任何时刻最多只允许一个线程去使用其它要使用该资源的线程必须等待直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步下文统称为同步。

3.2 互斥锁mutex

3.2.1 lock()和unlock()

  std::mutex是C++标准库中提供的互斥量类用于实现线程间的互斥操作保证在同一时间只有一个线程可以访问共享资源避免并发访问导致的数据竞争问题。

使用std::mutex需要以下步骤

  1. 在需要保护的代码块前后创建std::mutex对象。
  2. 在进入代码块之前使用std::mutexlock()方法锁定互斥量以防止其他线程进入。
  3. 在代码块执行完毕后使用std::mutexunlock()方法解锁互斥量允许其他线程进入。
方法说明
1lock()将mutex上锁。如果mutex已经被其它线程上锁那么会阻塞直到解锁如果mutex已经被同一个线程锁住那么会产生死锁
2unlock()将mutex解锁释放其所有权。如果有线程因为调用lock()不能上锁而被阻塞则调用此函数会将mutex的主动权随机交给其中一个线程如果mutex不是被此线程上锁那么会引发未定义的异常。
3try_lock()尝试将mutex上锁。如果mutex未被上锁则将其上锁并返回true如果mutex已被锁则返回false

先考虑不使用锁的情况

#include <iostream>
#include <thread>
#include <mutex>


int threadFunction1() {
    std::cout << "子线程1" << std::endl;
    return 0;
}

int threadFunction2() {
    std::cout << "子线程2" << std::endl;
    return 0;
}

int main() {
    std::thread t1(threadFunction1);
    std::thread t2(threadFunction2);

    t1.join();
    t2.join();

    std::cout << "主线程" << std::endl;

    return 0;
}

输出结果四种情况

 子线程2和子线程1同时进行。

子线程2先进行子线程1后进行。

子线程1先进行子线程2后进行。

上述代码加锁之后

#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;

int threadFunction1() {
    mtx.lock();
    std::cout << "子线程1" << std::endl;
    mtx.unlock();
    return 0;
}

int threadFunction2() {
    mtx.lock();
    std::cout << "子线程2" << std::endl;
    mtx.unlock();
    return 0;
}

int main() {
    std::thread t1(threadFunction1);
    std::thread t2(threadFunction2);

    t1.join();
    t2.join();

    std::cout << "主线程" << std::endl;

    return 0;
}

输出结果只有一种情况

 

分析

        代码使用了互斥量来实现线程同步确保了子线程1和子线程2之间的输出不会交叉。在主线程中你使用了t1.join()和t2.join()来等待子线程的结束。

        在子线程函数中你使用了mtx.lock()来加锁互斥量然后输出相应的信息最后使用mtx.unlock()来解锁互斥量。这样确保了每个子线程在输出信息时都是独占互斥量的避免了输出的交叉。

        在主线程中你使用了t1.join()和t2.join()来等待子线程的结束这样确保了主线程在子线程完成后才会继续执行。最后主线程输出了相应的信息。

        需要注意的是互斥量的加锁和解锁操作应该成对出现确保每次加锁都有对应的解锁操作。在你的代码中你已经正确地使用了mtx.lock()和mtx.unlock()来保证互斥量的正确使用。

        此外为了避免出现异常导致互斥量未能正确解锁的情况也可以考虑使用std::lock_guard来管理互斥量的锁定和解锁以实现更安全的代码编写。

3.2.2 lock_guard
        

        std::lock_guard是C++标准库中的一个模板类用于实现资源的自动加锁和解锁。它是基于RAII资源获取即初始化的设计理念能够确保在作用域结束时自动释放锁资源避免了手动管理锁的复杂性和可能出现的错误。

std::lock_guard的主要特点如下

自动加锁 在创建std::lock_guard 对象时会立即对指定的互斥量进行加锁操作。这样可以确保在进入作用域后互斥量已经被锁定避免了并发访问资源的竞争条件。
自动解锁std::lock_guard对象在作用域结束时会自动释放互斥量。无论作用域是通过正常的流程结束、异常抛出还是使用return语句提前返回std::lock_guard都能保证互斥量被正确解锁避免了资源泄漏和死锁的风险。
适用于局部锁定 由于std::lock_guard是通过栈上的对象实现的因此适用于在局部范围内锁定互斥量。当超出std::lock_guard对象的作用域时互斥量会自动解锁释放控制权。
使用std::lock_guard的一般步骤如下

(1)、创建一个std::lock_guard对象传入要加锁的互斥量作为参数。
(2)、执行需要加锁保护的代码块。
(3)、std::lock_guard对象的作用域结束时自动调用析构函数解锁互斥量。

        std::lock_guard是C++11中提供的一个RAIIResource Acquisition Is Initialization封装类用于管理互斥量的锁定和解锁。它可以简化代码避免手动管理锁定和解锁的过程从而减少错误和提高代码的可读性。

        std::lock_guard的使用非常简单只需要在需要加锁的作用域内创建一个std::lock_guard对象即可。当std::lock_guard对象被创建时它会自动加锁互斥量并在作用域结束时自动解锁互斥量。

下面是一个使用std::lock_guard的示例

#include <mutex>

std::mutex mtx;

void my_thread()
{
    std::lock_guard<std::mutex> lock(mtx);  // 自动加锁
    // 访问共享资源
}  // 自动解锁

        在这个示例中我们使用std::lock_guard来管理互斥量的锁定和解锁。当std::lock_guard对象被创建时它会自动调用mtx.lock()方法来加锁互斥量当std::lock_guard对象超出作用域时它会自动调用mtx.unlock()方法来解锁互斥量。

        需要注意的是std::lock_guard是一个轻量级的封装类它只能保证互斥量在作用域内始终处于锁定状态但不能保证互斥量的所有权。如果需要在作用域内传递互斥量的所有权可以使用std::unique_lock类来替代std::lock_guard类。

3.2.3 unique_lock

        std::unique_lock是C++标准库中的一个模板类用于实现更加灵活的互斥量的加锁和解锁操作。它提供了比std::lock_guard更多的功能和灵活性。

std::unique_lock的主要特点如下

        自动加锁和解锁 与std::lock_guard类似std::unique_lock在创建对象时立即对指定的互斥量进行加锁操作确保互斥量被锁定。在对象的生命周期结束时会自动解锁互斥量。这种自动加锁和解锁的机制避免了手动管理锁的复杂性和可能出现的错误。

        支持灵活的加锁和解锁 相对于std::lock_guard的自动加锁和解锁std::unique_lock提供了更灵活的方式。它可以在需要的时候手动加锁和解锁互斥量允许在不同的代码块中对互斥量进行多次加锁和解锁操作。

        支持延迟加锁和条件变量std::unique_lock还支持延迟加锁的功能可以在不立即加锁的情况下创建对象稍后根据需要进行加锁操作。此外它还可以与条件变量std::condition_variable一起使用实现更复杂的线程同步和等待机制。

使用std::unique_lock的一般步骤如下

1创建一个std::unique_lock对象传入要加锁的互斥量作为参数。
2执行需要加锁保护的代码块。
3可选地手动调用lock函数对互斥量进行加锁或者在需要时调用unlock函数手动解锁互斥量。

示例

#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 创建一个名为mtx的互斥锁对象
int threadFunction1() {
    std::unique_lock<std::mutex> lock(mtx); // 使用互斥锁进行加锁并在作用域结束时自动释放锁
    std::cout << "子线程1" << std::endl;
    return 0;
}

int threadFunction2() {
    std::unique_lock<std::mutex> lock(mtx); // 使用互斥锁进行加锁并在作用域结束时自动释放锁
    std::cout << "子线程2" << std::endl;
    return 0;
}

int main() {
    std::thread t1(threadFunction1);
    std::thread t2(threadFunction2);

    t1.join();
    t2.join();

    std::cout << "主线程" << std::endl;

    return 0;
}

输出结果 

分析

        代码使用了互斥锁std::mutex来保护对共享资源的访问以确保线程安全性。互斥锁允许一次只有一个线程访问被保护的代码块从而避免了多个线程同时修改共享资源的问题。

        在你的代码中我们创建了一个名为mtx的互斥锁对象并在两个子线程函数threadFunction1和threadFunction2中使用std::unique_lock<std::mutex>来对互斥锁进行加锁。这样做可以确保每个子线程在执行输出语句之前先获取到互斥锁从而保证了输出的顺序和线程的同步。

        在主线程中我们创建了两个子线程t1和t2并分别调用threadFunction1和threadFunction2。然后使用t1.join()和t2.join()等待子线程执行完毕。最后在主线程中输出"主线程"。

        这样通过使用互斥锁我们可以保证子线程1和子线程2的输出不会交错并且主线程会等待子线程执行完毕后再继续执行。这种同步机制可以确保线程间的顺序和协作。

3.2.4 公平锁和非公平锁大厂爱问面试题

        在多线程编程中锁是常用的同步机制用于保护共享资源的访问。根据获取锁的顺序可以将锁分为公平锁和非公平锁。

        公平锁是指多个线程在请求锁时按照请求的先后顺序依次获得锁。换句话说如果一个线程请求了锁但是锁已经被其他线程占用那么该线程会进入等待队列等待其他线程释放锁之后再次尝试获得锁。公平锁的优点是可以避免线程饥饿现象即某些线程一直无法获得锁因为总是被其他线程抢占。但是公平锁的缺点是可能会导致额外的开销因为需要维护等待队列。

        非公平锁是指多个线程在请求锁时不考虑请求的先后顺序直接竞争锁。如果一个线程请求了锁但是锁已经被其他线程占用那么该线程会自旋等待直到锁被释放。非公平锁的优点是可以减少等待时间提高并发性能但是可能会导致某些线程长时间无法获得锁因为总是被其他线程抢占。

        需要注意的是公平锁和非公平锁的区别只是在于获取锁的顺序而不是锁的实现方式。因此在实现锁时可以同时支持公平和非公平两种模式通过参数控制获取锁的顺序。

        在C++中`std::mutex`是一种非公平锁而`std::condition_variable`可以用于实现公平锁。如果需要使用公平锁可以将`std::mutex`和`std::condition_variable`结合使用实现类似于Java中`ReentrantLock`的功能。

3.3 条件变量condition variable

3.3.1 定义 

        同步机制中的条件变量condition variable是一种用于线程间通信和协调的机制。它通常与互斥锁mutex结合使用用于实现线程的等待和唤醒操作。

        条件变量提供了一个等待队列线程可以在条件不满足时进入等待状态并在条件满足时被唤醒。它的主要作用是允许线程在某个特定条件下等待而不是忙等待busy-waiting从而减少资源的浪费。

条件变量通常包括以下两个基本操作

  1. 等待wait线程在条件不满足时调用等待操作将自己加入到条件变量的等待队列中并释放互斥锁使其他线程能够继续执行。当条件满足时线程将被唤醒并重新获得互斥锁。

  2. 唤醒signal线程在某个条件满足时调用唤醒操作通知等待队列中的一个或多个线程可以继续执行。被唤醒的线程会尝试重新获得互斥锁并检查条件是否满足。

条件变量的使用一般遵循以下模式

  1. 线程获取互斥锁。
  2. 检查条件是否满足如果满足则继续执行否则进入等待状态。
  3. 在等待状态下线程会释放互斥锁让其他线程能够执行。
  4. 当条件满足时某个线程调用唤醒操作通知等待队列中的线程可以继续执行。
  5. 被唤醒的线程尝试重新获得互斥锁并检查条件是否满足如果满足则继续执行否则再次进入等待状态。

        需要注意的是条件变量的使用必须与互斥锁配合使用以确保线程在等待和唤醒操作时的线程安全性。互斥锁用于保护共享资源的访问而条件变量用于线程间的等待和唤醒操作。

        总结起来条件变量是一种线程间通信和协调的机制通过等待和唤醒操作实现线程的等待和唤醒。它的使用需要与互斥锁结合以确保线程安全性。

std::condition_variable中的成员函数

方法说明
1wait()使当前线程进入等待状态直到被其他线程通过notify_one()notify_all()函数唤醒。该函数需要一个互斥锁作为参数调用时会自动释放互斥锁并在被唤醒后重新获取互斥锁。
2wait_for()wait_for(): 使当前线程进入等待状态最多等待一定的时间直到被其他线程通过notify_one()或notify_all()函数唤醒或者等待超时。该函数需要一个互斥锁和一个时间段作为参数返回时有两种情况等待超时返回std::cv_status::timeout被唤醒返回std::cv_status::no_timeout。
3wait_until()wait_until(): 使当前线程进入等待状态直到被其他线程通过notify_one()或notify_all()函数唤醒或者等待时间达到指定的绝对时间点。该函数需要一个互斥锁和一个绝对时间点作为参数返回时有两种情况时间到达返回std::cv_status::timeout被唤醒返回std::cv_status::no_timeout。
4notify_one()notify_one(): 唤醒一个等待中的线程如果有多个线程在等待则选择其中一个线程唤醒。
5notify_all()notify_all(): 唤醒所有等待中的线程使它们从等待状态返回。

3.3.2 condition_variable相关示例

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx; // 创建一个名为mtx的互斥锁对象
std::condition_variable cv; // 创建一个名为cv的条件变量对象
void threadFunction1() {
    std::unique_lock<std::mutex> lock(mtx); // 使用互斥锁进行加锁并在作用域结束时自动释放锁
    cv.wait(lock); // 等待通知
    std::cout << "子线程1" << std::endl;
}

void threadFunction2() {
    std::unique_lock<std::mutex> lock(mtx); // 使用互斥锁进行加锁并在作用域结束时自动释放锁
    std::cout << "子线程2" << std::endl;
    cv.notify_one(); // 通知等待的线程
}

int main() {
    std::thread t1(threadFunction1);
    std::thread t2(threadFunction2);

    t1.join();
    t2.join();

    std::cout << "主线程" << std::endl;

    return 0;
}

输出结果 

分析
        这段代码演示了条件变量的基本用法其中包括一个等待线程和一个通知线程。具体来说线程t1等待条件变量cv的通知而线程t2在打印一条消息后通知等待的线程。

        在实现中互斥锁对象mtx被用于保护共享资源的访问同时也用于与条件变量对象cv配合使用以确保线程安全性。在等待线程t1中使用std::unique_lock<std::mutex> lock(mtx)创建一个独占的互斥锁然后调用cv.wait(lock)等待通知并在等待过程中自动释放锁。在通知线程t2中先使用std::unique_lock<std::mutex> lock(mtx)创建一个独占的互斥锁然后调用cv.notify_one()通知等待的线程。需要注意的是通知操作必须在互斥锁的保护下进行以避免竞争条件的出现。

        在主函数中线程t1和t2被创建并启动然后等待它们完成执行。最后主线程打印一条消息程序结束。

        需要注意的是在实际使用条件变量时需要考虑到多线程并发执行的情况以及可能出现的死锁、饥饿等问题需要仔细设计和测试程序以确保正确性和可靠性。

3.4 原子操作atimoc operations

3.4.1 定义

        std::mutex可以很好地解决多线程资源争抢的问题但它每次循环都要加锁、解锁这样固然会浪费很多的时间。
        C++中的原子操作是指在多线程环境下对共享变量进行操作时保证该操作的原子性即不会被其他线程中断或干扰。在C++11标准中引入了std::atomic模板类和一些原子操作函数用于实现原子操作。
        atomic本意为原子原子操作是最小的且不可并行化的操作。这就意味着即使是多线程也要像同步进行一样同步操作原子对象从而省去了互斥量上锁、解锁的时间消耗。
        使用 std::atomic 可以保证数据在操作期间不被其他线程修改这样就避免了数据竞争使得程序在多线程并发访问时仍然能够正确执行。

【原子操作与互斥量的区别节选自《C++并发编程 | 原子操作std::atomic-CSDN博客》】

原子操作std::atomic与互斥量的区别

1互斥量类模板保护一段共享代码段可以是一段代码也可以是一个变量。

2原子操作std::atomic类模板保护一个变量。

为何需要原子操作std::atomic

        为何已经有互斥量了还要引入std::atomic呢这是因为互斥量保护的数据范围比较大我们期望更小范围的保护。并且当共享数据为一个变量时原子操作std::atomic效率更高。

3.4.2 示例 

不做原子操作且不加锁的情况

#include <thread>
#include <atomic> 
#include <iostream>

// 全局的结果数据 
long total = 0;

// 点击函数
void click()
{
    for (int i = 0; i < 100000; i++)
    {
        // 对全局数据进行无锁访问 
        total += 1;
    }
}

int main(){
    // 创建3个线程模拟点击统计
    std::thread th1(click);
    std::thread th2(click);
    std::thread th3(click);
    th1.join();
    th2.join();
    th3.join();

    // 输出结果
    std::cout << "result:" << total << std::endl;
    return 0;
}

 输出结果不正确且不唯一

分析

        这段代码使用了C++的多线程库创建了三个线程来模拟点击统计。每个线程都会执行click函数该函数会对全局变量total进行无锁访问并将其加1。最后程序输出 total 的值。

        然而这段代码存在一个问题多个线程同时对同一个变量进行写操作可能会导致竞争条件Race Condition的发生从而产生不确定的结果。在这种情况下应该使用原子类型atomic type来确保操作的原子性。原子类型是一种特殊的数据类型可以保证对其进行读写操作时是原子的即不会被其他线程中断。

对上述代码进行改进只考虑原子操作的情况下

#include <thread>
#include <atomic> 
#include <iostream>

// 全局的结果数据 
std::atomic_long total = 0;

// 点击函数
void click()
{
    for (int i = 0; i < 100000; i++)
    {
        // 对全局数据进行无锁访问 
        total += 1;
    }
}

int main(){
    // 创建3个线程模拟点击统计
    std::thread th1(click);
    std::thread th2(click);
    std::thread th3(click);
    th1.join();
    th2.join();
    th3.join();

    // 输出结果
    std::cout << "result:" << total << std::endl;
    return 0;
}

输出结果

 

分析

        在修改后的代码中我们将全局变量total改为std::atomic<long>类型确保对其进行读写操作时是原子的。这样可以避免竞争条件的发生保证结果的正确性。 

只考虑加锁情况下

#include <thread>
#include <atomic> 
#include <iostream>
#include <mutex>
std::mutex mtx; // 创建一个名为mtx的互斥量对象
// 全局的结果数据 
long total = 0;

// 点击函数
void click()
{
    for (int i = 0; i < 100000; i++)
    {
        // 加锁
        mtx.lock();
        total += 1;
        mtx.unlock();
    }
}

int main(){
    // 创建3个线程模拟点击统计
    std::thread th1(click);
    std::thread th2(click);
    std::thread th3(click);
    th1.join();
    th2.join();
    th3.join();

    // 输出结果
    std::cout << "result:" << total << std::endl;
    return 0;
}

输出结果 

分析

        这段代码也是使用了C++的多线程库创建了三个线程来模拟点击统计。每个线程都会执行click函数该函数会对全局变量total进行加1操作但在进行操作之前会先加锁确保同一时间只有一个线程能够访问total变量。最后程序输出total的值。

        这种方式可以避免多个线程同时对同一个变量进行写操作从而避免竞争条件的发生。但是使用互斥锁会带来一些额外的开销因为每次加锁和解锁都需要进行一定的操作。因此在实际编程中应该根据具体情况选择合适的方法来保证线程安全。

        另外需要注意的是在使用互斥锁时必须保证所有访问共享资源的线程都使用同一个互斥锁对象否则可能会出现死锁等问题。

3.5 读写锁read—write lock

3.5.1 定义

        C++提供了读写锁Read-Write Lock来实现对共享资源的读写操作的并发控制。读写锁允许多个线程同时读取共享资源但只允许一个线程进行写操作。
        使用读写锁可以提高程序的并发性能因为多个线程可以同时读取共享资源而不会相互阻塞。只有当有线程正在写入共享资源时其他线程才会被阻塞。
        C++标准库提供了std::shared_mutex类来实现读写锁。允许多个线程同时读取共享资源但只允许一个线程进行写操作。std::shared_mutex提供了两种锁类型独占锁std::unique_lock和共享锁std::shared_lock可以在多线程环境下保证数据的安全性。

使用std::shared_mutex的一般步骤如下

  1. 创建一个std::shared_mutex对象用于控制对共享资源的访问。
  2. 在读取共享资源时使用std::shared_lock加共享锁表示获取了共享读锁允许其他线程同时进行读取操作。
  3. 在写入共享资源时使用std::unique_lock加独占锁表示获取了独占写锁其他线程无法进行读取或写入操作。

读写锁可以有三种状态

  • 读模式加锁状态
  • 写模式加锁状态
  • 不加锁状态

        只有一个线程可以占有写模式的读写锁但是可以有多个线程占有读模式的读写锁。
        读写锁也叫做“共享-独占锁”当读写锁以读模式锁住时它是以共享模式锁住的当它以写模式锁住时它是以独占模式锁住的。

1当读写锁处于写加锁状态时在其解锁之前所有尝试对其加锁的线程都会被阻塞
2当读写锁处于读加锁状态时所有试图以读模式对其加锁的线程都可以得到访问权但是如果想以写模式对其加锁线程将阻塞。这样也有问题如果读者很多那么写者将会长时间等待如果有线程尝试以写模式加锁那么后续的读线程将会被阻塞这样可以避免锁长期被读者占有。

3.5.2 读写锁示例

#include <thread>
#include <shared_mutex>
#include <iostream>

std::shared_mutex rwMutex; // 创建一个名为rwMutex的读写锁对象
int sharedData = 0;

void readData()
{
    std::shared_lock<std::shared_mutex> lock(rwMutex); // 使用shared_lock进行共享读锁的加锁
    // 对共享数据进行读取操作
    std::cout << "Reading data: " << sharedData << std::endl;
}

void writeData()
{
    std::unique_lock<std::shared_mutex> lock(rwMutex); // 使用unique_lock进行独占写锁的加锁
    // 对共享数据进行写入操作
    sharedData += 1;
    std::cout << "Writing data: " << sharedData << std::endl;
}

int main()
{
    std::thread reader1(readData);
    std::thread reader2(readData);
    std::thread writer(writeData);

    reader1.join();
    reader2.join();
    writer.join();

    return 0;
}

        在上述代码中我们使用std::shared_mutex来创建一个读写锁对象rwMutex。在读取共享数据时我们使用std::shared_lockrwMutex进行加锁这表示获取了共享读锁允许其他线程同时进行读取操作。而在写入共享数据时我们使用std::unique_lockrwMutex进行加锁这表示获取了独占写锁其他线程无法进行读取或写入操作。

        需要注意的是std::shared_lockstd::unique_lock都是模板类需要指定锁的类型即读锁或写锁以及锁对象。在加锁时可以使用构造函数进行初始化并在离开作用域时自动释放锁。

典型面试题目【节选自LinuxC/C++多线程(线程池、读写锁和CAS无锁编程) - 知乎

       现在有四个线程线程A、B、C、D其中线程A和B是以读模式打开的此锁并且已经拥有了读写锁现在线程C想要以写模式打开读写锁由于读写锁已经被别的线程拿走了所以线程C进入阻塞状态那么此时又来了一个线程D线程D想以读模式拿到这把互斥锁问线程D可以拿到吗

解答若有不对还请指正

        这个问题从理论上来讲线程D是可以拿到读写锁的但是从实际上来说是不可以拿到的试想一下如果可以拿到那么后面来的所有线程都是以读模式拿到读写锁那么此时被阻塞的线程C什么时候才能运行肯定要等其他以读模式打开的线程都运行完之后才能拿到这在实际情况中是根本不允许的因此一旦有以写模式打开读写锁的线程出现后面来的所有以读模式访问读写锁的线程均会被阻塞掉。

四、总结

        在线程编程中多个线程可以同时访问和修改共享数据。共享数据是指在多个线程之间共享的变量或数据结构。然而对于共享数据的并发访问可能会导致数据竞争和不确定行为因此需要采取适当的同步机制来保证线程之间的正确协作。

        以下是一些常见的处理共享数据的方法

1. 互斥锁Mutex使用互斥锁可以确保在任意时刻只有一个线程能够访问共享数据。线程在访问共享数据之前需要先获得互斥锁并在访问完成后释放锁。这样可以避免多个线程同时修改同一个数据导致的问题。

2. 条件变量Condition Variable条件变量用于线程之间的等待和通知机制。当某个线程需要等待一个条件满足时它可以通过条件变量进行等待当其他线程满足了条件可以通过条件变量发送信号通知等待的线程。条件变量通常与互斥锁一起使用以确保在等待和通知过程中的线程安全。

3. 原子操作Atomic Operations原子操作是指不可中断的操作可以保证在多线程环境下对共享数据的原子性访问。原子操作可以用于对简单的数据类型如整数、指针进行读取、写入和修改而不需要显式地使用互斥锁。

4. 读写锁Read-Write Lock读写锁允许多个线程同时读取共享数据但只有一个线程能够写入数据。这种机制适用于读操作频繁、写操作较少的场景可以提高并发性能。

5. 同步原语Synchronization Primitives除了上述常见的同步机制同步原语外还存在其他同步原语如信号量、屏障等用于实现更复杂的同步操作。

        在处理共享数据时需要仔细考虑线程之间的并发访问情况选择合适的同步机制并确保正确地使用和释放同步对象以避免数据竞争和线程安全问题。

五、参考文献 

[1]
[2]  进程和线程的区别和联系_线程和进程的关系和区别_小鱼不会骑车的博客-CSDN博客
[3]【精选】C++ Thread_悲伤土豆拌饭的博客-CSDN博客
[4]【精选】多线程技术全面介绍-CSDN博客
[5]【精选】多线程详解完结-CSDN博客
[6]  一文搞定c++多线程同步机制_c++多线程同步等待-CSDN博客
[7]【精选】线程同步的四种方式_多线程同步-CSDN博客
[8]  C++11 多线程std::thread详解_c++_sjc_0910-华为云开发者联盟
[9]  并行与并发-CSDN博客

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: c++