在C++11之前,涉及到多线程问题,都是和平台相关的,比如Windows和Linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含
< thread >
头文件。
【C++11线程库地址】
上面这些接口是不是非常熟悉,都是我们在Linux中经常使用的套路,但是我们可以看看他的构造函数:
我们可以看见构造函数用了可变参数模板,这样我们在传递参数的时候再也不用像Linux中那样用将数据打包成一个类对象来传递,我们可以直接通过可变参数模板直接传入即可。
比如这样:
void fun(int m)
{
for (int i = 0; i < m; ++i)
cout << i << endl;
}
int main()
{
thread t1(fun, 10);
thread t2(fun, 20);
t1.join();
t2.join();
return 0;
}
打印结果:
至于为啥打印的毫无规律是由于多个线程同时抢占显示器这种临界资源而造成的,处理方式可以加锁,我们在后面会详细讲解。
同时我们注意到在thread的构造函数中删除了拷贝构造
,这其实也很好理解,因为并没有实际意义。还提供了thread的移动赋值,这个在有些场景下会很有用。
其实上面我们的方式我们也是用了函数指针的方式来实现,但是一般我们不喜欢这样用,而更加青睐于lambda
,比如下面这种用法:
int main()
{
int m, n;
cin >> m >> n;
thread t1([m, n] {
for (int i = 0; i < m; ++i)
cout << "" << i << endl;
});
thread t2([m, n] {
for (int i = 0; i < m; ++i)
cout << "" << i << endl;
});
t1.join();
t2.join();
return 0;
}
这种方式写起来看起就比较有意思了,但是假如我们想要创建100个线程来实现上面的功能,一个一个写肯定是很麻烦的,我们可以采用下面这种方式:
int main()
{
int m;
cin >> m;
vector<thread> ths(m);
for (int i = 0; i < m; ++i)
{
ths[i] = thread([] {
for (int i = 0; i < 5; ++i)
cout << this_thread::get_id()<<":" << i << endl;
});
}
for (auto& e : ths)
e.join();
return 0;
}
这种方式我们就将匿名对象的资源给移动赋值了。代码中我们还给了this_thread::get_id()
,这个又是什么鬼呢?
这个this_thread::是一个命名空间,空间中有着下面的类:
上面代码中由于对象还没有被创建出来,所以就用了全局域中的this_thread命名空间中的get_id,这个用法其实类似于Linux中的用法。另外的sleep_until和sleep_for顾名思义,sleep_until是休眠到一个具体的时间点(比如2023 6 21),而sleep_for是休眠一个具体时间段(比如200毫秒). 具体的大家可以自行下去看看样例,我这里就不再多赘述了【this_thread】
这里面还有一个比较有意思的:yield
作用是让出当前线程的时间片.
在讲解yield之前我们再来补充一个小知识点:无锁编程
就拿我们刚开始讲的栗子来说:两个线程并发竞争显示器这种临界资源的时候打印出的无规律数据,一般的解决方式我们可以采用加锁的方式来处理,但是我们除了这种方式外还可以采用原子操作来解决。
而要实现原子操作就必不可免的要利用CAS原子操作
,那什么是CAS呢?
CAS是Compare And Swap的缩写(也可以是Compare And Set),比较与交换,通常指的是这样一种原子操作:针对一个变量,首先比较它的内存值和某个期望值是否相同,如果相同,就给它赋一个新值。现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是 CMPXCHG 汇编指令。有了这个原子操作,我们就可以用其来实现各种无锁(lock free)的数据结构。
大家想了解更多关于CAS原子操作可以移步陈皓大佬文章【无锁队列】
大家再来看看下面这段代码:
void ThreadFunc1(int& x)
{
x += 10;
}
int main()
{
int a = 10;
thread t1(ThreadFunc1, a);
t1.join();
cout << a << endl;
return 0;
}
注意上面代码中我们传入的参数加了&
,当我们编译的时候:
VS2022会直接报编译错误,但是老一点的编译器是不会报错的,在线程函数中对a修改,不会影响外部实参,因为:线程函数参数虽然是引用方式,但其实际引用的是线程栈中的拷贝。那假如我们就想修改呢?我们可以用std::ref()
函数,比如下面:
void ThreadFunc1(int& x)
{
x += 10;
}
int main()
{
int a = 10;
thread t2(ThreadFunc1, std::ref(a));
t2.join();
cout << a << endl;
return 0;
}
当我们编译运行时:
明显此时已经修改成功了。除此之外,我们甚至还可以用下面这种方式:
void ThreadFunc2(int* x)
{
*x += 10;
}
int main()
{
int a = 10;
thread t3(ThreadFunc2, &a);
t3.join();
cout << a << endl;
return 0;
}
运行结果:
这种通过地址的拷贝也是能够完成修改的。
注意:如果是类成员函数作为线程参数时,必须将this作为线程函数参数。
C++11提供的最基本的互斥量,该类的对象之间不能拷贝,也不能进行移动。mutex最常用的三个函数:
函数名 | 函数功能 |
---|---|
lock() | 上锁:锁住互斥量 |
unlock() | 解锁:释放对互斥量的所有权 |
try_lock() | 尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞 |
注意,线程函数调用lock()时,可能会发生以下三种情况:
线程函数调用try_lock()时,可能会发生以下三种情况:
我们来看看下面的代码:
long long tickets;
mutex mtu;
int main()
{
thread t1([] {
for (int i = 0; i < 100000; ++i)
{
tickets++;
}
});
thread t2([] {
for (int i = 0; i < 100000; ++i)
{
tickets++;
}
});
t1.join();
t2.join();
cout << tickets << endl;
return 0;
}
当我们运行时结果跟我们预想的一样,肯定会有并发打印的问题:
我们可以加锁处理:
我们这是将锁加在里面的,能不能加在外面呢?
当然也是可行的,那么哪种效率更高一些呢?在Linux中,我们知道一般加锁会选择粒度更小
的区域中加,也就是加在里面,我们可以写一个测试用例来测试下加在里面和外面的时间差异:
先试试把锁加在for循环外面:
size_t begin = clock();
thread t1([] {
mtu.lock();
for (int i = 0; i < 100000000; ++i)
{
tickets++;
}
mtu.unlock();
});
thread t2([] {
mtu.lock();
for (int i = 0; i < 100000000; ++i)
{
tickets++;
}
mtu.unlock();
});
t1.join();
t2.join();
size_t end = clock();
cout << end - begin << endl;
size_t begin = clock();
thread t1([] {
for (int i = 0; i < 10000000; ++i)
{
mtu.lock();
tickets++;
mtu.unlock();
}
});
thread t2([] {
for (int i = 0; i < 10000000; ++i)
{
mtu.lock();
tickets++;
mtu.unlock();
}
});
t1.join();
t2.join();
size_t end = clock();
cout << end - begin << endl;
我们居然惊奇的发现加锁粒度更大的操作速度居然要快,为什么呢❓
其实原因主要有下面两点:
当我们把锁加在for循环的里面时候,由于每进行一次++i操作都会申请锁和释放锁,并且当其中一个线程竞争到锁后另外一个线程会被切换挂起,可是由于每次++i的操作时间很短,所以线程间又可能被频繁的切换,而上下文的保存与切换是有代价的。
但是不是说粒度更大的加锁策略效率更高呢❓显然不是这样的,要具体情况具体分析。但是我们在Linux下一般建议是使用粒度更小的加锁策略。
第一个是我们刚刚使用了的,第二个recursive_mutex
是什么呢❓
这个是递归互斥锁 ,当我们递归调用自己的时候而造成死锁的解决方式。这是由于我们申请了锁后没有释放而继续递归自己下去再重复申请改锁而造成了死锁。
这时我们就可以使用recursive_mutex,他会在申请锁前先释放该锁,出了该函数栈帧后又会重新申请该锁。recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),除此之外,
std::recursive_mutex 的特性和 std::mutex 大致相同。
另外两个:timed_mutex和recursive_timed_mutex
是与时间有关的,比较简单。我这里大概提一下 std::timed_mutex的基本用法:
try_lock_for()
try_lock_until()
其他的大家可以自己到官网去看:【Mutex】
虽然上面加锁可以解决多线程中的问题,但是一定要控制得当,否则很容易造成死锁,那还有没有不用加锁就可以解决的方法呢❓
也就是下面我们要介绍的原子操作。
通过上面我们的讲解我们知道:锁控制不好时,可能会造成死锁,最常见的比如在锁中间代码返回,或者在锁的范围内抛异常。因此:C++11采用RAII
的方式对锁进行了封装,即lock_guard
和unique_lock
。
std::lock_gurad 是 C++11 中定义的模板类。定义如下:
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;
};
这种RAII的设计模式我们在智能指针阶段还会详细讲解,大家在这里可以先大概了解下原理。就是利用对象创建和对象销毁时调用构造函数和析构函数来帮助加锁和解锁操作。
通过上述代码可以看到,lock_guard类模板主要是通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。
lock_guard的缺陷:太单一,用户没有办法对该锁进行控制,因此C++11又提供了
unique_lock。
与lock_gard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动(move)赋值)时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。
与lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
【lock_guard】
【unique_lock】
C++11中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作,C++11引入的原子操作类型,使得线程间数据的同步变得非常高效。
【atomic】
比如之前我们抢票的代码还可以这样写:
atomic<long long> tickets;
mutex mtu;
int main()
{
thread t1([] {
for (int i = 0; i < 100000; ++i)
{
tickets++;
}
});
thread t2([] {
for (int i = 0; i < 100000; ++i)
{
tickets++;
}
});
t1.join();
t2.join();
cout << tickets << endl;
return 0;
}
我们使用原子操作不用加锁也可以正确的完成。其实原子操作的底层是用的CAS原子操作
来完成的。
在C++11中,程序员不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的
访问。更为普遍的,程序员可以使用atomic类模板,定义出需要的任意原子类型。
atmoic<T> t; // 声明一个类型为T的原子类型变量t
注意:原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
先来看看condition_variable
类中有哪些成员函数?
这个用法与Linux中基本一致,我在这里就不再多讲了。
现在假如我们要实现下面的场景,我们究竟应该怎么做?
支持两个线程交替打印,一个打印奇数,一个打印偶数。
这样实现有两个注意点:
所以我们就可以写出下面的代码:
int num = 1;
mutex mtu;
condition_variable cdv;
int main()
{
thread t1([=] {
for (;num<100;num++)
{
unique_lock<mutex> lock(mtu);
if (num % 2 == 0)
cdv.wait(lock);
cout << this_thread::get_id() << ":" << num << endl;
cdv.notify_one();
}
});
thread t2([] {
for (; num<100; num ++)
{
unique_lock<mutex> lock(mtu);
if(num%2!=0)
cdv.wait(lock);
cout << this_thread::get_id() << ":" << num << endl;
cdv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}
但是上面的代码可能会存在着问题,因为在条件判断的时候我们没有加锁,这就导致有可能其中一个线程打印出了101这样的数据,所以我们可以写一个死循环,在循环体里面判断即可。
thread t1([=] {
for (; ;num++)
{
unique_lock<mutex> lock(mtu);
if (num >= 100) break;
if (num % 2 == 0)
cdv.wait(lock);
cout << this_thread::get_id() << ":" << num << endl;
cdv.notify_one();
}
});
thread t2([] {
for (; ; num++)
{
unique_lock<mutex> lock(mtu);
if (num > 100) break;
if(num%2!=0)
cdv.wait(lock);
cout << this_thread::get_id() << ":" << num << endl;
cdv.notify_one();
}
});