thread类简单介绍
get_id()
#include <iostream>
#include <thread>
using namespace std;
int main()
{
thread t1;
cout << t1.get_id() << endl;
return 0;
}
另外,由于我这里没有传参,所以这是一个空线程,它不会启动的。除非进行移动赋值。
// vs下查看
typedef struct
{ /* thread identifier for Win32 */
void *_Hnd; /* Win32 HANDLE */
unsigned int _Id;
} _Thrd_imp_t;
在C++11,线程可以执行的对象有四种:
1.函数指针。
2.仿函数。
3.lambda表达式
4.包装器。
简单拿lambda表达式为例
#include <iostream>
#include <thread>
using namespace std;
int main()
{
thread t1([] {cout << "hello world" << endl;});
cout << t1.get_id() << endl;
t1.join();
return 0;
}
不过记得要进行join,避免内存泄露。
另外我们还可以给将要执行的函数传参数
C++11这里,thread的构造函数还有一个参数包,那么就代表我们可以传任意数量的参数。
不过这里有一个小坑,比如当我们用函数指针传参如果是引用的话,要在对应的位置加上ref(要传的参数),不然会导致编译报错。因为虽然我们以为我们传的是引用,但是图中要经过thread的类的构造函数,然后将这个引用的属性给去了,取而代之的是一个临时变量传过去,然后就因为类型不匹配而报错,如下
那么我们只要给出问题的参数加上ref,表示要保留其引用属性,就不会出问题了。
另外,thread类是防拷贝的,所以不允许拷贝构造和赋值重载,但是可以移动构造和移动赋值重载。
可以通过joinable()来判断线程是否有效,比如以下情况线程无效:
1.采用无参的构造函数创建的线程对象。
2.线程对象的状态已经转移给其他线程对象了(通过移动构造或移动赋值)。
3.线程已经调用join或者detach了。
线程的原子性操作
使用多线程能提高程序效率,但是也会带来线程安全问题,C++11的多线程库中,除了刚刚介绍的thread类,还有很多,比如mutex,还有condition_variable
使用mutex也要包含<mutex>这个头文件。
有一个变量 int sum。我们进行sum++,虽然对于C++来说这只是一条语句,但是它的操作并不是原子的,如果以汇编的角度来看,它这里是三条语句。
我们可以通过加锁的方式,来保证修改共享数据时的安全性,因为其他线程是阻塞等待的,那么就会影响程序整体的效率, 并且还有可能造成死锁。
锁的简单使用
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
mutex mut;
unsigned long sum = 0;
void func(int& n)
{
for (int i = 0; i < n; ++i)
{
mut.lock();
++sum;
mut.unlock();
}
}
int main()
{
//thread t1([] {cout << "hello world" << endl;});
int n;
cin >> n;
thread t1(func, ref(n));
thread t2(func, ref(n));
//cout << t1.get_id() << endl;
t1.join();
t2.join();
cout << "sum: " << sum << endl;
return 0;
}
但就如之前所说,效率低,因此C++11引入了原子操作,atomic
简单使用
#include <atomic>
using namespace std;
mutex mut;
atomic_long sum{ 0 };
void func(int& n)
{
for (int i = 0; i < n; ++i)
{
++sum;
}
}
这样就能保证sum++是原子性的。
#include <atomic>
int main()
{
atomic<int> a1(0);
//atomic<int> a2(a1); // 编译失败
atomic<int> a2(0);
//a2 = a1; // 编译失败
return 0;
}
lock_guard和unique_lock,mutex库
刚刚展示了使用atomic实现了对某一个变量的加减实现原子性,但是很多情况下,我们不仅仅是想保护一个变量,而是一段代码,那这个时候还是要用到锁的,但是是RAII风格的锁,也就是智能指针风格的锁(毕竟都是C++11了嘛)。
不过在这里可以提一下为什么要用RAII风格的锁,因为原来的锁(C++98),会有不能避免的会触发死锁的场景。
因为C++11引入了异常,虽然异常可以被捕获,但是会直接跳出出去,导致还没来及释放锁,那么就会造成死锁问题。也就是在锁内的范围抛了异常。
不过先来了解一下mutex库吧
1.mutex
std::mutex,最常见的锁。
最常见的三个函数
lock() : 上锁,锁住互斥量,如果已经被申请,那么会阻塞等待。
unlock(): 解锁,释放互斥量
try_lock(): 申请锁,但是如果该锁已经被申请了,那么也不会阻塞。
2.recursive_mutex
std::recursive_mutex,主要是应用在递归需要锁的场景中。
3.timed_mutex
std::timed_mutex,
它的特点是try_lock_for()和try_lock_until()
try_lock_for() : 跟try_lock()有点像,try_lock()是如果没有申请到锁,那么直接返回false;try_lock_for()会给定一个时间范围,如果在该时间范围内没有申请到锁,那么先是会阻塞,超过了时间范围内还没有申请到锁的话就会返回false。
try_lock_until() :接受一个时间点为参数,如果在时间点之前没有申请到锁则阻塞,超过了还没有就返回false。
4.recursive_timed_mutex
这个很好理解,就是递归 + timed_mutex 合体。
接下来正式介绍两把锁
5.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;
};
另外,explicit
是 C++ 中的一个关键字。它主要用于类构造函数,以阻止不应该发生的隐式类型转换。
示例:
class Foo {
public:
explicit Foo(int x) { /* ... */ }
};
void someFunction(Foo f) { /* ... */ }
int main() {
Foo f(42); // 正确:显式调用构造函数
someFunction(Foo(42)); // 正确:显式调用构造函数
someFunction(42); // 错误:因为构造函数是explicit的,所以不能进行隐式转换
return 0;
}
在上面的例子中,尝试隐式地将整数 42
转换为 Foo
对象会导致编译错误,因为 Foo
的构造函数被声明为 explicit
。如果你试图进行这样的隐式转换,编译器会给出错误消息。
lock_guard简单使用
#include <mutex>
#include <atomic>
using namespace std;
mutex mut;
atomic_long sum{ 0 };
void func(int& n)
{
for (int i = 0; i < n; ++i)
{
lock_guard<mutex> mu(mut);
++sum;
}
}
但是因为lock_guard过于简单而太单一,用户没有办法对锁进行控制,因此就有unique_lock。
6.unique_lock
unique_lock也是采用了RAII的方式对锁进行了封装。unique_lock的使用很灵活,跟mutex很像,但是又保证了其安全性。
并且它支持移动赋值,和交换,owns_lock可以返回当前对象是否上了锁。
简单示例
std::mutex mtx; // 全局互斥量
void print_block(int n, char c) {
std::unique_lock<std::mutex> lock(mtx); // 构造时锁定互斥量
lock.unlock(); // 手动解锁
// ... 在这里可以执行一些不需要互斥量保护的代码 ...
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时操作
}
面试题:支持两个线程交替打印,一个打印奇数,一个打印偶数
因为是多线程的,就算我们上锁了,也不能保证是打印奇数的线程先启动,还是打印偶数的线程先启动。假设我们要求先打印奇数(假设是t1线程),但是即便是把调用t1线程的代码写在前面,t2线程写在后面,t2线程也依旧先启动该如何呢?
首先要加锁是肯定的,因为我们要对变量进行++(假设是int x), 但是如果要保证交替打印,还需要线程同步了,在Linux的线程部分我们学过,这次再使用一下C++封装的一个类
std::condition_variable
是 C++ 标准库中的一个类,用于在多个线程之间同步。它通常与 std::mutex
和 std::unique_lock
一起使用,以实现线程之间的条件等待和通知。
比如让一个线程等待
当然还需要控制一个线程唤醒其他线程,
第一个是唤醒在等待队列队尾的一个线程,第二个是唤醒等待队列中的所有线程。
注意:如果一个线程进入等待了,那么它会立即释放它申请到的锁;如果一个线程被唤醒了,那么它会立刻申请锁,这个操作是原子的。
所以我们可以通过加锁和控制线程同步来完成交替打印,代码:
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>
#include <random>
using namespace std;
int main()
{
mutex mut;
int x = 1;
int n = 500;
condition_variable cv;
srand(time(nullptr));
thread t1([&]() {
for (int i = 0; i < n; ++i)
{
unique_lock<mutex> lock(mut);
while (x % 2 == 0) // 使用while是万分保险的,打印奇数
cv.wait(lock);
cout << "thread1 : " << x << endl;
++x;
cv.notify_one();
}
});
thread t2([&]() {
for (int i = 0; i < n; ++i)
{
unique_lock<mutex> lock(mut);
while (x % 2 == 1) // 使用while是万分保险的,打印偶数
cv.wait(lock);
cout << "thread2 : " << x << endl;
++x;
cv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}
自旋锁
与阻塞锁不同,自旋锁在申请锁的时候,如果该锁如果已经被申请了,那么该线程不会阻塞,而是采用循环的方式不断申请锁,直到成功为止。
自旋锁适用于并发度不是特别高,且临界区较为短小的场景。
在这种情况下,利用自旋锁可以避免线程切换的开销,从而提高效率。然而,如果临界区较大,或者持有锁的线程长时间不释放锁,那么等待获取锁的线程会不断自旋,导致CPU资源的浪费。
阻塞锁在获取锁时,如果锁已经被占用,线程会进入阻塞状态,直到锁被释放并唤醒。而自旋锁则通过循环等待的方式,不断尝试获取锁。
其实mutex里面有一个try_lock(),它也是不阻塞的,如果成功了返回true,失败了返回false,可以这样模拟自旋锁
int main()
{
int x;
mutex mtx;
thread t1([&]() {
while (!mtx.try_lock())
;
++x;
mtx.lock();
});
t1.join();
return 0;
}
这种方式很占用CPU。,因此可以进行改良
while (!mtx.try_lock())
this_thread::yield();
这个this_thread::yield();也就是不要让它那么频繁的循环申请,可以适当的把时间片让一让。
总之,自旋锁与阻塞锁相比,自旋锁不会使线程进入阻塞状态等待锁释放。阻塞锁在获取锁时,如果锁已经被占用,线程会进入阻塞状态,直到锁被释放并唤醒。而自旋锁则通过循环等待的方式,不断尝试获取锁。
atomic
C++11加入了atomic这个类,它可以让++这样的操作变成原子性的
一般建议对内置类型用,如果是自定义类型,也不是不可以,但是如果这个类比较大(临界区大了),还不如加锁。因为它的原理类似自旋锁。
atomic类中还有很多的方法
除了常见的加减操作,还有一个load方法,用法如下
int main()
{
atomic<int> x;
mutex mtx;
thread t1([&]() {
++x;
});
t1.join();
printf("%d\n", x.load());
return 0;
}
当我们要打印的时候,要加上load,不然编译会报错。
这种东西的底层原理其实是 CAS---无锁编程。
简单说下无锁编程 CAS
深入了解可以看陈浩大佬的博客
无锁队列的实现 | 酷 壳 - CoolShellhttps://coolshell.cn/articles/8239.html
引用 伪代码
int compare_and_swap (int* reg, int oldval, int newval)
{
int old_reg_val = *reg;
if (old_reg_val == oldval) {
*reg = newval;
}
return old_reg_val;
}
其中这个reg是一个指针,oldval代表的是当前我们真准备修改的值,newval是目标修改的值。那么无锁编程的原理就是,如果我们要对某个值进行++,如果加到一半该线程被切走了,然后别的线程加完了后切回来了,通过解引用指针得到的值与oldval进行对比,如果发现不相等,就说明其他线程已经修改了这个值,那么就不进行++了,而是修改了oldval了值,也就是oldval = *reg,然后再往后执行++。
总之CAS就是先比较,先比较跟旧值是否一样,如果一样再做加减的修改,如果不一样,先让旧值等于当前的值之后,再进行加减。
在C++11之后,给了接口
虽然我们可以直接用现成的atomic,但是我们也需要了解其原理
简单看看一个无锁队列
比如要往队列里面插入一个结点,多线程的方式
伪代码
EnQueue(Q, data) //进队列
{
//准备新加入的结点数据
n = new node();
n->value = data;
n->next = NULL;
do {
p = Q->tail; //取链表尾指针的快照
} while( CAS(p->next, NULL, n) != TRUE);
//while条件注释:如果没有把结点链在尾指针上,再试
CAS(Q->tail, p, n); //置尾结点 tail = n;
}
核心就是这里的do while循环,我们尾插结点,一般tail指针都指向NULL,p是尾结点,但是如果此时其他线程插入了一个结点,那么这里面的tail虽然还是NULL,但是 p->next已经不是NULL了,那么改变p的指向,使其重新指向链表的尾结点。
模拟atomic的++操作的原理(CAS)
int main()
{
int n =20000;
atomic<size_t> x = 0;
thread t1([&]() {
for (int i = 0; i < n; ++i)
{
size_t oldval, newval;
do
{
oldval = x;
newval = oldval + 1;
} while (!atomic_compare_exchange_weak(&x, &oldval, newval));
}
});
thread t2([&]() {
for (int i = 0; i < n; ++i)
{
size_t oldval, newval;
do
{
oldval = x;
newval = oldval + 1;
} while (!atomic_compare_exchange_weak(&x, &oldval, newval));
}
});
t1.join();
t2.join();
cout << x << endl;
return 0;
}
一些周边问题
关于shard_ptr
关于shard_ptr(只能指针),它本身是线程安全的,但是使用它的代码可能不是线程安全的。比如我们都知道shard_ptr里面有一个引用计数,每当shard_ptr进行了copy或者是指向了同一个资源的时候,这个引用计数就要++,但是这个++的操作不是原子的,我们需要额外的同步机制来保证线程安全,如果因为线程安全的问题导致引用计数不对,那么实际在析构的时候就会导致程序直接崩溃。
另外操作shard_ptr所指向的资源的行为也不是线程安全的,也还是要通过加锁来保证线程安全。
关于懒汉模式
作为单例模式的具体实现方式,饿汉模式和懒汉模式我们已经了解了。
其中饿汉模式是不会存在线程安全的问题的,因为程序已启动它就创建了。但是懒汉模式是存在线程安全的,当多个线程同时调用申请创建对象的时候就有可能创建多个对象。
一般问题都会出在这里
当然这里加个锁就能轻松解决,并且可以通过双检查的方式既保证安全还保证效率
但是自从C++11之后(主要是编译器支持),线程安全的单例懒汉模式非常非常的简单如下
class A
{
public:
static A& GetInstance()
{
static A a;
return a;
}
int b = 1;
private:
A()
{
cout << " A " << endl;
b++;
}
// 防拷贝
A(const A&) = delete;
A& operator=(const A&) = delete;
};
int main()
{
cout << A::GetInstance().b << endl;
cout << A::GetInstance().b << endl;
return 0;
}
乍一看可能觉得这是饿汉模式,但是它确实是懒汉模式。
注意!!局部的静态对象,是在第一次调用的时候初始化!
那么它是线程安全的吗?我们在构造函数里面对b进行了++。
在C++11之前,它不是线程安全的。但是在C++11之后,它是线程安全的。
C++11之后,它是可以保证局部静态对象的初始化是线程安全的,只初始化一次!
所以C++11之后,写懒汉模式就可以这样写,是最简单的写法,但是C++11之前就不可以。
可以理解为static A a;变成了原子操作。
结果