目录
一、thread类的简单介绍
在C++11中,实际上既可以说存在“线程类”,也可以说引入了“线程库”的支持。更准确地说,C++11标准库通过<thread>头文件提供了一系列与线程相关的类和函数,这些共同构成了C++11的线程库。
C++11标准库通过<thread>头文件提供了一系列与线程相关的类和函数,这些共同构成了C++11的线程库。C++11对线程进行支持,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含< thread >头文件。
二、thread类的使用
2.1 关键成员函数及其功能
构造函数
thread() 构造一个线程对象,没有关联任何线程函数,不启动任何线程。
thread(fn,args1, args2,...) 构造一个线程对象,并启动一个新线程来执行函数fn,(args1,args2,...)为线程函数的参数。线程函数可以是可执行对象(函数指针、仿函数、lambda、包装器)。注:
- 不支持拷贝构造以及赋值,但支持移动构造和移动赋值。
- 带参构造,创建可执行线程。
- 先创建空线程对象,移动构造或者移动赋值,把右值线程对象转移过去。
线程id
thread::id 线程id,唯一标识一个线程。
id get_id() const noexcept; 返回当前线程的 thread::id 对象,用来唯一标识一个线程。noexcept 关键字用于指定函数不会抛出异常。
thread::id this_thread::get_id() noexcept; 线程对象调用的函数获取当前执行线程的 ID
阻塞当前线程
void jion(); 该函数调用后会阻塞住线程,当该线程结束后,主线程继续执行.
bool joinable() const noexcept; 检查thread对象是否代表一个可连接的线程,如果线程已经启动且尚未被 join() 或 detach() 调用,则该函数返回 true。如果线程是空的或已经被 join() 或 detach() 调用,则返回 false。
线程分离
void detach(); 在创建线程对象后马上调用,把被创建线程与线程对象分离开,分离的线程变为后台线程,创建的线程的"死活"就与主线程无关。
注意:
- 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的
状态。 - 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
- thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个
线程对象关联线程的状态转移给其他线程对象,转移期间不影响线程的执行。 - 在线程对象销毁之前,必须要么调用 join() 要么调用 detach(),否则程序将终止线程并引发异常。
- 一旦线程被分离(detach),就不能再被连接(join),且线程对象不再与任何线程关联。
- 调用 join() 或 detach() 后,std::thread 对象不再表示任何线程,因此这些操作只能对每个线程对象执行一次。
- 可以通过jionable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效
采用无参构造函数构造的线程对象;
线程对象的状态已经转移给其他线程对象;
线程已经调用jion或者detach结束;
简单举例:
#include <iostream>
#include <thread>
using namespace std;
void PrintThreadId()
{
thread::id id = this_thread::get_id();//线程对象调用的函数获取当前执行线程的 ID要使用this_thread::get_id(),而不是get_id()
cout << "Thread ID: " << id << endl;
}
void Func1()
{
//创建线程
thread t1(PrintThreadId);
//获取线程id
thread::id t1_id = t1.get_id();
cout << "Thread 1 ID: " << t1_id << endl;
// 判断线程是否可连接
if (t1.joinable())
t1.join();
else
cout << "Thread 1 is not joinable." << endl;
//创建另一个线程
thread t2(PrintThreadId);
//获取线程id
thread::id t2_id = t2.get_id();
cout << "Thread 2 ID: " << t2_id << endl;
// 判断线程是否可连接
if (t2.joinable())
t2.join();
else
cout << "Thread 2 is not joinable." << endl;
return;
}
int main()
{
Func1();
return 0;
}
有时输出结果可能混乱,这是由于线程间竞争条件和同步问题造成的,如多线程同时尝试写入同一个输出流。
2.2 并发和并行
并发(Concurrency)与并行(Parallelism)是两个经常被混淆的概念,但在计算机科学中它们有明确的定义和区别。
并发
并发指的是两个或多个事件在同一时间段内发生。在多线程或多进程编程中,并发意味着多个任务或线程可以同时存在于内存中,并且可以在同一时间段内交替执行。并发通常是在单个处理器上通过时间片轮转、任务切换或线程上下文切换来实现的。
并行
并行是指两个或多个事件在同一时刻发生。在多线程或多处理器编程中,并行意味着多个任务或线程可以在同一时刻执行。并行通常是在多个处理器或多个处理器核心上通过并行处理来实现的。
区别
执行时机:
并发:事件在同一时间段内交替发生。
并行:事件在同一时刻发生。
执行方式:
并发:通常通过时间片轮转、任务切换或线程上下文切换在单个处理器上实现。
并行:通常通过多处理器或多处理器核心并行处理实现。
资源使用:
并发:通常在单个处理器上,需要更多的上下文切换开销。
并行:可以在多个处理器或处理器核心上运行,减少上下文切换开销。
适用场景:
并发:适用于需要交替执行的任务,如多任务操作系统。
并行:适用于可以同时执行的任务,如高性能计算。
在实际应用中,并发和并行可以相互结合,例如,一个多线程的程序可以同时运行多个任务,这些任务又可以进一步分解为多个子任务,在多个处理器核心上并行执行。
2.3 线程函数参数
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在
线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
void ThreadFunc1(int& x)
{
x += 10;
}
void ThreadFunc2(int* x)
{
*x += 10;
}
int main()
{
int a = 10;
// 这里会报错
thread t1(ThreadFunc1, a);
t1.join();
cout << a << endl;
// 如果想要通过形参改变外部实参时,必须借助std::ref()函数
thread t2(ThreadFunc1, std::ref(a));
t2.join();
cout << a << endl;
// 地址的拷贝
thread t3(ThreadFunc2, &a);
t3.join();
cout << a << endl;
return 0;
}
当尝试将 a 作为参数传递给 ThreadFunc1 时,编译器无法确定我们想要调用的是 ThreadFunc1 函数的哪个版本,因为 ThreadFunc1 函数有一个引用参数和一个指针参数。
注意:如果是类成员函数作为线程参数时,必须将this作为线程函数参数。
2.4 原子性操作库(atomic)
在多线程编程中,当多个线程同时修改共享数据时,确实可能会出现数据竞争和不一致的问题。这通常是因为线程之间的执行顺序是不可预测的,导致一个线程可能读取到一个不稳定的数据状态,并基于此状态进行操作。
为了解决这个问题,C++11 引入了原子操作库(<atomic>),它提供了一系列原子操作类,这些操作可以在多线程环境中安全地执行,无需额外的同步机制。这些原子操作可以保证读取和写入操作的原子性,即这些操作要么全部执行,要么全部不执行,不会被其他线程打断。
例如:
unsigned long sum = 0L; //表明为long类型
void TestSum(size_t num)
{
for (size_t i = 0; i < num; i++)
{
sum++;
}
}
void Func3()
{
cout << "Before joining,sum = " << sum << endl;
thread t1(TestSum, 10000);
thread t2(TestSum, 10000);
t1.join();
t2.join();
cout << "After joining,sum = " << sum << std::endl;
}
在上述代码中,sum 是一个全局变量,被两个线程同时修改。这是一个典型的数据竞争场景,可能导致 sum 的最终值不是预期的 20000。
虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对sum++时,其他线程就会被阻
塞,会影响程序运行的效率,而且锁如果控制不好,还容易造成死锁。
为了确保 sum 的修改是原子性的,我们可以将其声明为 std::atomic<unsigned long> 类型。
atomic<unsigned long> sum = 0L; //atomic 保证修改是原子性的,从而避免了数据竞争和不一致的问题
注意:
- 虽然 std::atomic 可以确保读取和写入操作的原子性,但它并不保证多线程操作的顺序。如果应用程序需要特定的执行顺序,可能还需要使用其他同步机制,如互斥量(std::mutex)或条件变量(std::condition_variable)。
- 原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11
中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及
operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。 - 使用以上原子操作变量时,必须添加头文件<atomic>
#include <atomic>
int main()
{
atomic<int> a1(0);
//atomic<int> a2(a1); // 编译失败
atomic<int> a2(0);
//a2 = a1; // 编译失败
return 0;
}
三、mutex的使用
在多线程环境下,如果想要保证某个变量的安全性,只要将其设置成对应的原子类型即可,即高
效又不容易出现死锁问题。但是有些情况下,我们可能需要保证一段代码的安全性,那么就只能
通过锁的方式来进行控制。
3.1 互斥量的种类
在C++11中,mutex总共包了四个互斥量的种类:
1. std::mutex
C++11提供的最基本的互斥量,该类的对象之间不能拷贝,也不能进行移动。
mutex类的常用成员函数 :
函数名 函数功能 void lock(); 上锁:锁住互斥量 void unlock(); 解锁:释放对互斥量的所有权 bool try_lock(); 尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞 注意,
线程函数调用lock()时,可能会发生以下三种情况:
- 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁。
- 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock) 。
线程函数调用try_lock()时,可能会发生以下三种情况:
- 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量。
- 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞。
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock) 。
2. std::recursive_mutex
允许同一个线程多次获得同一个互斥量的锁(即递归上锁)。
线程在释放锁时需要调用相同次数的 unlock()。适用于需要递归调用并保护共享数据的场景。
除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。
3. std::timed_mutex
比 std::mutex 多了两个尝试获取锁的方法,try_lock_for(),try_lock_until() ,提供了比try_lock()更灵活的等待机制。
try_lock_for() 接受一个时间段(如std::chrono::seconds),如果在这段时间内锁可用,则获取锁;否则,返回false。在这一段时间之内线程如果没有获得锁则被阻塞住(与std::mutex 的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁)则返回 false。
try_lock_until() 接受一个时间点,如果在时间点之前没有获得锁则被阻塞,如果在该时间点之前锁可用,则获取锁;超时(即在指定时间内还是没有获得锁),返回false。(如std::chrono::steady_clock::now() + std::chrono::seconds(10))
补充:chrono类
在C++11及更高版本中,<chrono> 头文件提供了一个用于处理时间的库,它允许程序员以类型安全的方式处理时间间隔和时间点。这个库的设计目的是为了取代旧的基于 time.h 的日期和时间处理功能,提供更加现代和功能丰富的替代方案。
try_lock_for() 和 try_lock_until() 两个函数都接受一个 std::chrono::duration 类型的对象作为参数,该对象表示等待锁定的时间。如果在这段时间内线程成功获得了锁,则返回 true;如果超时,则返回 false。
chrono::duration 类型用于表示两个时间点之间的时间间隔。
chrono的类实例化类型如下
chrono命名空间,常使用milliseconds(毫秒数)
4. std::recursive_timed_mutex
- 结合了std::recursive_mutex和std::timed_mutex的特性。
- 允许同一个线程多次获得锁,并且提供了try_lock_for()和try_lock_until()方法来尝试在指定时间内或直到指定时间点之前获取锁。
- 适用于需要递归调用且希望在特定时间内获取锁的场景。
举例说明recursive_mutex 的使用
int __count = 5;
std::recursive_mutex rmtx2; // 递归互斥量
void recursiveFunction()
{
rmtx2.lock(); // 递归上锁
std::cout << "Inside recursiveFunction, count = " << __count << std::endl;
if(--__count)
recursiveFunction(); // 递归调用
rmtx2.unlock(); // 递归解锁
}
void Func5()
{
std::thread t1(recursiveFunction);
t1.join();
}
举例 timed_mutex 的使用
#include <iostream>
#include <mutex>
#include <thread>
#include <chrono>
using namespace std;
timed_mutex tmtx;
void ThreadRoutine()
{
// 假设这里有一些工作要做,然后尝试获取锁
this_thread::sleep_for(chrono::milliseconds(500)); //模拟工作
//尝试在一秒内获取锁
if (tmtx.try_lock_for(chrono::seconds(1)))
{
cout << "Lock acquired successfully" << endl;
//执行临界区代码
this_thread::sleep_for(chrono::milliseconds(1000));//模拟临界区工作
tmtx.unlock();
}
else
{
cout << "Failed to acquire lock within specified time" << endl;
}
}
void Func6()
{
thread t(ThreadRoutine);
// 主线程先获取锁
this_thread::sleep_for(chrono::milliseconds(250));// 确保子线程开始执行
tmtx.lock();
cout << "Main thread has locked the mutex" << endl;
this_thread::sleep_for(std::chrono::seconds(2)); // 保持锁一段时间
tmtx.unlock();
t.join()
}
主线程在子线程尝试获取锁之前已经获取了锁,并且保持了锁超过1秒,所以子线程中的try_lock_for()会失败。
void Func7()
{
thread t(ThreadRoutine);
// 尝试在指定时间点之前获取锁
chrono::steady_clock::time_point tp = chrono::steady_clock::now() + chrono::microseconds(1000);
if (tmtx.try_lock_until(tp))
{
cout << "try_lock_until successfully " << endl;
//this_thread::sleep_for(chrono::milliseconds(2000));//模拟临界区工作
tmtx.unlock();
}
else
{
cout << "try_lock_until failed" << endl;
}
t.join();
}
3.2 lock_guard
lock_guard 是一个模板类,用于封装互斥量的锁定和解锁操作。它提供了一种 RAII(资源获取是初始化)风格的对象,该对象在构造时自动获取互斥量,在析构时自动释放互斥量。这意味着 lock_guard 对象的生命周期与其持有的锁的生命周期是同步的,确保了锁的正确释放。
在上一篇我们自己实现了lock_guard的基本功能,下面是它在库中的定义:
template<class _Mutex>
class lock_guard
{
public:
// 在构造lock_gard时,_Mtx还没有被上锁
explicit lock_guard(_Mutex& _Mtx)
: _MyMutex(_Mtx)
{
_MyMutex.lock();
}
// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
lock_guard(_Mutex& _Mtx, adopt_lock_t)
: _MyMutex(_Mtx)
{}
~lock_guard() _NOEXCEPT
{
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
lock_guard 的主要特点是简单和自动,它不需要用户手动管理锁的获取和释放。在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。
lock_guard的缺陷:太单一,用户没有办法对该锁进行控制,因此C++11又提供了unique_lock。
注意:
- 作用域:lock_guard 必须在它的作用域内创建和销毁,以确保在对象析构时自动释放锁。如果 lock_guard 对象超出其作用域,锁可能会被意外释放,导致数据竞争或不一致。
- 自动释放:lock_guard 在析构时自动释放锁,不需要手动调用 unlock() 方法。这简化了代码并提高了安全性。
- 锁的传递:lock_guard 对象不能被复制或移动,这意味着锁的状态不能被传递。如果需要传递锁,应该使用 unique_lock。
- 锁的嵌套:lock_guard 不支持嵌套锁定,即不能在持有第一个锁的情况下再持有第二个锁。如果需要嵌套锁定,应该使用 unique_lock。
- 异常安全:lock_guard 是异常安全的,它确保在锁被释放之前,所有的局部对象都被析构。这有助于避免资源泄漏。
- 避免虚假唤醒:在使用 condition_variable 和 lock_guard 时,应该小心处理虚假唤醒的问题。确保在检查条件之前解锁,并在满足条件后重新加锁。
- 使用智能锁:在需要更精细控制锁的场景中,可以考虑使用 std::unique_lock 或其他高级锁类,如 std::shared_lock 和 std::scoped_lock。
- 避免死锁:在多线程环境中,避免死锁是至关重要的。确保锁的获取和释放顺序一致,并避免在持有锁的情况下再次请求同一锁。
补充:“虚假唤醒”(Spurious Wakeup)是指在使用 std::condition_variable 和 std::mutex 进行线程间通信时,条件变量可能会在没有其他线程通知的情况下唤醒等待的线程。这种情况是允许发生的,因为条件变量的实现依赖于操作系统,而操作系统的信号量可能会在某些情况下导致虚假唤醒。
3.3 unique_lock
与lock_gard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所
有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。
在构造(或移动(move)赋值)时,unique_lock 对象需要传递一个 mutex 对象作为它的参数,新创建的unique_lock 对象负责传入的 mutex 对象的上锁和解锁操作。
特点:
- unique_lock 支持尝试获取锁,这有助于避免长时间等待锁。
- 可以在获取锁后保持锁,直到手动释放。
- 支持锁的移动和传递。
- 支持嵌套锁定。
- 在析构时自动释放锁,不需要手动调用 unlock() 方法。
- 是异常安全的,它确保在锁被释放之前,所有的局部对象都被析构。
与lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
- 上锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock
- 修改操作:移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有权)、释放(release:释放互斥量所有权,返回它所管理的互斥量对象的指针)
- 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。
mutex mtx;
condition_variable cv;
bool ready = false;
void Prepare_data()
{
this_thread::sleep_for(chrono::seconds(1));
{
unique_lock<mutex> lock(mtx);
ready = true;
cv.notify_one();// 通知等待的线程
}
}
void Process_data()
{
{
unique_lock<mutex> lock(mtx, defer_lock);// 尝试获取锁
if (lock.try_lock())
{
while (!ready)
{
cv.wait(lock);
}
cout << "Data is ready!" << endl;
}
else
{
cout << "Failed to acquire lock." << endl;
}
}
}
void Func9()
{
thread t1(Prepare_data);
thread t2(Process_data);
t1.join();
t2.join();
}
等待1s后开始接受数据。
举例:交替打印,线程1打印奇数,线程2 打印偶数
void Func8()
{
mutex mtx;
int x = 1;
condition_variable cv;
bool flag = false;
thread t1([&]() {
for (size_t i = 0; i < 5; i++)
{
unique_lock<mutex> lock(mtx);
if (flag)
cv.wait(lock);//进入wait前会解锁
cout << this_thread::get_id() << " , " << x << endl;
x++;
flag = true;
cv.notify_one();// t1 notify_one的时候 t2还没有wait
}
});
thread t2([&]() {
for (size_t i = 0; i < 5; i++)
{
unique_lock<mutex> lock(mtx);
if (!flag)
cv.wait(lock);
cout << this_thread::get_id() << " , " << x << endl;
x++;
flag = false;
cv.notify_one();
}
});
t1.join();
t2.join();
}