目录
1. C++多线程
1.1 thread库
1.2 mutex库
1.3 RAII锁
1.4 atomic+CAS
1.5 condition_variable
1.6 分别打印奇数和偶数
3. 单例模式线程安全
3.1 懒汉模式线程安全问题
3.2 懒汉模式最终代码
3.3 懒汉模式的另一种写法
本篇完。
此篇建议学了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;
}
1.4 atomic+CAS
C++11提供了原子操作,我们知道,线程不安全的主要原因就是访问某些公共资源的时候,操作不是原子的,如果让这些操作变成原子的后,就不会存在线程安全问题了。
CAS原理:
原子操作的原理就是CAS(compare and swap)。
- CAS包含三个操作数:内存位置(V),预期原值(A)和新值(B)。
- 如果内存位置的值与预期原值相等,那么处理器就会自定将该位置的值更新为新值。
- 如果内存位置的值与预期原值不相等,那么处理器不会做任何操作。
val是临界资源,两个线程t1和t2同时对这个值进行加加操作,每个线程都是将该值先拿到寄存器eax中。
- 线程将val值拿到寄存器eax中时,同时将该值放入原值V中。
- 在修改val值之前,CPU会先判断eax中的值与原值V中的值是否相等,如果相等则修改并且更新值,如果不相等则不修改。
伪代码原理:
while(1)
{
eax = val; // 将val值取到寄存器eax中
if(eax == V) // 和原值相同可以修改
{
eax++;
V = eax; // 修改原值
val = eax; // 修改val值
break; // 访问结束,跳出循环
}
}
- t1和t2虽然同时运行,但是时间粒度划分到极小的时候,CPU仍然是一个个在执行。
t1
线程将val
值拿到寄存器中,并且赋原值,经过判断发现和原值相同,所以修改val
值,并放回到va
l的地址中。
此时t2
线程被唤醒,它将val
值拿到寄存器中后与最开始的原值V相比,发现不相同了,所以就不进行修改,而且继续循环,知道寄存器中的值和原值相等才会改变。
- 原子操作虽然保证了线程安全,但是另一个无法写的的线程会不停的循环,而这也会占用一定的CPU资源。
CAS具体的原理有兴趣可以自行去了解,深入了解后写在简历是加分项。
atomic也是一个类,所以也有构造函数:
经常使用的是atomic(T val),在创建的时候传入我们想要进行原子操作的变量。
int a = atomic(1);
此时变量a的操作就都成了原子操作了,在多线程访问的时候可以保证线程安全。
成员函数:
该类重载了++,–等运算符,可以直接对变量进行操作。
看看没用atomic也没加锁的:
int main()
{
mutex mtx;
int x = 0;
int n = 100000;
int m = 2;
vector<thread> v(m);
for (int i = 0; i < m; ++i)
{
// 移动赋值给vector中线程对象
v[i] = thread([&](){
for (int i = 0; i < n; ++i)
{
++x;
}
});
}
for (auto& t : v)
{
t.join();
}
cout << x << endl;
return 0;
}
两个线程互相抢着加,就会出现有一个线程没加的情况,看看加锁的:
再看看用atomic的:
和加锁效果一样。
1.5 condition_variable
C++11中同样也有条件变量,用来实现线程的同步。
构造函数:
在创建条件变量的时候不用传入参数,同样是不允许被拷贝的。
其他成员函数:
放入等待队列:
wait(unique_lock<mutex>& lock),该接口是将调用它的线程放入到条件变量的等待队列中。
wait(unique_lock<mutex>& lck, Predicate pred),该接口和上面的作用一样,只是多了一个pred参数,当这个参数为true的话不放入等待队列,为false时放入等待队列。
这里传入的锁是unique_lock而不是lock_guard。
这是因为,当一个线程申请到锁进入临界区,但是条件不满足被放入条件变量的等待队列中时,会将申请到的锁释放。
lock_guard只能在对象生命周期结束时自动释放锁。
unique_lock可以在任意位置释放锁。
如果使用了lock_guard的话就无法在进入等待队列的时候释放锁了。
wait_for和wait_until都是等待指定时间,一个是在等待队列中待指定时间,另一个是在等待队列中带到固定的时间点后自定唤醒。
notify_one唤醒等待队列中的一个线程,notify_all唤醒等待队列中的所有线程。
1.6 分别打印奇数和偶数
写一个程序:支持两个线程交替打印,一个打印奇数,一个打印偶数。
分析:
- 首先创建一个全局的变量
val
,让两个线程去访问该变量并且进行加一操作。 - 考虑到线程安全,所以需要给对应的临界区加互斥锁
mutex
- 又是交替打印,所以要使用条件变量
condition_variable
来控制顺序,为了方便管理,使用的锁是unique_lock<mutex>
。
代码实现:
int main()
{
int val = 0;
int n = 10; // 打印的范围
mutex mtx; // 创建互斥锁
condition_variable cond; // 创建条件变量
thread t1([&](){
while (val < n)
{
unique_lock<mutex> lock(mtx); // 加锁
while (val % 2 == 0)// 判断是否是偶数
{
// 是偶数则放入等待队列中等待
cond.wait(lock);
}
// 是奇数时打印
cout << "thread1:" << this_thread::get_id() << "->" << val++ << endl;
cond.notify_one(); // 唤醒等待队列中的一个线程去打印偶数
}
});
this_thread::sleep_for(chrono::microseconds(100));
thread t2([&](){
while (val < n)
{
unique_lock<mutex> lock(mtx);
while (val % 2 == 1)
{
cond.wait(lock);
}
cout << "thread2:" << this_thread::get_id() << "->" << val++ << endl;
cond.notify_one();//唤醒等待队列中的一个线程去打印奇数
}
});
t1.join();
t2.join();
return 0;
}
上面代码两个线程执行的函数对象是lambda表达式,所以创建线程对象时,调用的是移动构造函数。
- wait()的第二个参数是false的时候,该线程被挂起到等待队列中,是true的时候不挂起,而且执行向下执行。
- 第二个参数的false和true可以是返回值,如代码就是使用的lambda表达式的返回值。
线程t1负责打印奇数,t2负责打印偶数,两个线程通过条件变量的控制交替打印。
还可以这么用:
int main()
{
int val = 0;
int n = 10; // 打印值的范围
mutex mtx;
condition_variable cond;
bool ready = true;
// t1线程打印奇数
thread t1([&](){
while (val < n)
{
{
unique_lock<mutex> lock(mtx);
cond.wait(lock, [&ready](){return !ready; });
cout << "thread1:" << this_thread::get_id() << "->" << val << endl;
val += 1;
ready = true;
cond.notify_one();
}
//this_thread::yield();
this_thread::sleep_for(chrono::microseconds(10));
}
});
// t2线程打印偶数
thread t2([&]() {
while (val < n)
{
unique_lock<mutex> lock(mtx);
cond.wait(lock, [&ready](){return ready; });
cout << "thread2:" << this_thread::get_id() << "->" << val << endl;
val += 1;
ready = false;
cond.notify_one();
}
});
t1.join();
t2.join();
return 0;
}
成功按照预期打印。
2. shared_ptr线程安全
智能指针复习:从C语言到C++_36(智能指针RAII)auto_ptr+unique_ptr+shared_ptr+weak_ptr-CSDN博客
以前敲的shared_ptr(加一个返回引用计数的接口):
namespace rtx
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
: _ptr(ptr)
, _pCount(new int(1))
{}
void Release()
{
if (--(*_pCount) == 0) // 防止产生内存泄漏,和析构一样,写成一个函数
{
delete _ptr;
delete _pCount;
}
}
~shared_ptr()
{
Release();
}
shared_ptr(const shared_ptr<T>& sp)
: _ptr(sp._ptr)
, _pCount(sp._pCount)
{
(*_pCount)++;
}
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
//if (this != &sp)
if (_ptr != sp._ptr) // 防止自己给自己赋值,注意不能比较this,类似s1 = s2; 再来一次s1 = s2;
{ // 比较_pCount也行
//if (--(*_pCount) == 0) // 防止产生内存泄漏,和析构一样,写成一个函数
//{
// delete _ptr;
// delete _pCount;
//}
Release();
_ptr = sp._ptr;
_pCount = sp._pCount;
(*_pCount)++;
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
int use_count()
{
return *_pCount;
}
protected:
T* _ptr;
int* _pCount;// 引用计数,有多线程安全问题,学了linux再讲,不能用静态成员
};
}
先看看库里面的使用:
int main()
{
std::shared_ptr<double> sp1(new double(7.77));
std::shared_ptr<double> sp2(sp1);
mutex mtx;
vector<thread> v(5);
int n = 100000;
for (auto& t : v)
{
t = thread([&](){
for (size_t i = 0; i < n; ++i)
{
// 拷贝是线程安全的
std::shared_ptr<double> sp(sp1);
// 访问资源不是
(*sp)++;
}
});
}
for (auto& t : v)
{
t.join();
}
cout << *sp1 << endl;
cout << sp1.use_count() << endl;
return 0;
}
2.1 库里面的shared_ptr使用
能指针共同管理的动态内存空间是线程不安全的,访问资源要自己加锁:
再把std换成自己的命名空间:
程序直接崩溃了,因为有时候引用计数不对。
多个线程及主线程中的所有智能指针都共享引用计数,又因为拷贝构造以及析构都不是原子的,所以导致线程不安全问题。
解决办法和Linux中一样,需要加锁:
引用计数加加和减减都要加锁
放个代码:
2.2 shared_ptr加锁代码
namespace rtx
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
: _ptr(ptr)
, _pCount(new int(1))
,_pMtx(new mutex)
{}
shared_ptr(const shared_ptr<T>& sp)
: _ptr(sp._ptr)
, _pCount(sp._pCount)
, _pMtx(sp._pMtx)
{
_pMtx->lock();
(*_pCount)++;
_pMtx->unlock();
}
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
//if (this != &sp)
if (_ptr != sp._ptr) // 防止自己给自己赋值,注意不能比较this,类似s1 = s2; 再来一次s1 = s2;
{ // 比较_pCount也行
//if (--(*_pCount) == 0) // 防止产生内存泄漏,和析构一样,写成一个函数
//{
// delete _ptr;
// delete _pCount;
//}
Release();
_ptr = sp._ptr;
_pCount = sp._pCount;
_pMtx->lock();
(*_pCount)++;
_pMtx->unlock();
}
return *this;
}
void Release() // 防止产生内存泄漏,和析构一样,写成一个函数
{
bool flag = false;
_pMtx->lock();
if (--(*_pCount) == 0)
{
delete _ptr;
delete _pCount;
flag = true;
}
_pMtx->unlock();
if (flag)
{
delete _pMtx; // new出来的,引用计数为0时要delete
}
}
~shared_ptr()
{
Release();
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
int use_count()
{
return *_pCount;
}
protected:
T* _ptr;
int* _pCount;// 引用计数,有多线程安全问题,学了linux再讲,不能用静态成员
mutex* _pMtx;
};
}
int main()
{
rtx::shared_ptr<double> sp1(new double(7.77));
rtx::shared_ptr<double> sp2(sp1);
mutex mtx;
vector<thread> v(7);
int n = 100000;
for (auto& t : v)
{
t = thread([&](){
for (size_t i = 0; i < n; ++i)
{
// 拷贝是线程安全的
rtx::shared_ptr<double> sp(sp1);
// 访问资源不是
mtx.lock();
(*sp)++;
mtx.unlock();
}
});
}
for (auto& t : v)
{
t.join();
}
cout << *sp1 << endl;
cout << sp1.use_count() << endl;
return 0;
}
3. 单例模式线程安全
单例模式复习:
从C语言到C++_37(特殊类设计和C++类型转换)单例模式-CSDN博客
3.1 懒汉模式线程安全问题
在C++11之后饿汉模式是没有线程安全问题的(做了相关补丁),因为单例对象是在main函数之前就实例化的,而多线程都是在main函数里面启动的。
但是懒汉模式是存在线程安全问题的,当多个线程使用到单例对象时候,在使用GetInstance()获取对象时,用因为调度问题出现误判,导致new多个单例对象。
这里给懒汉模式加个锁:(这里在getInstance这样加锁有没有什么问题?)
此时,每个调用GetInstance()的线程都需要申请锁然后释放锁,对锁的操作也是有开销的,会有效率上的损失。
单例模式在单例一经创建以后就不会再创建了,无论多少线程在访问已经创建的单例对象时都不会再创建,线程就已经安全了。所以在单例对象创建以后,根本没有必要再去申请锁和释放锁。
如果把加锁放在 if 里面呢?这样是不行的,因为第二次线程来的时候单例对象已经不是空的了,所以锁就白加了。
此时就要双检查加锁:
3.2 懒汉模式最终代码
class Singleton
{
public:
static Singleton* GetInstance()
{
// 双检查加锁
if (m_pInstance == nullptr) // 保护第一次后,后续不需要加锁
{
unique_lock<mutex> lock(_mtx); // 加锁,防止new抛异常就用unique_lock
if (m_pInstance == nullptr) // 保护第一次时,线程安全
{
m_pInstance = new Singleton;
}
}
return m_pInstance;
}
private:
Singleton() // 构造函数
{}
Singleton(const Singleton& s) = delete; // 禁止拷贝
Singleton& operator=(const Singleton& s) = delete; // 禁止赋值
// 静态单例对象指针
static Singleton* m_pInstance; // 单例对象指针
static mutex _mtx;
};
Singleton* Singleton::m_pInstance = nullptr; // 初始化为空
mutex Singleton::_mtx;
int main()
{
Singleton* ps = Singleton::GetInstance();//获取单例对象
return 0;
}
成功运行。
3.3 懒汉模式的另一种写法
放个代码:
class Singleton
{
public:
static Singleton* GetInstance()
{
// 局部的静态对象,第一次调用时初始化
// 在C++11之前是不能保证线程安全的
// C++11之前局部静态对象的构造函数调用初始化并不能保证线程安全的原子性。
// C++11的时候修复了这个问题,所以这种写法,只能在支持C++11以后的编译器上使用
static Singleton _s;
return &_s;
}
private:
// 构造函数私有
Singleton()
{};
Singleton(Singleton const&) = delete;
Singleton& operator=(Singleton const&) = delete;
};
int main()
{
Singleton::GetInstance();
return 0;
}
C++11之前局部静态对象的构造函数调用初始化并不能保证线程安全的原子性。
C++11的时候修复了这个问题,所以这种写法,只能在支持C++11以后的编译器上使用。
本篇完。
应该算是本专栏的最后一篇了,泪目泪目。道祖且长,行则将至,想再深入学习C++以后就靠自己拓展了。后一部分就是网络和Linux网络的内容了。