此篇建议学了Linux系统多线程部分再来看。
1. C++多线程
在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。
C++11中最重要的特性就是支持了多线程编程,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。
1.1 thread库
查下文档:
如图所示,C++11提供了thread库,thread是一个类,在使用的时候需要包含头文件pthread。
构造函数:
- 默认构造函数
thread()
使用该构造函数创建的线程对象仅是创建对象,线程并没有被创建,也没有允许。 thread(Fn&& fn, Args&&... args)
,这是一个万能引用模板。使用该构造函数时,第一个参数是可调用对象,可以是左值也可以是右值,比如函数指针,仿函数对象,lambda表达式等等。后面的可变参数就是传给线程函数的实参,是一个参数包,也就是可变参数。
thread(const thread&) = delete
,线程之间是禁止拷贝的。thread(thread&& x)
,移动构造函数。
成员函数:
-
get_id
,用来获取当前线程的tid值。调用该函数通常都是当前线程,但是当前的从线程从并没有自己的thread对象
。
所以线程库由提供了一个命名空间,该空间中有上图所示的几个函数,可以通过命名空间来直接调用,如:
this_thread::get_id(); // 获取当前线程tid值
哪个线程执行这条语句就返回哪个线程的tid值,命名空间中的其他几个函数的用法也是这样。
yield
调用该接口的线程会让其CPU,让CPU调度其他线程。sleep_until
调用该接口的线程会延时至一个确定的时间点。sleep_for
调用该接口的线程会延时一个时间段,如1s。
-
operator=(thread&& t)
,移动赋值。
将一个线程对象赋值给另一个线程对象,通常这么用:
thread t1; // 仅创建对象,不创建线程 t1 = thread(func); // t1线程函数并且执行
此时原本只创建的线程对象就有一个线程在跑了。
注意:只能赋右值,不能赋左值,因为赋值运算符重载被禁掉了,只有移动赋值。
-
join
,线程等待,用来回收线程资源。一般主线程会调用该函数,以t.join()
的形式,t就是需要被等待的线程对象,此时主线程会阻塞在这里,直到从线程运行结束。
如上面的多线程一样,必须使用join,否则线程资源不会回收,而且如果从线程运行的时间比主线程长的话,主线程会直接运行完并且回收所有资源,导致从线程被强制结束。
-
joinable
,用来判断线程是否有效。
如果是以下任意情况则线程无效:
- 采用无参构造函数构造的线程对象
- 线程对象的状态已经转移给其他线程对象
- 线程已经调用 join 或者 detach 结束
detach
,线程分离,从线程结束后自动回收资源。
其他的就不介绍了,用到的时候自行查文档即可。
要谨记:thread是禁止拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值。
使用一下:
#include <iostream> #include <thread> using namespace std; void Print(int n, int& x) { for (int i = 0; i < n; ++i) { cout << this_thread::get_id() << ":" << i << endl; std::this_thread::sleep_for(std::chrono::milliseconds(100)); ++x; } } int main() { int count = 0; thread t1(Print, 10, ref(count)); // 可调用对象和其实参 thread t2(Print, 10, ref(count)); t1.join(); t2.join(); cout << count << endl; return 0; }
多次运行的结果不一样,可能会出现像第一行一样的抢着打印的问题(学了Linux多线程应该比较清楚),下面就应该想到加锁了。
1.2 mutex库
如上图所示,C++11提供了mutex库,mutex同样是一个类,在使用的时候要包含头文件mutex。
构造函数:
- 只有默认构造函数
mutex()
,在创建互斥锁的时候不需要传任何参数。 mutex(const mutex&)=delete
,禁止拷贝。
其他成员函数:
lock()
,给临界区加锁,加锁成功继续向下执行,失败则阻塞等待。unlock()
,给临界区解锁。try_lock()
,给临界区尝试加锁,加锁成功返回true,加锁失败返回false。使用try_lock时,如果申请失败则不阻塞,跳过申请锁的部分,执行非临界区代码。来看伪代码:
mutex mtx; if(mtx.try_lock()) { // 临界区代码 // ...... } else { // 非临界区代码 // ...... }
mutex
不能递归使用,如下面伪代码所示:
void Func(int n) { lock(); // 加锁 // 临界区代码 // ...... Func(n - 1); // 递归调用 unlock(); // 解锁 }
在递归中不能使用这样的锁,会造成死锁。
最上面例子的正确使用如下:
#include <iostream> #include <thread> #include <mutex> using namespace std; void Print(int n, int& x, mutex& mtx) { for (int i = 0; i < n; ++i) { mtx.lock(); cout << this_thread::get_id() << ":" << i << endl; std::this_thread::sleep_for(std::chrono::milliseconds(100)); ++x; mtx.unlock(); } } int main() { mutex m; int count = 0; thread t1(Print, 10, ref(count), ref(m)); thread t2(Print, 10, ref(count), ref(m)); t1.join(); t2.join(); cout << count << endl; return 0; }
后面再来看看怎么实现交错打印的效果,再看看另一种用法:(lambda)
int main() { mutex mtx; int x = 0; int n = 10; thread t1([&](){ for (int i = 0; i < n; ++i) { mtx.lock(); cout << this_thread::get_id() << ":" << i << endl; std::this_thread::sleep_for(std::chrono::milliseconds(100)); ++x; mtx.unlock(); } }); thread t2([&](){ for (int i = 0; i < n; ++i) { mtx.lock(); cout << this_thread::get_id() << ":" << i << endl; std::this_thread::sleep_for(std::chrono::milliseconds(100)); ++x; mtx.unlock(); } }); t1.join(); t2.join(); cout << x << endl; return 0; }
上面代码的问题:如果加锁解锁之间存在抛异常就死锁了,这时就要用到RAII锁。
1.3 RAII锁
lock_guard
是一个类,采用了RAII方式来加锁解锁——将锁的生命周期和对象的生命周期绑定在一起。看下在Linux篇章写的代码:(把锁封装了)
#pragma once #include <iostream> #include <pthread.h> class Mutex { public: Mutex(pthread_mutex_t* mtx) :_pmtx(mtx) {} void lock() { pthread_mutex_lock(_pmtx); std::cout << "进行加锁成功" << std::endl; } void unlock() { pthread_mutex_unlock(_pmtx); std::cout << "进行解锁成功" << std::endl; } ~Mutex() {} protected: pthread_mutex_t* _pmtx; }; class lockGuard // RAII风格的加锁方式 { public: lockGuard(pthread_mutex_t* mtx) // 因为不是全局的锁,所以传进来,初始化 :_mtx(mtx) { _mtx.lock(); } ~lockGuard() { _mtx.unlock(); } protected: Mutex _mtx; };
看库里的构造函数:
lock_guard(mutex_type& m)
,在创建这个对象的时候需要传入一把锁,在构造函数中,进行了加锁操作。
lcok_guard(const lock_guard&)=delete
,该对象禁止拷贝,因为互斥锁就不可以拷贝。
析构函数的作用就是将lock_guard
对象的资源释放,也就是进行解锁操作。
lock_guard
只有构造函数和析构函数,使用该类对象加锁时不需要我们去关心锁的释放,但是它不能在对象生命周期结束之前主动解锁。
看一下unique_lock:
unique_lock
也是一种RAII的加锁对象,它和lock_guard
的功能一样,将锁的生命周期和对象的生命周期绑定在一起,但是又有区别。
unique_lock(mutex_type& m)
,这个和lock_guard
的用法一样,在构造函数中加锁。
unique_lock(const unique_lock&)=delete
,同样禁止拷贝。
析构函数中和lock_guard
一样,也是进行解锁操作。
lock
,加锁。unlock
,解锁。
try_lock
,尝试加锁。
在lock_guard
中就没有这几个接口,所以unique_lock
可以在析构之前主动解锁,主动解锁后仍然可以再主动加锁,这一点lock_guard
是不可以的。
try_lock_for
,尝试加锁一段时间,时间到后自动解锁。try_lock_until
,尝试加锁到指定时间,时间到来后自动解锁。
用法很多,需要使用的时候可以结合库文档来使用。用一下lock_guard和lambda的另一种用法:
int main() { mutex mtx; int n = 10; int m; cin >> m; vector<thread> v(m); for (int i = 0; i < m; ++i) { // 移动赋值给vector中线程对象 v[i] = thread([&](){ for (int i = 0; i < n; ++i) { { lock_guard<mutex> lk(mtx); cout << this_thread::get_id() << ":" << i << endl; } std::this_thread::sleep_for(std::chrono::milliseconds(100)); } }); } for (auto& t : v) { t.join(); } return 0; }
从C语言到C++_40(多线程相关)C++线程接口+线程安全问题加锁(shared_ptr+STL+单例)(中):https://developer.aliyun.com/article/1522534