本篇文章更多的是熟悉一下C++11的线程库接口,与linux的相关线程接口是非常相似的,更多的是将面向过程改为了面向对象。
并没有一些概念的讲解。
想知道线程的相关概念的可以看一看这篇文章及后续
在C++11之前,涉及到多线程问题,都是和平台相关的,
比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。
C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含< thread >头文件。
目录
- 线程接口:
- 构造:
- 常用接口:
- 线程函数参数:
- 具体实践:
- 创建线程的方法:
- 创建多线程的方法:
- this_thread:
- get_id:
- yeild:
- 互斥锁:
- 锁的接口:
- 锁的使用:
- 其他的锁:
- lock_guard:
- unique_lock:
- 条件变量:
线程接口:
构造:
首先我们来看构造,他有无参构造,带参构造(模板+万能引用+多参数模板),移动构造,却没有拷贝构造,
没有拷贝构造的原因在于
常用接口:
注意:
- 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
- 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
#include <thread>
int main()
{std::thread t1;cout << t1.get_id() << endl;return 0;
}
get_id()的返回值类型为id类型,id类型实际为std::thread命名空间下封装的一个类,该类中包含了一个结构体:
// vs下查看
typedef struct
{ /* thread identifier for Win32 */void *_Hnd; /* Win32 HANDLE */unsigned int _Id;
} _Thrd_imp_t;
- 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。
线程函数一般情况下可按照以下三种方式提供:
-函数指针
-lambda表达式
-仿函数
- thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不意向线程的执行。
- 可以通过jionable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效:
- 采用无参构造函数构造的线程对象
- 线程对象的状态已经转移给其他线程对象
- 线程已经调用jion或者detach结束
线程函数参数:
线程函数的参数是以值拷贝的方式拷贝到线程栈空间
中的,因此:即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
void ThreadFunc1(int& x)
{x += 10;
}
void ThreadFunc2(int* x)
{*x += 10;
}
int main()
{int a = 10;// 在线程函数中对a修改,不会影响外部实参,因为:线程函数参数虽然是引用方式,但其实际引用的是线程栈中的拷贝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;
}
那我们怎么理解多个线程执行同一个函数,因为每个线程都有独立的栈,所以虽然是同一个函数,却在不同的栈建立栈帧。
具体实践:
创建线程的方法:
由于可以使用lambda,我们选择直接使用lambda作为线程函数。
int main()
{thread t1([](int x)->void{for (int i = 0; i < x; i++){cout << " I am Thraed-1" << endl;}}, 10);thread t2([](int x)->void{for (int i = 0; i < x; i++){cout << " I am Thraed-2" << endl;}}, 20);t1.join();t2.join();return 0;
}
创建多线程的方法:
上段代码过于冗余,如果我们要创建更多但是功能相近的线程函数,该如何操作呢?
使用vector + thread无参构造即可轻松管理多线程。
注意:这里有一个细节,我们在循环体内应慎用lambda全部引用捕捉,如果上图代码采取引用捕捉,由于我们在lambda中使用了i这个for循环内部的控制变量,有可能还没有开始执行lambda,但是循环却已经结束,i到了5,导致数组越界从而报错。
this_thread:
为啥要看这个东西?因为很有用!
注意:这是std作用域中的this_thread域,里面有一些如下函数:
我们在创建线程时,如果需要有在lambda内打印id的需求,我们是没有办法打印出来的,即使我们的thread提供了get_id函数。
这是因为t1对象还没有构建好。
那我们怎么获取?
就在这个命名空间内,有以上4个函数。
get_id:
这样即可在构建时得到对应的id。
yeild:
对于这个接口我们目前了解一下即可。
这是啥呢?
这是让出当前线程时间片的一个接口(对于OS的一个建议)。
例子:
假设你正在一个繁忙的办公室工作,你和同事都需要使用同一台打印机。为了公平起见,你们约定每个人每次只能使用打印机一分钟。现在,你正在打印一个文件,但你发现文件很长,一分钟内肯定打印不完。这时,你看到旁边有几位同事也在等着打印急件,他们看起来比你还着急。
于是,你决定做一个礼貌的举动:你暂时放下自己的打印任务,让出打印机给其他人使用。这就像是在说:“我还有很多页要打印,但我可以先等等,你们先用吧。”这就是std::this_thread::yield函数在多线程环境中的作用——它让当前线程“礼让”,给其他线程一个运行的机会。
一般在无锁编程(无锁编程指的是少用锁,因为效率低)中出现。那么这就要提一提CAS(cmpare_and_swap)了。
也就是原子操作。
比如我们的i++在汇编层面是至少3条语句:(拿出i,进行修改,放回内存)。
这就导致在多线程时出现线程安全,于是就衍生出了原子操作,这是由硬件直接支持好的,也就是只有一条汇编语句,不存在线程安全的问题,
对于线程安全来说,软件层面有很多方式,比如锁,原子操作…但是这些各种各样的方式都离不开硬件的支持。
这两种方法我们随后都会涉及到。
关于sleep_until是休眠到一个绝对时间,
而sleep_for是休眠你指定的时间,原理是比较复杂的,需要的时候直接看文档copy代码即可。
互斥锁:
当我们对一个全局变量i进行多线程++时,线程安全就毫无意外的出现了。
所以我们需要进行加锁。
锁的接口:
这里我们只关注常用的lock与unlock即可
锁是不可拷贝的。
注意,线程函数调用lock()时,可能会发生以下三种情况:
- 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁
- 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)
线程函数调用try_lock()时,可能会发生以下三种情况:
- 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock释放互斥量
- 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)
锁的使用:
注意放锁的位置即可。
int x = 0;
mutex mtx;
void func(int n)
{for (int i = 0; i < n; i++){mtx.lock();x++;mtx.unlock();}
}int main()
{size_t begin = clock();thread t1(func, 100000);thread t2(func, 500000);t1.join();t2.join();size_t end = clock();cout << x << endl;cout << end - begin << endl;return 0;
}
但是注意:
上下段两段代码哪个效率高?
void func(int n)
{mtx.lock();for (int i = 0; i < n; i++){x++;}mtx.unlock();
}
答案是下段高,为什么下段明明是串行却比上段的部分串行效率高?
这与我们学习到的要尽可能的细粒度加锁不太对劲啊。
原因有二:
- 频繁的lock与unlock
- i++太快了,当t1线程抢到锁,t2刚去阻塞,但是由于++太快了,导致刚去阻塞就又回来了,导致频繁的上下文切换。
其他的锁:
std::recursive_mutex
其允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。
std::timed_mutex
比 std::mutex 多了两个成员函数,try_lock_for(),try_lock_until() 。
for是锁你指定的时间,until是锁到你指定的时间。
std::recursive_timed_mutex
上述两个锁的特性相加。
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类模板主要是通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。
lock_guard的缺陷:太单一,用户没有办法对该锁进行控制,因此C++11又提供了unique_lock。
unique_lock:
与lock_gard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。
在构造(或移动(move)赋值)时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化
unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。
与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所管理的互斥量的指针)。
条件变量:
持续更新~