C++多线程编程 (1)
对于单处理器系统,处理器在一个单元时间内只能执行一个进程,操作系统系统以极快的速度在多个进程之间进行切换,营造了一种多个进程同时运行的假象。
1. 一些基本概念:
c++中的静态库与动态库:
1. 静态库:*.lib 是指一些已经编译过的代码,在程序运行之前,静态库在编译的时候被放入到可执行文件中。
静态库在链接阶段,会将汇编生成的目标文件.o与引用到的库一起链接打包到可执行文件中,对应的链接方式称为静态链接。静态库与汇编生成的目标文件(.o文件)一起链接为可执行文件,那么静态库必定跟.o文件格式相似。其实一个静态库可以简单看成是一组目标文件(.o/.obj文件)的归档集合,即很多目标文件经过压缩打包后形成的一个文件。
静态库有两个重大缺点:
1)空间浪费
2)静态链接对程序的更新、部署和发布会带来很多麻烦。一旦程序中有任何模块更新,整个程序就要重新链接,发布给用户。
2. 动态库:*.dll 与静态库不同的是,动态库在程序开始执行后才开始进行链接,可以将许多程序都会用到的函数放入到动态库中。在这样就不必在每个程序中都包含这些函数了,只需在运行时链接一个动态库就可以了。
动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。动态库在程序运行是才被载入,也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可,增量更新。
动态库特点:
1)代码共享,所有引用该动态库的可执行目标文件共享一份相同的代码与数据。
2)程序升级方便,应用程序不需要重新链接新版本的动态库来升级,理论上只要简单地将旧的目标文件覆盖掉。
3)在运行时可以动态地选择加载各种应用程序模块
进程和线程的概念:
在多任务系统中,CPU以极快的速度在不同进程之间切换,每个进程只运行几毫秒,从严格意义上来说,cpu在任何时刻只运行一个进程,只不过它的快速切换营造了并行处理的假象。每个进程都有自己的虚拟地址空间和控制线程。线程是操作系统调度器分配处理器时间的基础单元,可以将系统看作运行在准并行环境中的进程集合,在进程间快速反复的切换叫做多任务处理。通常,在一个进程的地址空间中要执行多个线程。
感觉这篇博客对于线程和进程的解释很清晰:进程和线程的主要区别(C++多线程编程实战这本书完全连这个概念都没有讲清楚):具体解释如下
区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
所处环境:在操作系统中能同时运行多个进程(程序);而在同一个进程(程序)中有多个线程同时执行(通过CPU调度,在每个时间片中只有一个线程执行)
内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源。
包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
线程:
c++中通过std中的thread方法创建函数:
// ConsoleApplication1.cpp : 定义控制台应用程序的入口点。 #include "stdafx.h" #include <iostream> #include <thread> using std::cout; using std::endl; using std::thread; void func() { // do something } int main() { thread t(func); t.join(); // join()函数会阻塞线程,直到线程执行完毕 return 0; }
关于线程的阻塞:
在某一时刻某一个线程在运行一段代码的时候,这时候另一个线程也需要运行,但是在运行过程中的那个线程执行完成之前,另一个线程是无法获取到CPU执行权的(调用sleep方法是进入到睡眠暂停状态,但是CPU执行权并没有交出去),这个时候就会造成线程阻塞。
出现线程阻塞的原因:(参考:https://blog.csdn.net/sunshine_2211468152/article/details/87299708)
1. 睡眠状态
当一个线程执行代码的时候调用了sleep方法,线程处于睡眠状态,此时有其他线程需要执行时就会造成线程阻塞,而且sleep方法被调用之后,线程不会释放锁对象,也就是说锁还在该线程手里,CPU执行权还在自己手里,等睡眠时间一过,该线程就会进入就绪状态。
2. 礼让状态:
当一个线程正在运行时,调用了yield方法之后,该线程会将执行权礼让给同等级的线程或者比它高一级的线程优先执行,此时该线程有可能只执行了一部分而此时把执行权礼让给了其他线程,这个时候也会进入阻塞状态,但是该线程会随时可能又被分配到执行权,比较讲究谦让;
3.等待状态:
当一个线程正在运行时,调用了wait方法,此时该线程需要交出CPU执行权,也就是将锁释放出去,交给另一个线程,该线程进入等待状态,但与睡眠状态不一样的是,进入等待状态的线程不需要设置睡眠时间,但是需要执行notify方法或者notifyall方法来对其唤醒,自己是不会主动醒来的,等被唤醒之后,该线程也会进入就绪状态,但是进入仅需状态的该线程手里是没有执行权的,也就是没有锁,而睡眠状态的线程一旦苏醒,进入就绪状态时是自己还拿着锁的
4. 自闭状态:
当一个线程正在运行时,调用了一个join方法,此时该线程会进入阻塞状态,另一个线程会运行,直到运行结束后,原线程才会进入就绪状态。这个比较像是”走后门“,本来该先把你的事情解决完了再解决后边的人的事情,但是这时候有走后门的人,那就会停止给你解决,而优先把走后门的人事情解决了;
------------------------------------------------------------------------------------------------------------------------
如果不希望线程被阻塞,可以调用detach方法,将线程和线程对象分离:
// ConsoleApplication1.cpp : 定义控制台应用程序的入口点。 #include "stdafx.h" #include <iostream> #include <thread> using std::cout; using std::endl; using std::thread; void func() { // do something } int main() { thread t(func); t.detach(); // join()函数会阻塞线程,直到线程执行完毕 // do other thing return 0; }
调用detach方法后,线程和线程对象就分离了,让线程作为后台线程去执行,当前线程也不会阻塞,但是datech之后就无法再和线程发生联系了。例如,detach之后线程无法再进行join(),线程何时执行完我们也无法控制。
注:
std::thread出了作用域之后将会析构。这时如果线程函数没有执行完毕将会发生错误,因此应该保证线程函数的生命周期在线程变量std::thread的生命周期之内。
void func() { // do something } int main() { thread t(func); return 0; }
例如,上面的程序可能会报错,因为线程对象可能会先于线程函数结束。所以应该用join()阻塞线程或者用detach让线程在后台运行。
此外,可以将线程对象保存到一个容器中,以保证线程对象的生命周期:
// ConsoleApplication1.cpp : 定义控制台应用程序的入口点。 #include "stdafx.h" #include <iostream> #include <thread> #include <vector> using std::cout; using std::endl; using std::thread; using std::vector; vector<thread> g_list; vector<std::shared_ptr<thread>> g_list2; void func() { // do something } void CreateThread() { thread t(func); g_list.push_back(std::move(t)); // 将线程对象保存到容器中 g_list2.push_back(std::make_shared<thread>(func)); } int main() { CreateThread(); for (auto& thread : g_list) { thread.join; } for (auto& thread : g_list2) { thread->join(); } return 0; }
线程不支持复制,但是可以将线程移动:
void func() { // do something } int main() { thread t(func); thread t1(std::move(t)); // 移动语义 return 0; }
线程被移动之后,线程对象t将不再代表任何线程了。
线程的基本使用方法:
1. 获取线程ID,获取CPU核数
void func() { //do something } int main() { thread t(func); t.get_id(); // 获取线程ID cout << thread::hardware_concurrency() << endl; // 获取CPU核心数 return 0; }
2,线程休眠:
void func() { std::this_thread::sleep_for(std::chrono::seconds(3)); // 线程休眠 cout << "finish sleep" << endl; } int main() { thread t(func); t.join(); return 0; }
多线程中的互斥量:
互斥量是一种同步语句,是一种线程同步的手段,用来保护多线程同时访问的共享数据:
C++中提供了四种互斥量:
std::mutext: 独占的互斥量,不能递归使用
std::timed_mutex: 带超时的互斥量,不能递归使用。
std::recursive_mutex: 递归互斥量,不带超时功能。
std::recursive_timed_mutex: 带超时的递归互斥量
1. std::mutext: 独占的互斥量,不能递归使用
互斥量的基本接口很相似,一般都是通过lock()方法来阻塞线程,直到获得互斥量的所有权为止,在线程获得互斥量并完成任务之后,使用unlock来解除对互斥量的占用。lock(),unlock()应成对出现。try_lock()尝试锁定互斥量,成功返回true,否则为false,所以它是非阻塞的。
// ConsoleApplication1.cpp : 定义控制台应用程序的入口点。 #include "stdafx.h" #include <iostream> #include <thread> #include <mutex> #include <chrono> using namespace std; mutex g_lock; // 独占互斥量 void func() { g_lock.lock(); // 锁 cout << "Enter thread " << std::this_thread::get_id() << endl; std::this_thread::sleep_for(std::chrono::seconds(2)); cout << "Leaving thread " << std::this_thread::get_id() << endl; g_lock.unlock(); } int main() { thread t1(func); thread t2(func); thread t3(func); t1.join(); t2.join(); t3.join(); return 0; }
结果如下图所示:
一般推荐使用lock_guard()来替代lock/unlock,因为更加安全。lock_guard在构造时会自动锁定互斥量,在退出作用后进行析构时就会自动解锁互斥量,避免忘记unlock操作。
lock_guard用到了RAII技术:
使用局部对象管理资源的技术通常称为“资源获取就是初始化”,即Resource Acquisition Is Initialization 机制。这一机制是Bjarne Stroustrup首先提出的,要解决的是这样一个问题:
在C++中,如果在这个程序段结束时需要完成一些资源释放的工作,那么正常情况下自然是没有什么问题,但是当一个异常抛出时,释放资源的语句就不会被执行。于是Bjarne Stroustrup就想到确保 能运行资源释放代码的地方就是在这个程序段(栈帧)中放置的对象的析构函数了,因为stack winding会保证它们的析构函数都会被执行。将初始化和资源释放都放到一个包装类中的好处:
a. 保证了资源的正常释放
b. 省去了在异常处理中冗长而重复甚至有些还不一定执行到的清理逻辑,进而确保了代码的异常安全。
c. 简化代码体积。
所在lock_guard在类的构造函数中和会分配资源,在析构函数中释放资源,保证资源在出了作用域之后就释放,上面的例子使用lock_guard后会更加简洁:
// ConsoleApplication1.cpp : 定义控制台应用程序的入口点。 #include "stdafx.h" #include <iostream> #include <thread> #include <mutex> #include <chrono> using namespace std; mutex g_lock; // 独占互斥量 void func() { std::lock_guard<std::mutex> locker(g_lock); cout << "Enter thread " << std::this_thread::get_id() << endl; std::this_thread::sleep_for(std::chrono::seconds(2)); cout << "Leaving thread " << std::this_thread::get_id() << endl; } int main() { thread t1(func); thread t2(func); thread t3(func); t1.join(); t2.join(); t3.join(); return 0; }
2. std::recursive_mutex: 递归互斥量,不带超时功能。
递归锁允许同一线程多次获得该互斥锁,可以解决同一线程需要多次获取互斥量时的死锁的问题,一个线程多次获取同一互斥量时会发生死锁问题:
例如:
// ConsoleApplication1.cpp : 定义控制台应用程序的入口点。 #include "stdafx.h" #include <iostream> #include <thread> #include <mutex> #include <chrono> using namespace std; struct Complex { std::mutex mutex; int i; Complex() : i(2) {}; // 构造函数 void mul(int x) { std::lock_guard<std::mutex> lock(mutex); // 获取互斥量 i *= x; } void div(int y) { std::lock_guard<std::mutex> lock(mutex); // 获取互斥量 i /= y; } void both(int x, int y) { std::lock_guard<std::mutex> lock(mutex); // 获取互斥量 mul(x); div(y); } }; int main() { Complex cmp; cmp.both(21, 2); cout << cmp.i << endl; return 0; }
在主线程中多次获取互斥量,就会发生死锁。因为互斥量已被当前线程获取,无法释放,就会导致这样的问题。
递归锁可以解决这种问题:
// ConsoleApplication1.cpp : 定义控制台应用程序的入口点。 #include "stdafx.h" #include <iostream> #include <thread> #include <mutex> #include <chrono> using namespace std; struct Complex { std::recursive_mutex mutex; int i; Complex() : i(2) {}; // 构造函数 void mul(int x) { std::lock_guard<std::recursive_mutex> lock(mutex); // 获取互斥量 i *= x; } void div(int y) { std::lock_guard<std::recursive_mutex> lock(mutex); // 获取互斥量 i /= y; } void both(int x, int y) { std::lock_guard<std::recursive_mutex> lock(mutex); // 获取互斥量 mul(x); div(y); } }; int main() { Complex cmp; cmp.both(21, 2); cout << cmp.i << endl; return 0; }
但是递归锁会存在如下的缺点:
a. 用到递归锁的多线程互斥处理本身是可以简化的。递归互斥量很容易产生复杂的逻辑,会导致线程同步引起的晦涩的问题。
b. 与非递归锁相比,递归锁的效率会更低。
c. 递归锁没有说明一个线程最多可以重复获得几次互斥量,一旦超过一定的次数,再调用lock就会抛出std::system的错误。
3. std::timed_mutex: 带超时的互斥量,不能递归使用。
timed_mutex在获取锁时增加超时等待功能,因为有时候不知道获取锁需要等待多久,为了不至于一直在等待获取互斥量,可以设置一个超时时间,在超时后还可以做其他的事情。多出的两个接口为:try_lock_for, try_lock_until.
例如:
// ConsoleApplication1.cpp : 定义控制台应用程序的入口点。 #include "stdafx.h" #include <iostream> #include <thread> #include <mutex> #include <chrono> using namespace std; void work() { std::timed_mutex mutex; // 定义函数 std::chrono::milliseconds timeout(100); // 100ms while (true) { if (mutex.try_lock_for(timeout)) // 获取到互斥量 { cout << "Thread " << this_thread::get_id() << " do work in mutex" << endl; std::chrono::milliseconds sleep_time(250); this_thread::sleep_for(sleep_time); mutex.unlock(); // 释放互斥量 this_thread::sleep_for(sleep_time); } else // 未获取到互斥量 处理其他事务 { cout << "Thread " << this_thread::get_id() << " do work not in mutex" << endl; std::chrono::milliseconds sleep_time(100); this_thread::sleep_for(sleep_time); } } } int main() { thread t1(work); thread t2(work); t1.join(); t2.join(); return 0; }
------------------------------------------------------------分割线------------------------------------------------------------------
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)