本篇博客介绍: 主要介绍C++中的一些线程操作以及线程库
C++进阶 多线程相关
- 为什么要有线程库
- 线程库介绍
- 线程库常见的接口
- 构造线程对象
- 获取线程id
- join和deteach
- mutex库
- 原子操作相关
- 条件变量库
- 总结
为什么要有线程库
我们在Linux中写多线程的时候使用的是Linux下提供的多线程的一套api
但是如果我们的运行环境变成了windows呢 windows提供的多线程api肯定和Linux不同
也就是说 我的代码可移植性很差 这个时候如果有一个语言层面的库就能解决移植性的问题了
而在C++11中最重要的特性就是对线程进行支持了 使得C++在并行编程时不需要依赖第三方库 而且在原子操作中还引入了原子类的概念 要使用标准库中的线程 必须包含thread头文件
线程库介绍
线程库常见的接口
成员函数 | 用处 |
---|---|
thread() | 构造一个线程对象,没有关联任何线程函数,即没有启动任何线程 |
get_id() | 获取线程id |
jionable() | 线程是否还在执行,joinable代表的是一个正在执行中的线程。 |
jion() | 该函数调用后会阻塞住线程,当该线程结束后,主线程继续执行 |
detach() | 在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离的线程变为后台线程,创建的线程的"死活"就与主线程无关 |
构造线程对象
方式一 : 无参构造
函数如下
thread();
这是一个无参构造 所以说创建出来之后并没有任何的任务与其相关联 如果我们需要使用移动构造赋予其一个任务
代码表示如下
void func(int n)
{
for (int i = 0; i < n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t1;
t1 = thread(func, 10);
t1.join();
return 0;
}
方式二: 带参构造
函数如下
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
此时我们只需要省略无参构造t1的过程 直接使用构造函数即可 代码表示如下
void func(int n)
{
for (int i = 0; i < n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t1(func, 10);
t1.join();
return 0;
}
方式三 拷贝构造
在C++中的thread库中 我们禁止了拷贝构造 在这里特地强调下
方式四 移动构造
函数如下
thread (thread&& x) noexcept;
使用方式和第一种无参构造很类似 代码表示如下
void func(int n)
{
for (int i = 0; i < n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t1 = thread(func, 10);
t1.join();
return 0;
}
获取线程id
函数原型如下
this_thread::get_id()
我们可以使用该函数来获取线程的id 代码和运行结果如下
void func(int n)
{
cout << this_thread::get_id() << endl;
}
int main()
{
thread t1 = thread(func, 10);
t1.join();
return 0;
}
join和deteach
thread库中提供给我们这两个函数的用途主要是让我们去回收我们的线程资源
join
当我们调用 join()
函数的时候主线程会被阻塞
当新线程终止的时候主线程回去回收对应的资源 代码和运行结果如下
void func(int n)
{
cout << this_thread::get_id() << endl;
}
int main()
{
thread t1 = thread(func, 10);
t1.join();
return 0;
}
注意点:
- join的作用是回收资源 如果说我们连续对于一个地方回收两次资源则会造成程序崩溃
deteach
当我们调用 deteach()
函数的时候 就相当于主线程和新线程之间没有关系了
各跑各的
代码和示例如下
void func(int n)
{
cout << this_thread::get_id() << endl;
}
int main()
{
thread t1 = thread(func, 10);
t1.join();
Sleep(1);
return 0;
}
注意点:
- 我们在主线程的代码中如果不加sleep(1) 那么有可能新线程还没跑起来 整个进程就运行结束了
mutex库
不加锁会出现的问题
我们写出下面的代码 : 创建两个线程 让这两个线程执行同一个任务 打印0~99的数字
void func(int n)
{
for (int i = 0; i < n; i++)
{
cout << this_thread::get_id() << " : " << i << endl;
}
}
int main()
{
thread t1 = thread(func, 100);
thread t2 = thread(func, 100);
t1.join();
t2.join();
return 0;
}
我们会发现打印的时候出现这种情况
这是因为我们没有给线程加锁 在一个线程运行到一半的时候时间到了 切换到另一个线程的输出
解决的方式就是通过加锁
我们首先在定义一个锁 mtx
mutex mtx;
并且在任务开始之前加锁 任务开始之后解锁
mtx.lock();
for (int i = 0; i < n; i++)
{
cout << this_thread::get_id() << " : " << i << endl;
}
mtx.unlock();
线程运行就不会出现上面的问题了
C++线程库的引用问题
在C++的线程库当中 我们不能直接使用引用传递参数 否则一些编译器会报错 (博主使用的vs2022) 一些编译器虽然编译通过了 但是没有引用的效果 (我们老师使用vs2019出现上述效果)
如果说我们要往线程调用函数中传引用 则我们需要使用到这样的一个函数
ref(value)
代码和表示结果如下
void func(int n , int& x)
{
for (int i = 0; i < n; i++)
{
mtx.lock();
cout << this_thread::get_id() << " : " << i << endl;
x++;
mtx.unlock();
}
}
int main()
{
int x = 0;
thread t1 = thread(func, 10, ref(x));
thread t2 = thread(func, 10, ref(x));
t1.join();
t2.join();
cout << x << endl;
return 0;
}
当然 我们使用全局锁是很不安全的 并且会污染命名空间 所以说最好我们把锁也定义在main函数当中 并且以参数的形式 引用传递给函数
注意!我们不能使用传值的形式传递锁 因为它的拷贝构造函数被禁用了
演示结果如下
函数指针替换
我们在线程传参的时候不光可以传函数指针 还可以传递仿函数和lamabda表达式(底层就是仿函数)等等
有关于lamadba表达式的内容大家可以参考我的这篇博客
lamabda表达式
原子操作相关
关于原子性的相关问题 我在Linux线程互斥中详细介绍了 大家可以参考我的这篇博客
Linux线程互斥
如果大家阅读完了上面一篇博客之后就会知道 x++;
这并不是一个原子操作
要保证我们一个操作是原子的 除了加锁之外我们还可以使用C++提供的一个原子类
它的类名叫做 atomic
我们可以这么定义一个变量
atomic<int> x
此时我们使用 x++
就是一个原子操作了 也就不需要互斥锁了
为什么我们使用atomic之后x++就变成原子操作了呢?
这里其实和我们mysql当中的事务很类似
具体可以参考我的这篇博客
mysql事务
它将x++底层的三条汇编语言封装成了一个整体 要么成功 要么失败 (失败之后就回滚)
靠着事务保证了原子性
注意:
- 由于我们要保证原子性操作的资源一般是临界资源 所以说是不允许拷贝的 所以说atomic类中禁用了拷贝构造 移动构造等函数
条件变量库
学习到这个阶段我们应该有了一定自主学习的能力了
我们这里只介绍几个常用的函数 如果大家想深入学习以后可以直接在cspp网站上搜索函数学习
现在要求我们实现两个线程交替打印1-100
尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增。
我们尝试使用上面学过的知识去做这道题
代码和表示结果如下
void func(mutex& mtx)
{
for (int i = 0; i < 100; i+=2)
{
mtx.lock();
cout << this_thread::get_id() << " : " << i << endl;
mtx.unlock();
}
}
void func2(mutex& mtx)
{
for (int i = 1; i < 100; i += 2)
{
mtx.lock();
cout << this_thread::get_id() << " : " << i << endl;
mtx.unlock();
}
}
int main()
{
mutex mtx;
thread t1 = thread(func ,ref(mtx));
thread t2 = thread(func2, ref(mtx));
t1.join();
t2.join();
return 0;
}
我们发现 虽然我们能够让两个线程分别打印奇数和偶数 但是却不能让它们依次执行
在解决了原子性之后就该我们的条件变量库上场了
我们一般会这样子定义一个条件变量
condition_variable cv;
然后我们用两个函数来使用它
template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);
参数说明:
- 第一个参数是一个互斥锁 我们使用之前定义的锁构造一个就可以
- 第二个参数是一个函数指针 它返回一个bool类型的参数 可以被反复调用 直到返回true为止
在线程进入等待状态之后会主动释放锁
void notify_one() noexcept;
我们可以使用该函数来唤醒处于等待状态的一个线程 唤醒处于等待状态的线程之后会自动获取锁
那么使用上面学的条件变量我们就可以修改我们的代码 使它完成功能
condition_variable cv;
bool flag = true;
bool Flag()
{
return flag;
}
bool UFlag()
{
return !flag;
}
void func(mutex& mtx)
{
unique_lock<mutex> lck(mtx);
for (int i = 0; i < 100; i+=2)
{
cv.wait(lck, Flag);
cout << this_thread::get_id() << " : " << i << endl;
flag = false;
cv.notify_one();
}
}
void func2(mutex& mtx)
{
unique_lock<mutex> lck(mtx);
for (int i = 1; i < 100; i += 2)
{
cv.wait(lck, UFlag);
cout << this_thread::get_id() << " : " << i << endl;
flag = true;
cv.notify_one();
}
}
int main()
{
mutex mtx;
thread t1 = thread(func ,ref(mtx));
thread t2 = thread(func2, ref(mtx));
t1.join();
t2.join();
return 0;
}
解释下上面的代码
- 首先我们创造一个
unique_lock
对象后它会自动调用lock()函数加锁 - 当我们调用
wait()
函数的时候会自动解锁 - 当我们调用
notify_one()
函数的时候会自动加锁
此外我们可以通过控制flag的初始值来控制哪一个线程先行动
运行结果如下