文章目录
C++11 – 线程库
Thread Lib
在C++11之前,设计多线程问题都与平台相关,Windows下和Linux下都有自己的接口,这使得代码的可移植性非常差。C++11中最重要的特性就是对线程进行支持,使得C++并行编程时并不需要依赖第三方库,并且在原子操作中还引入了原子类的概念
线程对象的构造
调用无参的构造函数
// 单纯无参构造
thread t1; // 使用这种方式构造的线程对象没有关联任何线程函数
// 无参构造 + 移动赋值
void func(int n) {
for (int i = 0; i < n; i++)
cout << i << endl;
}
int main() {
thread t1;
t1 = thread(func, 10); // thread提供了移动赋值函数,因此无参构造的线程对象可以调用移动赋值接收带参创建的匿名线程对象
t1.join();
return 0;
}
使用场景: 比如创建线程池的时候需要先创建一批线程,一开始这些线程什么都不做,但是有任务到来时这些线程才被激活处理任务
调用带参的构造函数
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
fn
: 可调用对象,比如函数指针,仿函数,lambda表达式,包装器包装后的可调用对象等args...
: 可调用函数fn
的参数列表
调用移动构造函数
thread 提供移动构造函数,可以使用一个右值来构建线程对象
void func(int n) {
for (int i = 0; i < n; i++)
cout << i << endl;
}
int main() {
thread t1 = thread(func, 10); // thread提供了移动构造函数
t1.join();
return 0;
}
thread 类是防拷贝的,不允许拷贝赋值和拷贝构造,但是可以移动构造和移动赋值。
线程是操作系统的一个概念,线程对象可以关联一个线程,用以控制线程以及获取线程的状态,如果创建线程没有提供线程函数,那么该线程对象实际没有对应任何线程,如果提供了线程函数,那么主线程就会启动一个线程来执行该函数,该线程和主线程一起运行
线程类成员函数
成员函数 | 对比pthread 库 | 功能 |
---|---|---|
join | pthread_join() | 对该线程进行等待,在等待线程返回前,调用join函数的线程将会被阻塞 |
joinable | 判断该线程是否已经执行完毕,如果已经执行完毕返回true, 否则返回false | |
detach | pthread_detach() | 将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待 |
get_id | pthread_self() | 获取线程id |
swap | 将两个线程对象关联线程的状态进行交换 |
joinable()
函数可以用于判定线程是否有效,以下三种情况线程无效
- 采用无参构造函数构造的线程对象(没有关联任何线程)
- 线程对象的状态已经转移给其它线程对象 (已经将线程对象交给其它线程对象管理)
- 线程已经调用join 或者detach 或已经结束
获取线程ID的方式
我们可以通过调用thread类的成员函数get_id()
获取线程ID,但是该方法必须通过线程对象来进行调用,如果要在线程对象关联的线程函数中获取线程ID,可以调用this_thread命名空间下的get_id函数
void func(int n) {
cout << "child thread get t1 id = " << this_thread::get_id() << endl; // 在关联线程函数中获取线程ID
}
int main() {
thread t1 = thread(func, 10);
cout << "main thread get t1 id = " << t1.get_id() << endl; // 在主线程中获取子线程ID
t1.join();
return 0;
}
函数名 | 功能 |
---|---|
yield | 当前线程放弃执行,让操作系统调度另一线程继续执行 |
sleep_until | 当前线程休眠到一个具体时间点 |
sleep_for | 让当前线程休眠到一个时间段 |
线程函数的参数问题
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参。因为实际引用的是线程栈中的拷贝,而非外部实参。
void func(int& n) { // 参数为引用
n = 20;
cout << "child thread n = " << n << endl;
}
int main() {
int n = 10;
thread t1 = thread(func, n); // 直接传参
t1.join();
cout << "main thread n = " << n << endl;
return 0;
}
// 类似这样的代码在VS2022编译器下就跑不了
如果想要通过线程函数的形参改变外部实参,可以参考下三种方式:
使用std::ref()函数
使用ref()
函数可以保持对实参的引用
void func(int& n) {
n = 20;
cout << "child thread n = " << n << endl;
}
int main() {
int n = 10;
thread t1 = thread(func, ref(n));
t1.join();
cout << "main thread n = " << n << endl;
return 0;
}
地址的拷贝
void func(int* n) {
*n = 20;
cout << "child thread n = " << *n << endl;
}
int main() {
int n = 10;
thread t1 = thread(func, &n);
t1.join();
cout << "main thread n = " << n << endl;
return 0;
}
借助lambda表达式
将lambda表达式作为线程函数,利用lambda函数的捕捉列表,因引用的方式对外部实参进行捕捉,此时在lambda表达式中的形参的修改也能影响到外部实参
int main() {
int n = 10;
thread t1 = thread(func, [&n](){
n = 20;
cout << "child thread n " << n << endl;
});
t1.join();
cout << "main thread n = " << n << endl;
return 0;
}
join 和 detach
启动一个线程后,当这个线程退出时,需要对该线程所占用的资源进行回收,否则可能会导致内存泄漏等问题,thread 库为我们提供了两种回收线程资源的方式
join 方式
主线程创建新线程后,可以用join 函数等待新线程终止,当新线程终止后,join函数就会自动清理线程相关资源。 需要注意,join函数清理线程相关资源,thread对象就和已销毁线程没有关系了,因此一个线程对象一般只会使用一次join,否则程序会崩溃
int main() {
int n = 10;
thread t1 = thread(func, ref(n));
t1.join();
t1.join(); // 程序崩溃
return 0;
}
但如果线程对象被join后,又调用移动赋值函数,将一个右值线程对象的关联线程状态转移到该线程对象,那么该线程对象可以再调用join
但是再用join的方式清理线程,在某些场景下任然会出问题。比如一个线程在被join之前,因为某些愿意导致程序不再执行后续代码,则这个线程将不会被join
bool DoSomething() {
return false;
}
int main() {
int n = 10;
thread t1 = thread(func, ref(n));
if (!DoSomething())
return -1;
t1.join();
return 0;
}
// 这就是一个很典型的例子,若该函数不是main函数则会造成内存泄漏
所以采用join方式结束线程时,join的调用位置非常关键,可以采用RALL的方式对线程对象进行封装,利用对象的生命周期来控制线程资源的释放
class myThread {
public:
myThread(thread& t) :_t(t) {};
~myThread() {
if (_t.joinable()) _t.join();
}
myThread(myThread const&) = delete;
myThread& operator=(const myThread&) = delete;
private:
thread& _t;
};
-
每当我们创建一个线程对象后,就是用
myThread
类对其进行封装产生一个myThread
对象 -
当
myThread
对象生命周期结束时就会调用析构函数,析构函数中会通过joinable
判断该线程是否需要被join,如果需要则会调用join
清理线程资源
detach方式
主线程创建新线程后也可以调用detach
函数将新线程和主线程分离,分离后的新线程会在后台运行,其所有权和控制权会交给C++运行库,此时C++运行库会保证当线程退出时,相关资源能够被正确回收
- 使用
detach
方式回收的线程资源,一般在线程对象创建好之后就立即调用detach函数,因为线程对象可能会因为某些原因在后续调用detach
函数之前被销毁,可能会导致程序崩溃 - 因为当线程对象被销毁时需要调用thread的析构函数,thread析构函数会通过joinable判断该线程对象是否与线程关联,如果需要则会调用terminate终止当前程序(程序崩溃)
Mutex Lib
C++11中,mutex中总共包含了四种互斥量
四种互斥量
1、std::mutex
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动
常用成员函数
成员函数 | 功能 |
---|---|
lock | 对互斥量加锁 |
try_lock | 尝试对互斥量进行加锁 |
unlock | 对互斥量进行解锁,释放互斥量的所有权 |
2、
std::recursize_mutex
_
recursize_mutex
叫做递归互斥锁,该锁专门用于递归函数中的加锁操作
- 如果递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请到自己还未释放的锁,导致死锁问题
recursize_mutex
支持一个线程对互斥量多次上锁(递归上锁),来获取互斥量对象的多层所有权,但是释放该互斥量也需要调用与该锁层次深度相同的unlock
除此之外,recursize_mutex
提供的接口和mutex提供的接口大致相同,就不介绍了
3、std::timed_mutex
timed_mutex中提供了两个成员函数:
try_lock_for
: 接受一个时间范围,这段时间线程没有获得锁则会被阻塞住,如果其它线程释放了锁,该线程就会获得该锁。如果超时则返回false,并继续执行后续代码try_lock_untill
接受一个时间点为参数,如果指定事前前没有拿到锁会被阻塞住,若拿到锁则返回true继续执行后续代码,若超时返回false,并继续执行后续代码
除此之外,timed_mutex也提供了lock
,unlock
,try_lock
等接口
4、
std::recursize_timed_mutex
recursize_timed_mutex
是recursize_mutex
和timed_mutex
的结合,既可以在递归函数中进行加锁操作,也支持定时申请锁
lock_guard
使用互斥锁时可能出现的问题
使用互斥锁,如果加锁的范围太大,极有可能在中途返回忘记解锁,此后申请这个互斥锁的线程就会被阻塞住,导致死锁问题
mutex mtx;
void func2() {
mtx.lock();
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr) {
// ... // 可以看到文件不存在时,函数返回忘记将锁释放,锁被卡在调用这个函数的线程中
return;
}
// ...
mtx.unlock();
}
因此C++11采用了RALL的方式对锁进行了封装,出现了lock_guard和unique_lock
lock_guard
lock_guard时C++11中的一个模板类
template <class Mutex> class lock_guard;
lock_guard类模板主要是通过RALL的方式对其管理的互斥锁进行了封装
- 在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock进行加锁
- 当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用unlock自动解锁
通过这种方式,从lock_guard对象从定义到对象析构,这段区域都属于互斥锁保护的范围
mutex mtx;
void func2() {
lock_guard<mutex> lg(mtx); // 使用lock_guard
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr) {
// ...
return;
}
// ...
}
如果只想用lock_guard保护一段代码,还可以通过定义匿名的局部域来控制lock_guard对象的生命周期
mutex mtx;
void func2() {
// ...
{
lock_guard<mutex> lg(mtx); // 使用lock_guard
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr) {
// ...
return;
}
// ...
}
}
lock_guard 的模拟实现
template<typename Mutex>
class my_lock_guard {
public:
my_lock_guard(Mutex& _mtx) :mtx(_mtx) {
mtx.lock();
};
~my_lock_guard() {
mtx.unlock();
};
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
Mutex& mtx;
};
unique_lock
由于lock_guard接口过于单一,用户不能对锁进行控制,因此C++11又提供了unique_lock
unique_lock 和 lock_guard 类似,但是unique_lock更加灵活,提供了更多的成员函数
- 加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock
- 修改操作:移动赋值、swap、release(返回它锁管理的互斥量的指针,并释放所有权限)
- 获取属性:owns_lock(返回当前对象是否上锁), operator bool(与owns_lock的功能相同)、mutex返回当前unique_lock锁管理的互斥量的指针
以下场景就很适合用unique_lock:
void func3() {
unique_lock<mutex> u1(mtx);
// ...
u1.unlock();
func2(); // func2()内部业务逻辑较长,并不需要访问u1保护的临界资源
u1.lock();
// ...
} // 调用析构函数
可以看出unique_lock 非常灵活
Atomic Lib
C++11引入了原子操作类型,使得线程间数据同步变得非常高效,程序员不需要对原子类型进行加锁解锁操作,线程能够对原子类型变量进行互斥访问。
// 对原子操作类型进行 ++ 或 --操作可以看作是原子的
void func4(atomic<int>& n, int times) {
for (int i = 0; i < times; i++) {
n++;
}
}
int main() {
atomic<int> n = { 0 };
int times = 10000;
thread t1(func4, ref(n), times);
thread t2(func4, ref(n), times);
t1.join();
t2.join();
cout << n << endl;
return 0;
}
原子类型通常属于资源类型数据,多个线程只能访问单个原子类型的拷贝,因此C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等
为了防止意外标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除掉了
原子类型不仅仅支持++操作和–操作,加一个值,减一个值、与、或、异或都是可以的
Condition_variable Lib
condition_variable 库中提供的成员函数可以分为wait系列和notify系列两类
wait系列成员函数
wait系列函数的作用是让调用的线程进行阻塞等待,包括wait
, wait_for
,wait_until
// 版本1
void wait (unique_lock<mutex>& lck);
// 版本2
template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);
- 版本一的wait函数只需要传入一个互斥锁,线程调用wait后会被立即阻塞直到被唤醒
- 版本二的wait函数除了需要传入一个互斥锁,还需要一个返回值为bool的可调用对象,与版本一的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要被继续阻塞
注意:调用wait系列函数时,传入的互斥锁类型必须是unique_lock
notify系类成员函数
notify系列成员函数的作用就是唤醒等待的线程,包括notify_one 和 notify_all.这两者就类似于pthread_signal
和 pthread_cond_broadcast
线程库实战
(实现两个线程交替打印1-100)
void actual_combat1() {
atomic<int> n = { 1 };
thread t1([&] {
while (n < 100) {
if (n & 1) {
cout << n << endl;
n++;
}
}
});
thread t2([&] {
while (n < 100) {
if ((n & 1) == 0) {
cout << n << endl;
n++;
}
}
});
t1.join();
t2.join();
}
void func1(int& n, int max, mutex& mtx, condition_variable& cv) {
while (n < max) {
unique_lock<mutex> ul(mtx);
if (n % 2 == 1) {
cout << n << endl;
n++;
cv.notify_one();
}
else {
cv.wait(ul, [&]{return n % 2 == 1;});
}
}
}
void func2(int& n, int max, mutex& mtx, condition_variable& cv) {
while (n < max) {
unique_lock<mutex> ul(mtx);
if (n % 2 == 0) {
cout << n << endl;
n++;
cv.notify_one();
}
else {
cv.wait(ul, [&] {return n % 2 == 0; });
}
}
}
void actual_combat2() {
int n = 1;
int max = 100;
mutex mtx;
condition_variable cv;
thread t1(func1, ref(n), max, ref(mtx), ref(cv));
thread t2(func2, ref(n), max, ref(mtx), ref(cv));
t1.join();
t2.join();
}
void actual_combat3() {
int n = 100;
mutex mtx;
condition_variable cv;
bool flag = true;
thread t1([&] {
int i = 1;
while (i <= 100) {
unique_lock<mutex> ul(mtx);
cv.wait(ul, [&flag]()->bool {return flag; });
cout << this_thread::get_id() << ":" << i << endl;
i += 2;
flag = false;
cv.notify_one();
}
});
thread t2([&] {
int j = 2;
while (j <= 100) {
unique_lock<mutex> ul(mtx);
cv.wait(ul, [&flag]()->bool {return !flag; });
cout << this_thread::get_id() << ":" << j << endl;
j += 2;
flag = false;
cv.notify_one();
}
});
t1.join();
t2.join();
}
int main() {
// actual_combat1();
// actual_combat2();
actual_combat3();
return 0;
}
参考文章
「2021dragon」的原创文章C++11线程库
原文链接:https://blog.csdn.net/chenlong_cxy/article/details/126976346