当前位置: 首页 > news >正文

C++线程库的学习

目录

  • 线程库
    • thread类和pthread_create对比
    • thread简单使用
    • 线程启动函数传参的问题
    • 加锁过程中异常——死锁
    • 条件变量
      • 条件变量有三个等待方法和两个通知方法:
      • wait、wait_for、wait_until
      • notify_one、notify_all
      • 通过条件变量让两个线程交替打印基偶数
    • 原子操作
      • atomic

线程库

thread类和pthread_create对比

  • pthread_create:属于 Linux 等遵循 POSIX 标准的操作系统平台下的线程接口,是系统级的 API。如果要在
    Windows 平台上使用类似功能,需要使用 Windows 特定的线程 API(如CreateThread ),所以可移植性较差,仅适用于支持 POSIX 标准的系统。
  • std::thread:是 C++ 标准库的一部分,只要编译器支持 C++11 及以上标准,无论是在
    Linux、Windows、macOS 等不同操作系统上,都可以使用统一的接口来创建和管理线程,具有良好的跨平台性。
功能:创建一个新的线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg)
参数:thread:返回线程IDattr:设置线程的属性,attr为NULL表示使用默认属性start_routine:是个函数地址,线程启动后要执行的函数arg:传给线程启动函数的参数返回值:成功返回0;失败返回错误码

pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。所以第一个参数thread是输出型参数。
pthread_t 其实只是一个无符号长整型

缺点:arg:传给线程启动函数的参数,因为限制了只能传一个参数arg,如果线程启动函数的参数很多,应该都封装到结构体里面,把结构体对象的地址传给void*的arg。并且只能传函数指针作为线程启动的函数。

thread简单使用

在这里插入图片描述
对于C++的thread类,fn作为一个可执行对象,也就是线程启动的执行对象,可以传函数指针,仿函数,lambda,包装器。
而args可变参数包可以传不同数量的值作为线程启动执行的函数的参数

简单使用一下:
在这里插入图片描述

线程阻塞式等待:join()

外部可以t1的this指针拿到该线程的id
但是线程启动函数print没有this指针,就要通过:

在这里插入图片描述
通过this_thread的静态成员函数this_thread::get_id(),就可以拿到thread类里的id

thread不支持拷贝构造,但是支持移动构造是为什么?
没有提供线程自启动的函数,空线程创建出来以后想跑就要通过移动构造、移动赋值这种方式

void Print2(size_t n, const string& s)
{for (size_t i = 0; i < n; i++){cout << this_thread::get_id() << s << ":" << i << endl;}
}int main()
{size_t n;cin >> n;//创建n个线程执行Printvector<thread> vthd(n);size_t j = 0;for (auto& thd : vthd){// 移动赋值thd = thread(Print2, 10,  "线程" + to_string(j++));//当空线程创建出来,是不能执行的,要想让空线程执行就要通过移动赋值等操作}for (auto& thd : vthd){thd.join();}thread t1(Print2, 100,  "我是线程1");thread t2(move(t1));//因为不支持拷贝构造,thread t2(t1)是不行的t2.join();return 0;
}

创建n个线程,为什么要用容器?
循环中创建的 t 在每次次迭代结束时就会析构,析构时如果不join等待,就可能导致程序终止
因为直接循环创建,线程会串行执行(一个执行完再启动下一个),完全失去了多线程并发的意义。

for(int i=0; i<n; i++){thread t(print, 100);t.join();  // 等待当前线程执行完毕才继续下一次循环
}

总结两种使用方式:
1.带参构造,创建可执行线程
2.先创建空线程对象,通过移动构造或移动赋值,把右值线程对象转移

互斥锁
在这里插入图片描述
在这里插入图片描述

mutex互斥类
recursive_mutex递归互斥类 —— 递归函数里加锁,如果用mutex会死锁,因为第一次调用函数时,线程成功获取锁。递归调用同一函数时,线程尝试再次获取同一把锁,但是mutex规定同一线程只能获取一次锁,所以递归不能获得锁无法继续执行,外层函数也无法释放锁,形成死锁

在这里插入图片描述
正常是lock阻塞式加锁,当然也可以非阻塞式加锁try_lock

线程启动函数传参的问题

#include<mutex>
#include<thread>
#include<chrono>void Print1(size_t n, const string& s, mutex& m, int& rx)
{for (size_t i = 0; i < n; i++){m.lock();cout <<this_thread::get_id()<<s<<":" << i << endl;++rx;m.unlock();}
}int main()
{mutex mtx;int x = 0;thread t1(Print1, 10,  "t1", ref(mtx), ref(x));thread t2(Print1, 20, "t2", ref(mtx), ref(x));cout <<"线程1:" << t1.get_id() << endl;cout <<"线程2:"<< t2.get_id() << endl;t1.join();t2.join();cout << x << endl;return 0;
}

