文章目录
- 线程库
- thread类的介绍
- 线程对象的构造方式
- thread提供的成员函数
- 获取线程的id的方式
- 线程函数参数
- join与detach
- 互斥量库(mutex)
- mutex的种类
- lock_guard和unique_lock
- 原子性操作库(atomic)
- 条件变量库(condition_variable)
线程库
thread类的介绍
在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差,C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念
注意:要使用标准库中的线程,必须包含< thread >头文件
线程对象的构造方式
函数名 | 功能 |
---|---|
thread() | 构造一个线程对象,没有关联任何线程函数,即没有启动任何线程 |
thread(fn,args1,args2…) | 构造一个线程对象,并关联线程函数fn,args1,args2,…为线程函数的参数 |
一.调用无参的构造函数
thread提供无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,即没有启动任何线程
thread t1;
由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象
#include<thread>
void func(int n) //打印0~n
{
for (int i = 0; i <= n; i++)
{
cout << i << " ";
}
cout << endl;
}
int main()
{
thread t1;
t1 = thread(func, 10); //调用的是移动构造
t1.join();
return 0;
}
线程提供无参构造函数有什么作用呢?
场景: 实现线程池的时候就是需要先创建一批线程,但一开始这些线程什么也不做,当有任务到来时再让这些线程来处理这些任务
二.调用带参的构造函数
thread的带参的构造函数的定义如下:
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
参数说明
fn
:可调用对象: 比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等
args...
:调用可调用对象fn时所需要的若干参数
调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t2(func, 10);
t2.join(); //线程等待
return 0;
}
三.调用移动构造函数
thread类提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t3 = thread(func, 10);//使用右值线程对象构造一个线程对象
t3.join();//线程等待
return 0;
}
注意点:1)线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态
2)当创建一个线程对象后,如果没有提供线程函数,该对象不会对应任何线程
- 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行
其中:可以用下面的是那种方式提供线程函数:函数指针,lambda表达式,函数对象
4)thread类是防拷贝的. 不允许拷贝构造以及赋值,但是可以移动构造和移动赋值:即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不影响线程的执行
thread提供的成员函数
函数名 | 功能 |
---|---|
get_id() | 获取线程id |
joinable() | 判断线程是否还在执行,joinable代表的是一个正在执行中的线程, 如果为真,表示还在执行 |
join() | 该函数调用后会阻塞住线程,当该线程结束后,主线程继续执行 |
detach() | 在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离的线程变为后台线程,创建的线程的"死活"就与主线程无关 |
swap() | 将两个线程对象关联线程的状态进行交换 |
可以通过jionable()
函数判断线程是否是有效的,如果是以下任意情况,则线程无效
- 采用无参构造函数构造的线程对象 (该线程对象没有关联任何线程)
- 线程对象的状态已经转移给其他线程对象 (已经将线程交给其他线程对象管理)
- 线程已经调用
join
或者detach
结束 (线程已经结束)
关于detach(): 将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待
获取线程的id的方式
调用thread类的成员函数get_id
可以获取线程的id.但该方法必须通过线程对象来调用get_id
函数
- 如果要在线程对象关联的线程函数中获取线程id,可以调用
this_thread
命名空间下的get_id
函数
#include<thread>
void func()
{
cout << this_thread::get_id() << endl; //获取当前线程id
}
int main()
{
thread t(func);
t.join();
return 0;
}
this_thread
命名空间中还提供了以下三个函数:
函数名 | 功能 |
---|---|
yield | 当前线程“放弃”执行,让操作系统调度另一线程继续执行 |
sleep_until | 让当前线程休眠到一个具体时间点 |
sleep_for | 让当前线程休眠一个时间段 |
线程函数参数
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的, 因此: 即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参, 比如:
#include<thread>
void add(int& num) //即使线程参数为引用类型,在线程中修改后也不能修改外部实参
{
num++;
}
int main()
{
int num = 0;
thread t(add, num);
t.join();//线程等待
cout << num << endl; //0
return 0;
}
并且这里会报错,需要把add函数的引用去掉**,如果要通过线程函数的形参改变外部的实参,可以参考以下三种方式:**
方式一:借助std::ref函数
当线程函数的参数类型为引用类型时,如果要想线程函数形参引用的是外部传入的实参,而不是线程栈空间中的拷贝,那么在传入实参时需要借助ref函数保持对实参的引用
#include<thread>
void add(int& num)
{
num++;
}
int main()
{
int num = 0;
thread t(add, std::ref(num));//借助ref函数保持对实参的引用
t.join();
cout << num << endl; //1
return 0;
}
方式二:传参的时候传递地址的拷贝
将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参,
#include<thread>
void add(int* num)
{
(*num)++;
}
int main()
{
int num = 0;
thread t(add, &num);
t.join();
cout << num << endl; //1
return 0;
}
方式三:借助lambda表达式
将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参,
int main()
{
int num = 0;
thread t([&num]{num++; });
t.join();
cout << num << endl; //1
return 0;
}
类的成员函数创建线程:
注意:如果是类的非静态成员函数作为线程函数时,因为非静态成员函数的第一个参数是this指针,所以必须将this指针(对象的地址)作为线程函数参数
class A
{
public:
void func()
{
cout << "void func()" << endl;
}
};
int main()
{
A a;
thread t(&A::func,&a); //必须将this指针作为线程函数参数
t.join();
return 0;
}
仿函数创建线程
使用函数对象来创建线程,必须要重载operator()
struct A
{
void operator()(int a = 10)
{
cout << "A::operator(),a = " <<a << endl;
}
};
int main()
{
A a1;
thread t1(a1); //调用A::operator() a1是可调用对象
thread t2(A(), 20);//当然也可以传匿名对象
t1.join();
t2.join();
return 0;
}
多线程怎么创建:
vector里面存放线程对象,然后遍历vector让线程执行任务
- 需要注意的是:如果这里用的是范围for遍历vector,需要加引用! 因为线程对象不允许拷贝和赋值,范围for本质是把迭代器,把对象赋值给变量
void Print(int n, int x)
{
for (int i = 0; i < n; ++i)
{
cout << i * x << endl;
}
}
int main()
{
thread t1; //无参线程对象,这个线程不会运行起来
thread t2(Print, 10, 2);
t2.join(); //等待t2线程 t1不需要等待,因为t1没有执行任何东西
int n;
cin >> n;
vector<thread> vthds;//把线程存在vector
vthds.resize(n); //n个线程
for (auto& t : vthds) //线程执行任务 注意这里要加引用!!!
{
t = thread(Print, 100, 2);//构造一个匿名的线程对象赋值给t 这里是移动赋值!
}
for (auto& t : vthds) //线程等待 注意这里要加引用!!!
{
t.join();
}
return 0;
}
join与detach
启动一个线程后,当这个线程退出时,需要对该线程所使用的资源进行回收,否则可能会导致内存泄露等问题.thread库给我们提供了如下两种回收线程资源的方式
join方式
主线程创建新线程后,可以调用join函数等待新线程终止,当新线程终止时join
函数就会自动清理线程相关的资源,
join
函数清理线程的相关资源后.thread对象与已销毁的线程就没有关系了,因此一个线程对象一般只会使用一次join
,否则程序会崩溃
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << " ";
}
cout << endl;
}
int main()
{
thread t(func, 20);
t.join();
t.join(); //程序崩溃
return 0;
}
但如果一个线程对象join
后,又调用移动赋值函数,将一个右值线程对象的关联线程的状态转移过来了,那么这个线程对象又可以调用一次join
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t(func, 20);
t.join();
t = thread(func, 30); //移动赋值,将一个右值线程对象的关联线程的状态转移过来了
t.join();
return 0;
}
但采用join
的方式结束线程,在某些场景下也可能会出现问题,比如在该线程被join
之前,如果中途因为某些原因导致程序不再执行后续代码,这时这个线程将不会被join
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
bool DoSomething()
{
return false;
}
int main()
{
thread t(func, 20);
if (!DoSomething())
return -1;
t.join(); //因为上面返回了,所以这里不会被执行
return 0;
}
因为上面没有join,对线程所使用的资源进行回收,所以会导致内存泄露等问题, 上面代码运行时出错!
因此采用join
方式结束线程时,join
的调用位置非常关键,为了避免上述问题,可以采用RAII的方式对线程对象进行封装,也就是利用对象的生命周期来控制线程资源的释放,比如:
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; //这里要用引用,因为thread不支持拷贝和赋值
};
- 每当创建一个线程对象后,就用myThread类对其进行封装产生一个myThread对象
- 当myThread对象生命周期结束时就会调用析构函数,在析构中会通过
joinable
判断这个线程是否需要被join
,如果需要那么就会调用join
对其该线程进行等待
例如刚才的代码中,使用myThread类对线程对象进行封装后,就能保证线程一定会被join
int main()
{
thread t(func, 20);
myThread mt(t); //使用myThread对线程对象进行封装
if (!DoSomething())
return -1;
t.join();
return 0;
}
当myThread对象生命周期结束时就会调用析构函数,就会对我们曾经的线程进行join释放资源
detach方式font>
主线程创建新线程后,也可以调用detach
函数将新线程与主线程进行分离,分离后新线程会在后台运行,其所有权和控制权将会交给C++运行库,此时C++运行库会保证当线程退出时,其相关资源能够被正确回收,
- 使用
detach
的方式回收线程的资源,一般在线程对象创建好之后就立即调用detach
函数,否则线程对象可能会因为某些原因,在后续调用detach
函数分离线程之前被销毁掉,这时就会导致程序崩溃
int main()
{
thread t(func, 20);
t.detach(); //一般在线程对象创建好之后就立即调用`detach`函数
if (!DoSomething())
return -1;
//t.detach(); //如果放在这里,程序直接崩溃了,因为在调用detach函数分离线程之前,线程已经被销毁掉
return 0;
}
互斥量库(mutex)
mutex的种类
在C++11中.mutex中总共包了四种互斥量: (需要引入头文件:#include<mutex>
)
第一种互斥量: std::mute
mutex锁是C++11提供的最基本的互斥量.**mutex对象之间不能进行拷贝,也不能进行移动, ** 常用的成员函数如下:
成员函数 | 功能 |
---|---|
lock | 对互斥量进行加锁 |
try_lock | 尝试对互斥量进行加锁 |
unlock | 对互斥量进行解锁,释放互斥量的所有权 |
lock和try_lock的区别:
线程函数调用lock
时,可能会发生以下三种情况:
- 如果该互斥量当前没被其他线程锁住,则调用线程将该互斥量锁住,直到调用
unlock
之前,该线程一直拥有该锁 - 如果该互斥量已经被其他线程锁住,则当前的调用线程会被阻塞
- 如果该互斥量是被当前调用线程锁住,则会产生死锁(deadlock)
线程调用try_lock
时,类似也可能会发生以下三种情况:
- 如果该互斥量当前没被其他线程锁住,则调用线程将该互斥量锁住,直到调用
unlock
之前,该线程一致拥有该锁, - 如果该互斥量已经被其他线程锁住,则
try_lock
调用返回false.当前的调用线程不会被阻塞 - 如果该互斥量是被当前调用线程锁住,则会产生死锁(deadlock)
第2种互斥量:std::recursive_mutex
如果在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题,
recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作 ,recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock
除此之外.recursive_mutex也提供了lock
、try_lock
和unlock
成员函数,其的特性与mutex大致相同
第3种互斥量:std::timed_mutex
timed_mutex中提供了以下两个成员函数:
try_lock_for
:接受一个时间范围表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁**,如果超时(即在指定时间之内还是没有获得锁),则返回false.**try_lock_untill
:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false
除此之外.timed_mutex也提供了lock
、try_lock
和unlock
成员函数,其的特性与mutex相同,
第4种互斥量:std::recursive_timed_mutex
recursive_timed_mutex就是recursive_mutex和timed_mutex的结合 : recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁
加锁示例
例如:我们让两个线程各自打印1-100的数字
void func(int n)
{
for (int i = 1; i <= n; i++)
{
cout << i << " ";
}
cout << endl;
}
int main()
{
thread t1(func, 100);
thread t2(func, 100);
t1.join();
t2.join();
return 0;
}
在没有使用互斥锁保证线程安全的情况下,就可能会导致输出错乱,如果要让两个线程的输出不会相互影响,即不会让某一次输出中途被另一个线程打断,那么就需要用互斥锁对打印过程进行保护,
这里加锁的方式有两种,一种是在for循环体内进行加锁,一种是在for循环体外进行加锁,比如:
在for循环体内加锁:
void func(int n, mutex& mtx) //引用接收互斥锁
{
mtx.lock(); //for循环体外加锁
for (int i = 1; i <= n; i++)
{
cout << i << endl;
}
mtx.unlock();
}
在for循环体外加锁:
如果锁加在外面,就变成了串行操作,一个执行流执行完才到第二个执行流++,相当于单线程
void func(int n, mutex& mtx) //引用接收互斥锁
{
for (int i = 1; i <= n; i++)
{
mtx.lock(); //for循环体内加锁
cout << i << endl;
mtx.unlock();
}
}
int main()
{
mutex mtx;
//注意这里的互斥锁需要传引用,为了保证两个线程使用的是同一个互斥锁
thread t1(func, 100, std::ref(mtx));
thread t2(func, 100, std::ref(mtx));
t1.join();
t2.join();
return 0;
}
在这里,哪种方式比较高效呢?
本来是加锁的粒度越小越好,按照这个说法,应该是在for循环内加锁更好的,但是 此处在for循环体外加锁比在for循环体内加锁更高效, 因为在for循环体内加锁,虽然t1和t2能交替并行运行,但是因为会导致线程打印数字时频繁进行加锁解锁操作,会导致t1和t2线程频繁的切换上下文,而如果在for循环体外加锁,那么这两个线程只需要在开始打印1之前进行一次加锁,在打印完100后进行一次解锁就行了
1)在for循环体外加锁也就意味着两个线程的打印过程变成了串行的, 即一个线程打印完1-100后另一个线程再打印,但这时打印效率提高了,因为避免了这两个线程间的频繁切换
2)为了保证两个线程使用的是同一个互斥锁,线程函数必须以引用的方式接收传入的互斥锁,并且在传参时需要使用ref函数保持对互斥锁的引用
3)此处也可以将互斥锁定义为全局变量,或是用lambda表达式定义线程函数,然后以引用的方式将局部的互斥锁进行捕捉,这两种方法也能保证两个线程使用的是同一个互斥锁
4)如果想要在for循环内部加锁,最好是使用自旋锁!
定义全局变量的不好之处:
- 在项目中实际不太建议定义全局变量,因为全局变量如果定义在头文件中,当这个头文件被多个源文件包含时,在这多个源文件中都会对这个全局变量进行定义
- 这时就会导致变量重定义,但如果将全局变量定义为静态,加上static修饰,那这个全局变量就只在当前文件可见
如果确实有一些变量需要在多个文件中使用,那么一般建议将这些变量封装到一个类当中,然后将这个类设计成单例模式,当需要使用这些变量时就通过这个单例对象去访问即可
lock_guard和unique_lock
使用互斥锁时可能出现的问题
使用互斥锁时,如果加锁的范围太大,那么极有可能在中途返回时/遇到异常问题,导致忘记了解锁,此后申请这个互斥锁的线程就会被阻塞住,也就是造成了死锁问题
比如:
mutex mtx;
void func()
{
mtx.lock();
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr)
{
//,,,
return; //中途返回(未解锁)
}
mtx.unlock();//这句代码没有被执行!
}
int main()
{
func();
return 0;
}
因此使用互斥锁时如果控制不好就会造成死锁,最常见的就是此处在锁中间代码返回,此外还有一个比较常见的情况就是在锁的范围内抛异常,也很容易导致死锁问题
#include<mutex>
//从值为base开始插入,插入n个数
void func(vector<int>& v, int n, int base, mutex& mtx)
{
try
{
for (int i = 0; i < n; ++i)
{
//在for循环内部加锁->串行执行
mtx.lock();
cout << this_thread::get_id() << ":" << base + i << endl; //输出插入的值
// push_back失败了,例如:扩容失败,抛异常 -- 异常安全的问题
v.push_back(base + i);
// 模拟push_back失败抛异常
if (base == 1 && i == 8) //让第一个线程,插入值为8的时候抛异常
throw bad_alloc();
//抛异常之后,这句代码就执行不到了
mtx.unlock();
}
}
catch (const exception& e)
{
cout << e.what() << endl;
}
}
int main()
{
thread t1, t2;
vector<int> vec;
mutex mtx;
t1 = thread(func, std::ref(vec), 100, 1, std::ref(mtx));//线程1从1开始插入,插入个数100个
t2 = thread(func, std::ref(vec), 100, 100, std::ref(mtx));//线程1从100开始插入,插入100个
t1.join();
t2.join();
for (auto e : vec)
cout << e << " ";
cout << endl << endl;
cout << vec.size() << endl;
return 0;
}
死锁的原因:前面的线程抛异常之后没有解锁,剩下的线程就进不来了,就会导致死锁问题!
解决办法1: 在捕获异常的地方解锁!
void func(vector<int>& v, int n, int base, mutex& mtx)
{
try
{
for (int i = 0; i < n; ++i)
{
mtx.lock();
cout << this_thread::get_id() << ":" << base + i << endl;
// push_back失败了,例如:扩容失败,抛异常 -- 异常安全的问题
v.push_back(base+i);
// 模拟push_back失败抛异常
if (base == 1 && i == 8) //让第一个线程抛异常
throw bad_alloc();
//抛异常之后,这句代码就执行不到了
mtx.unlock();
}
}
catch (const exception& e)
{
cout << e.what() << endl;
mtx.unlock();//解锁
}
}
int main()
{
thread t1, t2;
vector<int> vec;
mutex mtx;
t1 = thread(func, std::ref(vec), 100, 1, std::ref(mtx));//线程1从值为1开始插入,插入个数100个
t2 = thread(func, std::ref(vec), 100, 100, std::ref(mtx));//线程1从值为100开始插入,插入个数100个
t1.join();
t2.join();
for (auto e : vec)
{
cout << e << " ";
}
cout << endl << endl;
cout << vec.size() << endl;
return 0;
}
此时就不会导致死锁问题! 因为线程1不再竞争锁了,所以最终容器的元素个数并非200个!
C++11采用RAII的方式对锁进行了封装,于是就出现了lock_guard和unique_lock.
lock_guard
lock_guard是C++11中的一个模板类,其定义如下:
template <class Mutex>
class lock_guard;
lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装,
- 在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用
lock
进行加锁, - 当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用
unlock
自动解锁
通过这种构造对象时加锁,析构对象时自动解锁的方式就有效的避免了死锁问题,比如:
mutex mtx;
void func()
{
lock_guard<mutex> lg(mtx); //调用构造函数加锁
//,,,
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr)
{
//,,,
return; //调用析构函数解锁
}
//,,,
} //调用析构函数解锁
int main()
{
func();
return 0;
}
案例2:
void func(vector<int>& v, int n, int base, mutex& mtx)
{
try
{
for (int i = 0; i < n; ++i)
{
lock_guard<mutex> lock(mtx); //调用构造函数加锁
cout << this_thread::get_id() << ":" << base + i << endl;
// 失败了 抛异常 -- 异常安全的问题
v.push_back(base + i);
// 模拟push_back失败抛异常
if (base == 1000 && i == 888)
throw bad_alloc(); //调用析构函数解锁
}
}
catch (const exception& e)
{
cout << e.what() << endl;
}
}//调用析构函数解锁
int main()
{
thread t1, t2;
vector<int> vec;
mutex mtx;
t1 = thread(func, std::ref(vec), 100, 1, std::ref(mtx));//线程1从1开始插入,插入个数100个
t2 = thread(func, std::ref(vec), 100, 100, std::ref(mtx));//线程1从100开始插入,插入100个
t1.join();
t2.join();
for (auto e : vec)
cout << e << " ";
cout << endl << endl;
cout << vec.size() << endl;
return 0;
}
从lock_guard对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围,
如果只想用lock_guard保护某一段代码,可以通过定义匿名的局部域来控制lock_guard对象的生命周期,比如:
mutex mtx;
void func()
{
//匿名局部域
{
lock_guard<mutex> lg(mtx); //调用构造函数加锁
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr)
{
//,,,
return; //调用析构函数解锁
}
} //调用析构函数解锁
//...往后执行
}
int main()
{
func();
return 0;
}
模拟实现lock_guard
模拟实现lock_guard类的步骤如下:
- lock_guard类中包含一个锁成员变量(引用类型),这个锁就是每个lock_guard对象管理的互斥锁,
- 调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的
lock
函数进行加锁, - lock_guard的析构函数中调用互斥锁的
unlock
进行解锁, - 需要删除lock_guard类的拷贝构造和拷贝赋值,因为lock_guard类中的锁成员变量本身也是不支持拷贝的,
template<class Lock>
class LockGuard // 锁的守卫
{
public:
LockGuard(Lock& lock)
:_lock(lock)//互斥锁不支持拷贝! 引用变量必须在初始化列表初始化
{
_lock.lock();//加锁
}
~LockGuard()
{
_lock.unlock();//解锁
}
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
Lock& _lock; //互斥锁不支持拷贝!所以传引用
};
如何使用:
void func(vector<int>& v, int n, int base, mutex& mtx)
{
try
{
for (int i = 0; i < n; ++i)
{
LockGuard<mutex> lock(mtx); //自己实现的
cout << this_thread::get_id() << ":" << base + i << endl;
// 失败了 抛异常 -- 异常安全的问题
v.push_back(base + i);
// 模拟push_back失败抛异常
if (base == 1000 && i == 888)
throw bad_alloc(); //调用析构函数解锁
}
}
catch (const exception& e)
{
cout << e.what() << endl;
}
}//调用析构函数解锁
int main()
{
thread t1, t2;
vector<int> vec;
mutex mtx;
t1 = thread(func, std::ref(vec), 100, 1, std::ref(mtx));//线程1从1开始插入,插入个数100个
t2 = thread(func, std::ref(vec), 100, 100, std::ref(mtx));//线程1从100开始插入,插入100个
t1.join();
t2.join();
for (auto e : vec)
cout << e << " ";
cout << endl << endl;
cout << vec.size() << endl;
return 0;
}
unique_lock
但由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock.
unique_lock与lock_guard类似.unique_lock类模板也是采用RAII的方式对锁进行了封装,在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁,
但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的场景:
- 要用互斥锁保护函数1的大部分代码,但是中间有一小块代码调用了函数2.而调用函数2时不需要用函数1中的互斥锁进行保护,函数2内部的代码由其他互斥锁进行保护,
- 因此在调用函数2之前需要对当前互斥锁进行解锁,当函数2调用返回后再进行加锁,这样当调用函数2时其他线程调用函数1就能够获取到这个锁,
mutex mtx;
void func1()
{
unique_lock<mutex> ul(mtx);//调用构造函数加锁
//...
ul.unlock();//解锁
func2();
ul.lock();//加锁
//....
}//调用析构函数解锁
原子性操作库(atomic)
线程安全问题
多线程最主要的问题是共享数据带来的问题(即线程安全),如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据,但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦,比如:
#include<mutex>
void func(int& n, int times)
{
for (int i = 0; i < times; i++)
{
n++;
}
}
int main()
{
int n = 0;
int times = 100000; //每个线程对n++的次数
thread t1(func, ref(n), times);
thread t2(func, ref(n), times);
t1.join();
t2.join();
cout << n << endl; //打印n的值
return 0;
}
上述代码中分别让两个线程对同一个变量n进行了100000次++
操作,理论上最终n的值应该是200000.但最终打印出n的值却是小于200000的
根本原因就是++
操作并不是一个原子性操作,++的步骤分为以下三步:
load
:将共享变量n从内存加载到寄存器中update
:更新寄存器里面的值,执行+1操作store
:将新值从寄存器写回共享变量n的内存地址
++操作对应的汇编代码:
因此可能当线程1刚将n的值加载到寄存器中就被切走了(上下文切换),也就是只完成了++
操作的第一步,而线程2可能顺利完成了一次完整的++
操作才被切走,而这时线程1继续用之前加载到寄存器中的值完成剩余的两步操作,最终就会导致两个线程分别对共享变量n进行了一次++
操作,但最终n的值却只被++
了一次
加锁解决线程安全问题
C++98中对于这里出现的线程安全的问题,会选择对共享修改的数据进行加锁保护,比如:
#include<mutex>
void func(int& n, int times, mutex& mtx)
{
mtx.lock();
for (int i = 0; i < times; i++)
{
//mtx.lock();
n++;
//mtx.unlock();
}
mtx.unlock();
}
int main()
{
int n = 0;
int times = 100000; //每个线程对n++的次数
mutex mtx;
thread t1(func, ref(n), times, ref(mtx));
thread t2(func, ref(n), times, ref(mtx));
t1.join();
t2.join();
cout << n << endl; //打印n的值 200000
return 0;
}
这里可以选择在for循环体里面进行加锁解锁,也可以选择在for循环体外进行加锁解锁,但效果终究是不尽人意的,在for循环体里面进行加锁解锁会导致线程的频繁进行加锁解锁操作,在for循环体外面进行加锁解锁会导致两个线程的执行逻辑变为串行,而且如果锁控制得不好,还容易造成死锁,
原子类解决线程安全问题
C++11中引入了原子操作类型,使得线程间数据的同步变得非常高效,如下:
原子类型名称 | 对应的内置类型名称 |
---|---|
atomic_bool | bool |
atomic_char | char |
atomic_schar | signed char |
atomic_uchar | unsigned char |
atomic_int | int |
atomic_uint | unsigned int |
atomic_short | short |
注意: 需要用大括号对原子类型的变量进行初始化,
程序员不需要对原子类型进行加锁解锁操作,线程能够对原子类型变量互斥访问,比如刚才的代码可以改为:
#include<mutex>
void func(atomic_int& n, int times)
{
for (int i = 0; i < times; i++)
{
n++; //原子性++
}
}
int main()
{
atomic_int n = { 0 };
int times = 100000; //每个线程对n++的次数
thread t1(func, ref(n), times);
thread t2(func, ref(n), times);
t1.join();
t2.join();
cout << n << endl; //200000
return 0;
}
除此之外,也可以使用atomic类模板定义出任意原子类型,比如上述代码还可以改为:
#include<mutex>
void func(atomic<int>& n, int times)
{
for (int i = 0; i < times; i++)
{
n++;
}
}
int main()
{
atomic<int> n = 0;
int times = 100000; //每个线程对n++的次数
thread t1(func, ref(n), times);
thread t2(func, ref(n), times);
t1.join();
t2.join();
cout << n << endl; //打印n的值
return 0;
}
- 原子类型通常属于“资源类型”数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及
operator=
等, - 为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、
operator=
默认删除掉了, - 原子类型不仅仅支持原子的
++
操作,还支持原子的--
、加一个值、减一个值、与、或、异或操作,
条件变量库(condition_variable)
condition_variable中提供的成员函数,可分为wait系列和notify系列两类,
wait系列成员函数
wait系列成员函数的作用就是让调用线程进行阻塞等待,包括wait
、wait_for
和wait_until
,
下面先以wait
为例进行介绍.wait函数提供了两个不同版本的接口:
//版本一
void wait(unique_lock<mutex>& lck);
//版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);
函数说明:
- 调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒,
- 调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false.那么该线程还需要继续被阻塞,
为什么调用wait系列函数时需要传入一个互斥锁?
- 因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动获得这个互斥锁,
- 因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁,
wait_for和wait_until函数的使用方式与wait函数类似:
- wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒,
- wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒,
- 线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒,此外,如果调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false.那么当前线程还需要继续被阻塞,
注意:调用wait系列函数时,传入互斥锁的类型必须是unique_lock.
notify系列成员函数
notify系列成员函数的作用就是唤醒等待的线程,包括notify_one
和notify_all
,
notify_one
:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做,notify_all
:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做,
注意: 条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队,