目录
一、进程与线程
二、多线程的实现
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()
函数来管理线程的执行。
方法 | 说明 | |
1 | join() | 等待一个线程完成,如果该线程还未执行完毕,则当前线程(一般是主线程)将被阻塞,直到该线程执行完成,主线程才会继续执行。 |
2 | detach() | 将当前线程与创建的线程分离,使它们分别运行,当分离的线程执行完毕后,系统会自动回收其资源。如果一个线程被分离了,就不能再使用join() 函数了,因为线程已经无法被联接了。 |
3 | joinable() | 判断线程是否可以执行join() 函数,返回true/false |
2.2.1 join()
在C++的std::thread
类中,join()
方法用于等待线程执行完毕。具体来说,当调用join()
方法时,程序会阻塞当前线程,直到被调用的线程完成其执行。
join()
方法有以下特点:
- 调用
join()
方法的线程会一直等待,直到被调用的线程执行完毕。 join()
方法只能被调用一次,多次调用会导致编译错误。- 如果在调用
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()
方法有以下特点:
- 调用
detach()
方法后,std::thread
对象不再与其所代表的线程相关联。 - 分离后的线程将变为守护线程,即程序不会等待它执行完毕。
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
类提供了以下静态成员函数:
函数 | 说明 | |
1 | std::this_thread::sleep_for() | 当前线程休眠指定的时间 |
2 | std::this_thread::sleep_until() | 当前线程休眠直到指定时间点 |
3 | std::this_thread::yield() | 当前线程让出CPU,允许其他线程运行 |
4 | std::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)是用于协调多个线程或进程之间访问共享资源的机制。在多线程或多进程环境下,如果没有适当的同步机制,会导致数据竞争、死锁等问题,从而影响程序的正确性和性能。
常见的同步原语包括:
- 互斥锁(mutex):用于保护共享资源,只允许一个线程或进程访问共享资源。
- 条件变量(condition variable):用于在多个线程之间进行通信,当某个条件满足时,唤醒等待该条件的线程。
- 读写锁(read-write lock):用于提高对共享资源的读取性能,允许多个线程同时读取共享资源,但只允许一个线程进行写操作。
- 原子操作(atomic operation):用于保证对共享变量的操作是原子的,即不会被其他线程或进程中断。
- 信号量(semaphore):用于控制多个线程或进程之间的访问顺序,可以用来实现互斥锁或条件变量等机制。
在C++11标准中,引入了std::mutex
、std::condition_variable
、std::shared_mutex
、std::atomic
等同步原语,方便开发者在多线程环境下编写安全的代码。此外,C++标准库还提供了一些包装类,如std::lock_guard
、std::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
需要以下步骤:
- 在需要保护的代码块前后创建
std::mutex
对象。 - 在进入代码块之前,使用
std::mutex
的lock()
方法锁定互斥量,以防止其他线程进入。 - 在代码块执行完毕后,使用
std::mutex
的unlock()
方法解锁互斥量,允许其他线程进入。
方法 | 说明 | |
1 | lock() | 将mutex上锁。如果mutex已经被其它线程上锁,那么会阻塞,直到解锁;如果mutex已经被同一个线程锁住,那么会产生死锁。 |
2 | unlock() | 将mutex解锁,释放其所有权。如果有线程因为调用lock() 不能上锁而被阻塞,则调用此函数会将mutex的主动权随机交给其中一个线程;如果mutex不是被此线程上锁,那么会引发未定义的异常。 |
3 | try_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中提供的一个RAII(Resource 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),从而减少资源的浪费。
条件变量通常包括以下两个基本操作:
-
等待(wait):线程在条件不满足时调用等待操作,将自己加入到条件变量的等待队列中,并释放互斥锁,使其他线程能够继续执行。当条件满足时,线程将被唤醒,并重新获得互斥锁。
-
唤醒(signal):线程在某个条件满足时,调用唤醒操作,通知等待队列中的一个或多个线程可以继续执行。被唤醒的线程会尝试重新获得互斥锁,并检查条件是否满足。
条件变量的使用一般遵循以下模式:
- 线程获取互斥锁。
- 检查条件是否满足,如果满足则继续执行,否则进入等待状态。
- 在等待状态下,线程会释放互斥锁,让其他线程能够执行。
- 当条件满足时,某个线程调用唤醒操作,通知等待队列中的线程可以继续执行。
- 被唤醒的线程尝试重新获得互斥锁,并检查条件是否满足,如果满足则继续执行,否则再次进入等待状态。
需要注意的是,条件变量的使用必须与互斥锁配合使用,以确保线程在等待和唤醒操作时的线程安全性。互斥锁用于保护共享资源的访问,而条件变量用于线程间的等待和唤醒操作。
总结起来,条件变量是一种线程间通信和协调的机制,通过等待和唤醒操作实现线程的等待和唤醒。它的使用需要与互斥锁结合,以确保线程安全性。
std::condition_variable中的成员函数
方法 | 说明 | |
1 | wait() | 使当前线程进入等待状态,直到被其他线程通过notify_one() 或notify_all() 函数唤醒。该函数需要一个互斥锁作为参数,调用时会自动释放互斥锁,并在被唤醒后重新获取互斥锁。 |
2 | wait_for() | wait_for(): 使当前线程进入等待状态,最多等待一定的时间,直到被其他线程通过notify_one()或notify_all()函数唤醒,或者等待超时。该函数需要一个互斥锁和一个时间段作为参数,返回时有两种情况:等待超时返回std::cv_status::timeout,被唤醒返回std::cv_status::no_timeout。 |
3 | wait_until() | wait_until(): 使当前线程进入等待状态,直到被其他线程通过notify_one()或notify_all()函数唤醒,或者等待时间达到指定的绝对时间点。该函数需要一个互斥锁和一个绝对时间点作为参数,返回时有两种情况:时间到达返回std::cv_status::timeout,被唤醒返回std::cv_status::no_timeout。 |
4 | notify_one() | notify_one() : 唤醒一个等待中的线程,如果有多个线程在等待,则选择其中一个线程唤醒。 |
5 | notify_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
的一般步骤如下:
- 创建一个
std::shared_mutex
对象,用于控制对共享资源的访问。 - 在读取共享资源时,使用
std::shared_lock
加共享锁,表示获取了共享读锁,允许其他线程同时进行读取操作。 - 在写入共享资源时,使用
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_lock
对rwMutex
进行加锁,这表示获取了共享读锁,允许其他线程同时进行读取操作。而在写入共享数据时,我们使用std::unique_lock
对rwMutex
进行加锁,这表示获取了独占写锁,其他线程无法进行读取或写入操作。
需要注意的是,std::shared_lock
和std::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):除了上述常见的同步机制(同步原语)外,还存在其他同步原语,如信号量、屏障等,用于实现更复杂的同步操作。
在处理共享数据时,需要仔细考虑线程之间的并发访问情况,选择合适的同步机制,并确保正确地使用和释放同步对象,以避免数据竞争和线程安全问题。