在这里插入图片描述

thread t1构造传参的时候,x不是直接给Print1函数的,是传给构造函数,然后再由构造函数实现把值传给Print1
但是在这个过程中,因为底层转接层一些麻烦的原因传给Print1的已经不是x了,而是x的拷贝,也就是传给Print1的所有参数都是右值
也就是说rx引用其实引用的是x的拷贝,所以编译编不过,x的拷贝是一个临时对象,int& rx 引用不能引用一个临时对象
用ref可以解决这个问题,可以理解为ref可以保持x左值引用的属性往下继续传递

总结:
要接收引用,传参的时候就要用ref()或者指针

或者说直接用lambda就好了,因为lambda直接捕获了,就不用关心传参

int main()
{size_t n1 = 0;size_t n2 = 0;cin >> n1 >> n2;mutex mtx;size_t x = 0;thread t1([n1, &x, &mtx]() {for (size_t i = 0; i < n1; i++){mtx.lock();++x;mtx.unlock();}}
}

加锁过程中异常——死锁

在这里插入图片描述
假设有两个线程 t1、t2 都用这段逻辑操作同一把锁 mtx。

  • t1 执行时,func() 抛异常,锁没释放,t1 因异常退出循环。
  • t2 后续执行 mtx.lock(),会一直阻塞(等 t1 释放锁,但 t1 已经无法释放)。

这种情况下就会出现死锁,要用RAII思想解决

// RAII
template<class Lock>
class LockGuard
{
public:LockGuard(Lock& lk):_lk(lk){_lk.lock();}~LockGuard(){_lk.unlock();}
private:Lock& _lk;//锁不支持拷贝,注意要是引用
};
int main()
{mutex mtx;size_t n1 = 100;size_t n2 = 100;size_t x = 0;thread t1([n1, &x, &mtx]() {try {for (size_t i = 0; i < n1; i++){//mtx.lock();//LockGuard<mutex> lg(mtx);//手搓的lock_guard<mutex> lg(mtx);//库里的++x;func();//里面出异常了//mtx.unlock();}}catch (const exception& e){cout << e.what() << endl;}});t1.join();cout << x << endl;return 0;
}

刚刚举例是手搓的简易智能指针
库里面有可以直接用
在这里插入图片描述
lock_guard只支持构造析构,只有一种解锁方式:析构
unique_lock支持更多功能,比如移动构造、可以配合时间锁使用,两种解锁方式:析构和支持手动解锁(手动加锁也行)

串行(Serial)
是 “一个接一个,按顺序执行”,同一时间只做一件事,前一件做完才能开始下一件。

比如你独自做饭:必须先买菜,再洗菜,然后切菜,最后炒菜 —— 每个步骤严格按顺序进行,前一步没结束,后一步绝对不能开始。

在程序里,就是多个任务按顺序依次执行,只有一个任务完成后,下一个任务才会开始。比如单线程程序中,代码从第一行执行到最后一行,中间不会 “跳着” 执行其他任务。

并发(Concurrency) 就像一个人同时处理多个任务,但实际上是快速交替进行。
比如你一边做饭一边听音乐 —— 你并不是真的同时在做饭和听音乐,而是注意力在两者之间快速切换,看起来像同时进行。

在程序里,就是一个 CPU 核心快速在多个线程之间切换执行,给人 “同时运行” 的感觉。

并行(Parallelism) 才是真正的 “同时进行”。
比如你和家人一起做饭 —— 你切菜,家人炒菜,两件事在同一时间实实在在地同步进行。

在程序里,就是多个 CPU 核心同时各自运行不同的线程,真正实现了 “多任务同时执行”。

简单说:
串行是 “一个任务(或线程)完全执行完,才开始下一个任务(或线程),前一个不结束,后一个不开始”
并发是 “交替执行,看起来同时”
并行是 “真正同时执行”

条件变量

假设多个线程要使用同一个打印机(共享资源),互斥锁就像打印机的 “使用许可证”。线程 A 拿到许可证(加锁)时,其他线程(B、C、D)必须排队等待。只有 A 用完并归还许可证(解锁)后,下一个线程才能拿到许可证使用打印机。

这个过程中,对打印机的操作是严格按顺序执行的(串行),但这并不意味着整个程序都是串行的:

  • 线程在等待锁的时候,可以被操作系统切换去执行其他不涉及该锁的代码(比如线程 B 在等打印机时,可以先去处理别的任务)。
  • 只有竞争同一把锁的代码段是串行的程序中其他不涉及该锁的部分仍可以并发或并行执行。

我们要说的环境变量,它可以帮助线程间协作,但并不是改变串行这个规则,而是让线程从 “盲目等待” 变成 “有条件地等待”,避免了无效的资源竞争和 CPU 浪费。

假设多个线程要使用一个共享的 “资源池”(比如任务队列):

  • 只用互斥锁:线程会像一群人围着一个门,不管里面有没有资源,都要反复推门(尝试加锁)查看。就算里面空了,大家还是会不停推门(忙等),白白消耗力气(CPU资源)。

  • 加了条件变量:线程会先看一眼资源池(加锁检查),如果没资源,就坐在旁边的椅子上(进入等待状态),释放门的控制权(自动解锁)。直到有人放进新资源后喊一声 “有新东西了”(notify),等待的线程才会起身再次去推门(重新加锁并检查)。

条件变量
在这里插入图片描述

条件变量有三个等待方法和两个通知方法:

wait、wait_for、wait_until

我们要使用std::unique_lock而非std::lock_guard管理锁,因为条件变量的wait方法需要能够暂时释放锁,而unique_lock对比lock_guard的最大区别就是能手动上锁和解锁
在这里插入图片描述

功能:使当前线程阻塞等待条件变量通知
template <class Predicate>  
void wait (unique_lock<mutex>& lck, Predicate pred);
参数:
lck: 一个被 std::unique_lock管理的已经锁定的std::mutex对象,wait 会在等待期间自动释放该锁,被唤醒时重新获取锁
pred: 一个可调用对象(如 lambda 表达式、函数指针等),用于检查等待的条件是否满足。其返回值为 bool 类型,true 表示条件满足,false 表示条件未满足
返回值:无返回值

说明:该函数会使当前线程阻塞,直到以下情况发生:
其他线程调用了同一条件变量的 notify_one () 或 notify_all () 进行通知
同时 pred 返回 true(即等待的条件满足)

对比单个参数的wait,我们可以发现单个参数的wait是不能检查等待条件是否满足的,可能存在 “虚假唤醒”(即线程被唤醒但条件并未满足)。需要用户在调用 wait() 前后自行处理条件判断逻辑。
在这里插入图片描述
也是跟wait一样,一个能检查等待条件,一个不能检查,我们主要介绍能检查的那个

功能:使当前线程阻塞一段指定时间或直到条件满足(以先发生者为准)
template <class Rep, class Period, class Predicate>       
bool wait_for (unique_lock<mutex>& lck,
const chrono::duration<Rep,Period>& rel_time, 
Predicate pred);
参数:
lck: 一个被 std::unique_lock管理的已经锁定的std::mutex对象,wait_for 会在等待期间自动释放该锁,被唤醒时重新获取锁
rel_time: 表示最大等待时间的 std::chrono::duration 类型值(如 std::chrono::seconds (5) 表示最多等待 5 秒)
pred: 一个可调用对象(如 lambda 表达式、函数指针等),用于检查等待的条件是否满足,返回值为 bool 类型(true 表示条件满足)返回值:返回 bool 类型的值:
true:在指定时间内条件满足,正常返回
false:超过指定等待时间后条件仍未满足,超时返回

说明:该函数会使当前线程阻塞,直到以下任一情况发生:

  1. 其他线程调用同一条件变量的 notify_one () 或 notify_all () 且 pred 返回 true(条件满足)
  2. 等待时间超过 rel_time 所指定的时长(超时)

在这里插入图片描述

功能:使当前线程阻塞直到指定的绝对时间点或条件满足(以先发生者为准)
template <class Clock, class Duration, class Predicate>       
bool wait_until (unique_lock<mutex>& lck,
const chrono::time_point<Clock,Duration>& abs_time,
Predicate pred);
参数:
lck: 一个被 std::unique_lock管理的已经锁定的std::mutex对象,wait_for 会在等待期间自动释放该锁,被唤醒时重新获取锁
rel_time: 表示最大等待时间的 std::chrono::duration 类型值(如 std::chrono::seconds (5) 表示最多等待 5 秒)
pred: 一个可调用对象(如 lambda 表达式、函数指针等),用于检查等待的条件是否满足,返回值为 bool 类型(true 表示条件满足)返回值:返回 bool 类型的值:
true:在指定时间内条件满足,正常返回
false:超过指定等待时间后条件仍未满足,超时返回

wait_for和wait_until的区别:

wait_for:含义:指定从当前时刻起,额外等待的时长。

  • 适用于只关心等待时长的场景。比如在一个任务调度系统中,某个任务等待其他资源准备的最长时间是 5 分钟,此时可以使用 wait_for来控制等待时间,超过 5 分钟就不再等待,继续后续逻辑。
  • 当希望设定一个相对固定的等待周期,比如每隔 10 秒检查一次某个条件是否满足时,wait_for 比较方便。

wait_until:含义:明确指出线程等待到某个具体的时刻,以系统时钟为参照。

  • 适用于对时间点有明确要求的场景。比如在一个定时任务系统中,要求线程在每天早上 8 点整执行特定操作,就可以获取 8 点整对应的std::chrono::time_point,然后使用 wait_until 让线程等待到这个时间点再执行后续操作。
  • 当与系统的时间安排、特定事件的时间节点紧密相关时,wait_until 更能满足需求

notify_one、notify_all

在这里插入图片描述

功能:通知一个正在等待该条件变量的线程
void notify_one() noexcept;
参数:无参数
返回值:无返回值

说明:

  1. 该函数用于通知至少一个正在等待该条件变量的线程。如果有多个线程在等待,系统会选择其中一个线程进行通知(具体选择哪个线程是不确定的)。
  2. 被通知的线程将从等待状态中被唤醒,并尝试重新获取互斥锁,然后继续执行后续操作。
  3. noexcept 说明该函数不会抛出异常,保证了异常安全性。
  4. 如果没有线程在等待该条件变量,调用 notify_one() 不会产生任何效果。

在这里插入图片描述

功能
std::condition_variable::notify_all() 用于通知所有正在等待该条件变量的线程。当某个线程调用这个函数时,它会唤醒当前与该条件变量关联的等待队列中所有处于阻塞状态的线程。
void notify_all() noexcept;
无参数。
无返回值。

noexcept 说明该函数不会抛出异常

notify_one怎么找到对应的wait呢?

condition_variable cv;定义了条件变量cv后
cv.wait(lock, [&]()->bool{return flag; });和
cv.notify_one();都是该cv类的成员函数
  • 当线程调用 cv.wait(…) 时,是在 “当前 cv 对象” 上等待通知;
  • 当线程调用 cv.notify_one() 时,是向 “当前 cv 对象” 的等待队列中的线程发送通知;
  • 只有等待同一个 cv 对象的线程,才可能被该 cv 对象的 notify 系列函数唤醒。

通过条件变量让两个线程交替打印基偶数

假设我们有两个线程A和B,我们希望A先打印完基数后,B随后开始打印偶数,然后A等待B打印偶数后才能继续打印基数,也就是交替打印

我们可以这样做:

  1. 创建一个条件变量和一个互斥锁。
  2. 在A线程中,我们先锁定互斥锁,然后通过wait判断现在也不应该打印基数,然后执行A线程的任务,任务完成后,我们解锁互斥锁,并通知条件变量。
  3. 在B线程中,我们也先锁定互斥锁,然后让B线程也通过wait判断现在也不应该打印偶数。当A线程通知条件变量后,B线程就会被唤醒,然后执行B线程的任务。

那我们会有一个问题,如果A线程要等B打完偶数才能继续打印基数,那就要等B线程通知条件变量,而B线程要等A打完基数才能继续打印偶数。那么两个都要等对方,那谁先?肯定是A先,如何能让A先打印?

用flag,wait是在等其他线程通知没错,但是怎么判断等没等到,就要依靠pred这个可调用对象,如果它返回了true,即使没有通知,那我也可以说其他线程通知到了
所以我可以先把flag=true
A是靠flag == true判断的
B是靠 flag == false 判断的

就算B先调度了,B发现flag是true,就会等
A发现flag是true就会打印1,然后把flag=false,告诉B可以打印2了

#include <thread>
#include <mutex>
#include <condition_variable>
void two_thread_print()
{std::mutex mtx;condition_variable c;int n = 100;bool flag = true;thread t1([&](){int i = 1;while (i < n){unique_lock<mutex> lock(mtx);//循环结束,调用析构就解锁了c.wait(lock, [&]()->bool{return flag; });cout << i << endl;flag = false;i += 2; // 奇数c.notify_one();}});thread t2([&](){int j = 2;while (j < n){unique_lock<mutex> lock(mtx);c.wait(lock, [&]()->bool{return !flag; });cout << j << endl;j += 2; // 偶数flag = true;c.notify_one();}});t1.join();t2.join();
}
int main()
{two_thread_print();return 0;
}

如果你不想写lambda
也可以

if(flag==true)c.wait(lock);

原子操作

原子操作:即不可被中断的一个或一系列操作

在这里插入图片描述
两个线程都执行++i这个操作,可以说是原子操作吗?
不行,因为++i这个操作实际上分成了三条指令,可能完成了两句指令就线程切换了
比如mov,add之后eax中的值从0加到了1,但是第一个线程没有执行最后一句,也就是没有把eax放回去,就被切换走了。这时候第二个线程来看,取到的是没加的eax,操作完让eax加到1,放回去。第一个线程切换回来了,继续上次没跑完的第三句指令,把=1的eax放回去。
结果就导致,两个进程都++i,但是只加了一次的结果

一般情况下:如果是只有一条汇编指令,可以说是原子操作,因为执行一次指令,不可能说执行半句就线程切换。

说一般情况下,是因为指令分为精简指令级和复杂指令级,了解一下
精简指令集(RISC)

  • 指令数量少且简单,每条条指令通常只完成一项基本操作(如取数、运算、存数等)
  • 指令长度固定,执行时间短且一致

复杂指令集(CISC)

  • 指令数量多且功能复杂,一条指令可完成复杂操作(如一次完成读取 - 运算 - 存储)
  • 指令长度不固定,执行时间差异大

在这里插入图片描述
锁的操作并不是说是原子操作,不可被打断,而是我++x分为三条汇编语句时,我执行了两条语句被进程切换了,另外的那个进程没有锁就不能++x,只能等我把所有语句执行完解锁。

如果说只是为了一个小小的x资源的++,就用锁不断的阻塞线程的操作,效率很低。这种情况是不适合用互斥锁的,会导致线程频繁堵塞,适合用自旋锁,但是C++库里的锁没有提供自旋锁,得自己造轮子,有兴趣的可以去搜一搜。

还有一种方式解决这个问题:

atomic

C++11引入了atomic类模版,加入了原子操作类型,让这种简单的操作变得高效。

在这里插入图片描述

头文件
#include <atomic>

比如刚刚的size_t x=0,就可以改为atomic<size_t> x=0
不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的访问。

atomic<size_t> x = 0;//size_t x = 0;++x;thread t1([&]() {for (size_t i = 0; i < n1; i++){++x;}});

在这里插入图片描述

直接像内置类型一样定义也是可以的,根据上面的表用即可
atomic_long sum{ 0 }
http://www.dtcms.com/a/319705.html

相关文章:

  • 从Centos 9 Stream 版本切换到 Rocky Linux 9
  • MongoDB数据存储界的瑞士军刀:cpolar内网穿透实验室第513号挑战
  • IDEA-Research推出的一系列检测、分割模型:从DINO(改进版DETR)、Grounding Dino、DINO-X到Grounded SAM2
  • 串联所有单词的子串-leetcode
  • 计算机基础·linux系统
  • Linux线程学习
  • pytorch学习笔记-最大池化maxpooling的使用、搭建多层网络并验证、sequential的使用
  • golang的面向对象编程,struct的使用
  • 2.8 逻辑符号
  • Linux怎么查看时区信息?(Linux时区)(tzselect)
  • Java中接口与抽象类
  • 处理失败: module ‘fitz‘ has no attribute ‘open‘
  • 传统防火墙与下一代防火墙
  • 华为 2025 校招目标院校
  • 【2025最新】在 macOS 上构建 Flutter iOS 应用
  • 嵌入式学习---在 Linux 下的 C 语言学习 Day10
  • 可执行文件的生成与加载执行
  • 超高车辆如何影响城市立交隧道安全?预警系统如何应对?
  • [论文阅读] 软件工程 | 软件工程中的同理心:表现、动机与影响因素解析
  • oracle 11G安装大概率遇到问题
  • 大文件断点续传(vue+springboot+mysql)
  • Failed to restart docker.service: Unit docker.service is masked.
  • PostgreSQL 数据库 设置90天密码过期时间的完整方案
  • 读取了错误数据导致STM32 单片机Hard Fault
  • 智能升级革命:Deepoc具身模型开发板如何让传统除草机器人拥有“认知大脑”
  • 分布式微服务--GateWay(过滤器及使用Gateway注意点)
  • 翻译模型(TM):基于短语的统计翻译模型(PBSMT)的构建
  • C++语法与面向对象特性(2)
  • PyTorch如何实现婴儿哭声检测和识别
  • 目标检测数据集 - 自动驾驶场景道路异常检测数据集下载「包含VOC、COCO、YOLO三种格式」