【Linux】多线程(一)
多线程我将分为两个部分进行讲述,本篇文章将要讲述线程的一些概念、线程控制、线程互斥、可冲入与线程安全、常见锁概念以及线程同步,而下一篇文章我将讲述生产者消费者模型、POSIX信号量、读写者模型、自旋锁以及STL、智能指针和线程安全。
一、线程
1.1 线程的概念
这里分别讲述操作系统中线程的概念和我们自己理解后线程的概念。
操作系统中线程的概念:
- 线程是比进程更加轻量化的执行流
- 线程是进程内部执行的一种执行流
我们理解后线程的概念:
- 线程是CPU调度的基本单位
- 进程是承担资源的基本载体,线程参与分配进程的资源
我们知道创建一个进程需要做很多工作,将程序的代码和数据加载到内存、创建进程的PCB、创建进程的页表、创建进程进程地址空间等等,进程想要访问自己的代码、自己的数据、动态库等等都需要通过进程地址空间和页表来访问的,所以进程地址空间是进程的“资源”窗口。创建进程需要做这么多工作,操作系统的设计者就想到,创建进程是为了执行代码,创建线程也是执行代码,而进程被创建出来以后,代码和数据都是通过进程地址空间看到的,那么我们是否创建一种“进程”,它只创建一个PCB,通过PCB的指针指向与进程相同的进程地址空间。假设我们创建一个进程后,再创建四个“进程”,进程代码中有五个函数,然后再代码区中将这五个函数分别分给这五个执行流,其他区域的数据该共享的共享,该分开的分开,CPU执行这些执行流时,只会执行进程的一部分,像这种比传统进程更加轻量化的“进程”就可以被称作为“线程”了。
通过将进程地址空间分给每一个执行流,本质上每个执行流只需要使用部分页表,就可以看到每一份资源的不同块。由于这些执行流指向的是同一个进程地址空间,所以很多资源都是可以被共享的。创建一个进程需要做很多工作,但是创建线程只需要参与进程资源的分配即可。我们可以得到线程的创建更简单,意味着释放也必然更加简单,所以线程是比进程更加轻量化的执行流。线程与进程指向的是同一个进程地址空间,意味着线程在进程的进程地址空间内运行,所以线程是进程内部执行的一种执行流。
一个进程就可以创建多个线程,那么操作系统就需要对这些线程进行管理,先描述再组织,线程就要有自己的id、优先级、调度算法等,操作系统中确实有线程控制块TCP(Thread Control Block)的概念,但是Linux操作系统的设计者并未使用TPC,因为PCB中的很多属性与TCP相似,所以Linux操作系统中就拿着PCB的结构充当TCP,这样就可以让操作系统中所有调度切换的所有代码在线程层面上进行复用,不用在单独设计线程了,这里就是Linux操作系统中线程的实现方案。
1.2 线程的理解
那CPU拿到一个PCB需要区分是进程还是线程吗?CPU就像快递员一样,快递员只负责送快递,并不关心快递中是什么东西,CPU的工作就是执行代码,并不用区分是进程还是线程。CPU获取到一个执行流的PCB,这个执行流所占的代码,进程地址空间能看到的数据等等,都是小于等于这个执行流所在的进程的,因为这个执行流可能只是进程中的其中一个,只有当进程中只有一个执行流时,才会相等。
那么我们该如何理解我们现在讲到的进程和之前讲到的进程呢?
我们现在讲到的进程是由下图中蓝色区域组成的,进程 = 内核数据结构 + 可执行程序(代码和数据),现在所讲到的进程就是内部有多个执行流进程,而以前讲到的进程是内部只有一个执行流的进程。进程中所有的PCB地位都是对等的,一个PCB对应一个执行流。
Linux操作系统中实际上并没有真正意义上的线程,因为它实际上是通过进程的数据结构模拟出来的线程,当一个进程创建了多个线程后,CPU拿到一个PCB就会执行该PCB对应的方法,实际上只是执行进程的一部分,实际上CPU执行的执行流要比真实的进程量级上要轻一些,在Linux操作系统中我们称这样执行流为轻量级进程(线程)。即使进程中只有一个执行流,CPU执行该执行流时,也会认为它是轻量级进程,它并不区分该执行流是进程中唯一的执行流还是进程中执行流的其中一个。CPU调度时,看到的就是一个一个的PCB,所以CPU调度的基本单位就是线程了。
下面我将使用代码来验证刚刚的说法,大家先不用管这里的代码使用,会在后面讲到,这里先观察现象,在我们想使用Linux中的线程时,在编译链接时引入一个ptread库。通过下面的程序的运行,我们发现确实有两个执行流在执行,并且他们的PID都是相同的。
下面我们在程序运行的时候,可以使用ps -aL命令来查看有哪些正在执行的轻量级进程,通过下面的图片也确实证明了有两个线程,因为这是同一个进程的两个线程,相同的PID也是应该的。这两个线程的PID是相同的,但是LWP是不同的,LWP(Lightweight Process)就是轻量级进程,所以LWP是用来标识同一个进程中不同线程的。CPU调度线程的时候,看的是PCB,同一个进程中所以PCB中的PID都是相同的,所以操作系统进行调度的时候看的是LWP。我们之前讲的进程都只有一个执行流,所以这个执行流的LPW的值就等于PID,用LPW和PID都可以。但是实际情况下,无论进程有几个线程都是看到LWP,判断是否是主线程就判断PID是否等于LWP就可以了。
为了让创建线程的现象更加明显,下面代码中我们每过两秒钟创建一个线程 ,并使用脚本打印当前进程所拥有的线程。通过下面代码和脚本的结果来看,线程确实是逐步增多的。
这里我们再谈谈线程比进程更加轻量化的原因。
首先就是创建线程只需要创建一个PCB,删除线程只需要删除一个PCB,这是一个原因。其次是在调度上同一个进程中不同的线程进行切换时,部分寄存器是不用被切换的,而不同进程切换则需要将全部寄存器进行切换,这也是一个原因,但是最主要的原因是CPU进行缓存的时候,大部分时候都是直接将内存中的数据拿到寄存器中的,但是CPU中还集成了一个大的硬件空间cache,计算机中有一个局部性原理(一旦CPU在访问某条代码或数据,下一次大概率会访问到它附近的代码和数据),所以CPU在访问某一行代码或数据的时候,会将它附近的代码或数据放入到cache中,cache中的信息我们称之为热数据,根据局部性原理大概率下一条要执行的代码就在cache中,就可以直接向cache中读取,即便没有在cache中,再从内存中读取也不迟。cache是以进程为单位的,所以理论上切换同一个进程中的不同线程,是不需要切换cache的,因为cache本来缓冲的就是这个进程地址空间中的数据,cache可能会导致线程的切换导致热数据失效,但同样有可能下一次访问的代码和数据就在cache中,但是进程切换时,一个进程的代码和数据对另一个进程就没有任何意义,马上就失效了,这就是线程比进程更加轻量化的原因。
操作系统加载一个很大的可执行程序的时候,会加载它的一部分,就是根据局部性原理,局部性原理给磁盘加载到内存的预加载机制提供了理论基础。
1.3 内存的4KB理解
我们以前在讲文件系统的时候讲到过,文件系统会指导操作系统与磁盘进行IO交互时,以4KB为单位进行交互的。
在我们看来可执行程序就是一个文件,而在文件系统看来可执行程序就是由一个一个4KB块构成的,每一块我们称之为ELF的数据段,Linux操作系统在设计的时候,可执行程序我们也认为已经按照4KB的大小在逻辑上被划分好了,同样物理内存在操作系统层面上天然也是按照4KB的大小在逻辑上被划分好了。在操作系统层面上文件系统进行IO的基本单位大小的4KB我们称之为page size,磁盘中组成文件的每一个4KB我们称之为页帧,物理内存中被划分出来的每一个4KB我们称之为页框。正是因为编译器,操作系统中的内存管理,文件系统等的互相配合才促使了文件系统会指导操作系统与磁盘进行IO交互时,以4KB为单位进行交互这样的结论。
一个物理内存的大小是4GB,以4KB进行划分就会得到1,048,576个页框,操作系统就需要知道这些页框的使用情况,所以要对这些页框进行管理,先描述再组织,操作系统会为每一个框维护一个结构体(struct page),再维护一个数组(struct page pages[1048576]),将数组的下标与页框进行映射,这样操作系统对页框的管理就转换为了对数组的管理。
我们知道如何一个文件都会有inode属性,方法集和文件缓冲区,文件缓冲区的本质就是将加载到内存中与文件内容相关的页框以某种数据结构关联起来,又页框是被数组管理起来的,所以最终文件缓冲区的本质就是某种数据结构与管理页框的数组相关联起来。
1.4 页表(虚拟地址到物理地址的转换)
我们之前是以下图理解页表的,左边虚拟地址,中间物理地址,右边各种标记位,假设地址都为4字节,标记位为2字节,所以一个页表项就是10字节,虚拟地址有2
3
^3
3
2
^2
2个,就代表要映射2
3
^3
3
2
^2
2次,这样物理内存连页表的存不下,所以我们之前理解的页表是有问题的。
我们之前理解虚拟地址就仅仅认为它由32位比特位组成的,这样理解并不全面,实际上虚拟地址是可以分为10位比特位、10位比特位和12位比特位这三部分的,页表实际上并不是只有一张,它是被分为两级的,一级页表我们称之为页目录,可以通过虚拟地址的前10个比特位进行查找,所以页目录最多有2 1 ^1 1 0 ^0 0(1024)项,我们可以将页目录想象成为一个数组,前10个比特位组成的数数字就是数组下标,根据一级页表数组下标中存储的内容找到二级页表。
再根据虚拟地址中间10位比特位进行查找,所以一个二级页表最多也只有2 1 ^1 1 0 ^0 0 (1024)项,再将二级页表想象成为数组,中间10个比特位组成的数数字就是数组下标,二级页表数组中存储的内容就是物理内存中的页框的起始地址,我们要找到物理内存中的代码或数据,前提就是找到虚拟地址所在的页框,所以一级页表和二级页表做的工作就是找到页框,也就是说我们只需要根据虚拟地址的前20个比特位就可以找到对应页框,所以我们只需要维护虚拟地址中前20个比特位的映射关系即可。
根据一级和二级页表我们就拥有了一个页框的起始地址,也许只是想访问一个页框中的一部分,那么我们就可以通过虚拟地址的最后12位比特位找到我们要访问的那个一部分,也就是页框的起始地址+虚拟地址的最后12位比特位找到,虚拟地址的最后12位比特位通常充当的是页内偏移,这里的12位比特位大家看着有没有一点熟悉呢,2 1 ^1 1 2 ^2 2就是4KB啊,也就是一个页框的大小,所以这12位能够覆盖一个页框的每一个地方。
一级页表只有一个但有2 1 ^1 1 0 ^0 0项,所以二级页表最多只有2 1 ^1 1 0 ^0 0个,但每一个二级页表也有2 1 ^1 1 0 ^0 0项,所以我们所维护的页表项就为2 2 ^2 2 0 ^0 0个,相比于之前的2 3 ^3 3 2 ^2 2个已经少了非常多了。一级和二级页表就能够覆盖整个物理内存,但是我们一个可执行程序通常并不会用完整个物理内存,也许这个可执行程序只经常使用一些前面的页表,所以一级页表需要一直存在,但是二级页表并不需要一开始就被创建,这样需要维护的页表数量又大大的减少了。
CPU中有一个寄存器可以直接找到页目录,还有一个寄存器存储的下一条指令的虚拟地址,在CPU中还集成了硬件MMU,MMU可以将虚拟地址转化为物理地址,所以CPU得到一个虚拟地址后,虚拟地址到物理地址的转换完全是在CPU内部完成的。
我们创建线程后会分配进程的资源,也就是分配物理内存中哪部分数据和代码属于这个线程,本质上就是为这个线程分配它能使用的页表,划分页表的本质就是划分进程地址空间。
1.5 线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
1.6 线程的缺点
- 性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多
1.7 线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
1.8 线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验
1.9 进程和线程
- 进程是资源分配的基本单位
- 线程是调度的基本单位
进程和线程的关系如下图:
1.9.1 线程独立的数据
线程共享进程数据,但也拥有自己的一部分数据:
- 硬件上下文
- 独立的栈结构
- 线程ID
- errno
- 信号屏蔽字
- 调度优先级
1.9.2 线程共享的数据
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
二、线程控制
2.1 pthread原生线程库
在下面的代码中完成了线程的创建并使用,但是当我们编译时,编译器却告诉我们不认识pthread_create函数,但是我们命名使用了线程库的头文件了,这里还报错呢?
原因是内核中并没有线程的概念,只有轻量级进程的概念,所以Linux操作系统只会提供创建轻量级进程的系统调用,并不会提供线程的系统调用,但是有些人没有具体学习过Linux操作系统,并不知道轻量级进程的概念,所以Linux操作系统的设计者在用户和操作系统直接设计了一个应用层,这个软件层对上提供线程控制的接口,对下层将线程与轻量级进程的概念对应起来,用户在上层创建一个线程,实际上在下层创建的就是一个轻量级进程,这个软件层并不属于操作系统,它是操作系统的设计者在操作系统上层封装的第三方库,名为pthread原生线程库,这个库在安装操作系统时就是自带的,所以编译链接这个些线程函数库时要使用编译器命令的"-lpthread"
选项。
2.2 线程创建(pthread_create函数)
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
功能:用于创建新线程。
参数
- thread: 这是一个指向 pthread_t 类型变量的指针,用于接收新创建的线程的标识符。
- attr: 这是一个指向线程属性对象的指针,用于指定新线程的属性。如果设置为 NULL,则使用默认属性。
- start_routine: 这是一个指向函数的指针,该函数是线程启动后要执行的函数。这个函数必须接受一个 void* 类型的参数,并返回一个 void* 类型的值。
- arg: 这是传递给 start_routine 函数的参数,它的类型是void*,所以可以传递任意类型的对象。
下面代码我们就简单的先使用pthread_create函数创建一个线程来使用一下,通过他们代码运行结果和ps -aL命令的结果来看确实是有两个执行流,也就是说确实创建了一个线程。
能够创建一个线程就意味着能够创建多个线程,下面我们就使用pthread_create函数创建多个线程,并且上面我们说了函数的第四个参数的类型为void*,说明可以传任意对象的地址,这里我们就传结构体对象的地址,结构体中有线程名、创建时间和一个函数,下面的代码中我们就每个1秒创建一个线程,线程中输出结构体中的数据,并调用结构体中的函数,并且使用脚本每隔1秒查看当前进程有多少个执行流,运行程序观察现象。
通过下图每1秒都多出来了一个执行流,创建四个加上主线程一共五个,并且我们可以得知线程中确实将结构体中的信息被打印出来了,函数也被调用了。
#include <iostream>
#include <pthread.h>
#include <string>
#include <time.h>
#include <unistd.h>
#include <functional>
#include <vector>
using namespace std;
const int threadnum = 4;
typedef function<void()> func_t;
class ThreadDate
{
public:
ThreadDate(string name, uint64_t time, func_t f)
: threadname(name), ctime(time), func(f)
{}
public:
string threadname;
uint64_t ctime;
func_t func;
};
void Func()
{
cout << "我完成了部分任务" << endl;
}
// 新线程
void *ThreadRoutine(void *arg)
{
ThreadDate* pt = (ThreadDate*)arg;
while (1)
{
cout << "I am new thread" << " threadname : " << pt->threadname << " create time : " << pt->ctime << endl;
pt->func();
sleep(1);
}
return nullptr;
}
int main()
{
vector<pthread_t> pthreads;
for (int i = 0; i <= threadnum; i++)
{
char threadname[64];
snprintf(threadname,sizeof(threadname),"%s-%d","thread",i);
pthread_t pth;
ThreadDate* pt = new ThreadDate(threadname,time(nullptr),Func);
pthread_create(&pth, nullptr, ThreadRoutine, pt);
sleep(1);
}
// 创建新进程以后,执行main函数的执行流我们叫做主线程
while (1)
{
cout << "I am main thread" << endl;
sleep(1);
}
return 0;
}
在讲线程的缺点的时候我们提到了相比于进程,线程的健壮性降低了,原因时一个进程崩溃了并不会影响其他进程,而一个线程崩溃了会导致整个进程崩溃,在下面的代码中,我们将要创建四个线程,并让所有线程一直运行,但是在第四个线程中发生除0错误,运行程序观察现象,通过结果来看一个进程发送异常确实导致了整个进程终止了。
2.3 线程终止
2.3.1 从线程return
这种方法对主线程不适用,从main函数return相当于调用exit。
在下面的代码中,我们让线程循环五次后就return返回,通过下面代码和脚本结果来看,线程确实退出了,线程数量由两个变为了一个。
2.3.2 pthread_ exit函数
#include <pthread.h>
void pthread_exit(void *retval);
功能:用来使线程退出
参数:
- retval:这是一个指向线程返回值的指针。线程可以返回一个指向任何类型数据的指针,如果线程不需要返回任何数据,可以传递 NULL。
在下面的代码中,我们让线程循环五次后就调用pthread_exit函数,通过下面代码和脚本结果来看,线程确实退出了,线程数量由两个变为了一个。
2.3.3 pthread_ cancel函数
一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
#include <pthread.h>
int pthread_cancel(pthread_t thread);
功能:用于取消指定的线程
参数:
- thread:这是你要请求取消的线程的线程 ID。
返回值
- 成功时,pthread_cancel 返回 0。
- 失败时,返回一个错误码。
在下面这段代码中,我们创建一个新线程,让这个线程一直运行,我们在主线程中过3秒后使用pthread_cancel 取消新线程,运行程序观察现象,通过下面代码和脚本的运行结果来看,新线程确实被取消了。
2.4 线程等待(pthread_join函数)
线程退出如果没有进行等待,其空间没有被释放,仍然在进程的地址空间内,会出现类似于进程中的僵尸问题。
线程有两种状态:可连接(joinable)和分离(detached)
- 可连接状态:这是线程默认的状态。当一个线程完成执行后,它的资源不会被立即释放,而是等待其他线程调用 pthread_join 函数来回收这些资源。
- 分离状态:当线程被设置为分离状态时,一旦它完成执行,其所有资源会自动被释放。
pthread_join函数
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
功能:用于等待指定线程终止的函数
参数:
- thread:这是你要等待的线程的线程 ID。这个 ID 是通过 pthread_create 函数创建线程时返回的,或者通过 pthread_self 函数获取的当前线程的 ID。
- retval:这是一个指向指针的指针(void ** 类型),是一个输出型参数,用于接收被等待线程的返回值。如果不需要知道线程的返回值,可以传NULL。
返回值:
- 成功时,pthread_cancel 返回 0。
- 失败时,返回一个错误码。
pthread_self函数
#include <pthread.h>
pthread_t pthread_self(void);
功能:用于获取调用线程的线程 ID 的函数
返回值:
- 返回一个 pthread_t 类型的值,该值唯一地标识调用线程。
pthread_detach函数
#include <pthread.h>
int pthread_detach(pthread_t thread);
功能:用于线程的状态从可连接变为分离
参数:
- thread:指定线程的标识符。
返回值:
- 如果成功,函数返回 0
- 如果失败,返回错误码。
下面的代码中使用了pthread_join函数,但是并没有特别明显的现象,这里就不过多讲解了。需要注意的是调用pthread_join函数的进行需要阻塞等待指定的进程。
我们知道进程终止的时候,可以通过return和pthread_exit函数传递返回信息,而接收信息则需要通过pthread_join函数来接收。由于线程返回的类型是void*,所以可以返回任意类型的对象。下面我就不使用简单的内置类型返回了,而是返回一个自定义类型进行演示。下面的代码中我将新线程需要返回一些信息放入结构体中,主线程则阻塞等待新线程的退出,通过下面的代码结果来看主线程确实获取到了新线程返回的信息。
如果我们创建一个线程以后,就不想别的线程管它了,我们可以将该线程的状态改为分离,这样就可以让线程退出后自动被回收。在下面的代码中,我将新线程先变为分离状态,再使用主线程使用pthread_join函数回收新线程,用变量n接收pthread_join函数的返回值,我们发现n的值不为0,说明回收失败了,这也证明了一个线程为分离状态,则它不能被其他线程使用pthread_join函数回收。想要改变新线程的状态,pthread_detach函数可以在新线程中使用,也可以在主线程中使用。
上面我们讲到pthread_cancel可以取消线程,pthread_join可以等待线程,pthread_detach可以改变线程状态,下面就进行这三个函数的综合使用。
上面讲解pthread_cancel函数时,知道了可连接状态下的线程可以被取消,那么这里测试在分离状态下的线程可不可以被取消。通过下面代码的结果来看,即使是在分离状态下的线程也是可以被取消的,但是不能被pthread_join函数回收。
那么如果一个可连接状态下的线程被取消了,可以被pthread_join函数回收吗?通过下面代码的运行结果来看,可连接状态下的线程被取消了,可以被pthread_join函数回收,但是它返回的信息是-1,这是因为thread线程被别的线程调用pthread_ cancel取消,线程返回值所指向的单元里存放的是常数PTHREAD_CANCELED(-1)
。
2.5 线程ID及pthread原生线程库管理线程
2.5.1 线程是由pthread原生线程库进行管理的
Linux操作系统中的线程我们通常也被称之为用户级线程,因为线程是在用户层实现的,而不是操作系统内实现的,操作系统中只有轻量级进程,操作系统可以将轻量级进程管理起来。当pthread原生线程库与操作系统结合就会存在线程的概念,那么整个系统(pthread原生线程库+Linux操作系统)中就会有很多线程,这些线程也需要被管理起来,而操作系统中只有轻量级进程的概念,轻量级进程的创建、删除等是操作系统来做的,但是用户所看到的线程的需要通过pthread原生线程库进行管理。
管理就是先描述再组织,每创建一个线程,线程库中就会创建一个结构体对象,结构体对象中的属性一部分来自于轻量级进程的PCB,一部分来自于用户,并且线程和轻量级进程是一一对应的,如果创建了很多线程,就会有很多的结构体对象,这是就可以将这些结构体对象以某种数据结构操作起来,这样就可以在线程库中讲线程管理起来。
2.5.2 线程库中是如何保证对应线程有自己独立的栈
我们之前说过,线程中很多属性都是要共享的,但是每一个线程都需要有自己独立的数据,最主要的就是上下文和栈,上下文是在PCB中维护的,可以保证每个线程有自己独立的上下文,但是进程地址空间中只有一个栈,它是如何保证每一个线程都有独立的栈的呢?
操作系统中创建线程的函数是clone函数,调用这个函数创建轻量级进程时,需要传入调用者自己创建的栈地址,pthread原生线程库中创建线程的函数底层调用了clone函数,而clone函数所需的栈空间是线程库在堆中申请的空间,用来充当这个线程的栈。所以每个新线程的栈是在pthread原生线程库中维护的,默认进程地址空间中的栈是由主线程来使用的,这样就能保证每一个线程都有自己独立的栈了。
2.5.3 如何理解pthread原生线程库管理线程
启动一个进程需要将对应的程序加载到内存中,一个进程需要有自己车进程地址空间和页表,如果进程中会创建线程,则需要将线程库加载到内存,并通过页表将线程库映射到进程地址空间中,进程中创建一个线程,线程库会在堆上申请堆空间作为这个线程的栈,还会在线程库中维护一个管理线程的结构体对象,进程也许会创建多个线程,线程库中就会有多个结构体对象,但是一个用户启动的进程并不是一个,并且可能有多个用户在使用同一个操作系统,又pthread原生线程库是动态库,只需要加载一次,所以的进程就都可以使用,所以pthread原生线程库需要管理整个操作系统中多个用户创建的所有线程。
2.5.4 线程ID
在下面这段代码中,我们将会创建3个线程,并将这三个线程的线程ID存储在vector中,再将这3个线程的线程ID统一进行打印,先以十进制的方式打印,再以十六进制的方式打印,使用脚本打印当前进程的所有执行流的属性,查看对应的LWP与线程ID的关系,运行代码和脚本观察结果。
以十进制打印的线程ID是一个很大的数字,以十六进制打印的线程ID看起来像是一个地址,看起来线程ID与LWP并没有直接的关系,实际上线程ID确实是一个地址,也与LWP这个值没有任何关系。
动态库会被映射到进程地址空间中的共享区中,这里讲解具体的一个动态库即pthread原生线程库,线程库中有很多维护线程的接口,在创建线程的时候,会为线程维护一个线程属性集合,这个属性集合中包含了描述线程的结构体对象、线程局部存储和线程栈,有多个线程就有多个属性集合,而线程库中就是以数组的方式将这些属性集合管理好的,而线程ID本质上就是线程对应属性集合在pthread原生线程库中的地址。
2.5.5 线程局部存储
我们知道线程之间很多属性都是共享的,例如全局变量,一个线程改变全局变量的值,同样也会导致其他线程访问的全局变量是改变后的值,那么如果我并不想这个值被共享,而是每一个线程都各自一份,可以为这个变量加上__thread
选项来实现。
在下面的代码中,我们会创建一个新线程,代码中定义一个全局变量,并且这个变量只会在新线程中被修改,在主线程和新线程中同时打印这个全局变量的值和地址,运行代码和脚本查看结果,我们发现确实创建了一个线程,并且这两个线程打印全局变量的值和地址都是一样的。
这里我们给这个变量之前添加__thread,其他的都不变,运行代码和脚本观察现象,我们发现两个线程打印变量的值和地址不一样的,__thread选项就是告诉编译器,将这个变量给每一个线程都来一份私有的,这个变量会在线程属性集合中的线程局部存储中被存放。
2.6 站在语言层面上理解thread
不仅仅是操作系统中有多线程,实际上很多语言里也有多线程,例如C++、Java等,那么语言层面上的多线程与操作系统中的多线程有什么关系呢?为了代码的可移植性,实际上语言层面上的多线程都是封装了多种操作系统中的多线程的实现。这意味着开发者可以编写在不同操作系统上都能运行的多线程代码,而无需关心底层操作系统的差异。
三、Linux线程互斥
3.1 进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
3.2 实验及现象解释
在下面的代码中,我们我们简单的模拟了一下抢票系统,首先定义一个全局变量ticket来代表所剩的票数,然后创建4个线程分别去抢票,每抢到一张票就打印抢到票的线程名和这张票是还剩多少张票时抢到的,当所剩票数大于0线程就可以抢,小于等于0时线程就退出,运行程序观察结果,我们发现所剩票数出现了0、-1、-2张时,还能抢到票的情况,这显然是不合理的。
首先讲解这个问题的时候,先给大家补充一个小知识点,C++中的++、- -操作看上去只有一次操作,但是编译变为汇编代码后,实际上有三次操作,数据都是加载到内存中的,代码中的运算实际上都是在CPU中进行的,上面的++、–操作编译成汇编后,首先是将数据拷贝到CPU中的寄存器中,然后进行运算,最后在讲数据拷贝回内存,这三个操作任何时候都有可能被调度机制打断,所以++、- -操作并不是原子的。
假设我们定义一个全局变量a为0,我们创建两个线程分别对a进行++操作,首先我们知道++操作有三步,从内存拷贝数据、运算和将数据拷贝回内存。
首先是线程一,一直重复这三步将a变为了10,当它想继续时,它的时间片到了,停在了++的第二步上,此时内存中的a是10,寄存器中的a是11,将寄存器中的内容保存到硬件上下文中。
然后是线程二,CPU中的寄存器读到a的值是10,然后线程二一直重复++操作,将a变为了100,然后它的时间片也到了,停在了++的第一步上,此时内存上的a是100,寄存器中的a是100,将寄存器中的内容保存到硬件上下文中。
最后是线程一,运行线程一之前需要先恢复它的硬件上下文,此时它将上下文的内容覆盖到寄存器中,寄存器中的a就为11,然后进行第三步将a拷贝回内存中,a的值就变为了11。
第二次运行线程一时,直接让线程二做的工作全部白费了,这就是两个执行流在并发访问时,因为时间片切换会对一个数据进行更新发生互相干扰,造成数据不一致的问题。这也体现了多线程访问同一个int时,并不是原子的,会有数据不一致的并发问题。
接下来就来讲解上面实验为什么会出现那样结果, 这里判断ticket是否大于0就是运算,并且它并不是原子的,需要进行读取和判断两个步骤,假设现在的ticket为1,然后线程一将ticket的值从内存读取到寄存器中后,它的时间片就到了,此时它就需要将所有寄存器中的内容都保存到它的上下文中,这时候寄存器中的内容就属于线程私有的了,此时由于线程一都并没有修改内存中的ticket,所以内存中的ticket还是1,然后线程二、三、四同样如此,都是将ticket的值从内存读取到寄存器中后,时间片就到了,它们上下文中ticket的值都为1。当继续执行线程一时,首先需要恢复它的上下文,寄存器中ticket的值为1,就通过判断,执行下面的代码将ticket减为0了,当继续线程二时,也是先恢复它的上下文,寄存器中ticket的值为1通过判断,执行- -操作的时候,需要重新读取ticket的值,所以ticket就被减为-1,线程三、四也是同样的道理,由于寄存器中ticket的值都为1,就都可以通过判断,执行后序的- -操作,最终导致ticket减为负数的情况,为了解决这样的问题,就需要为临界区加锁。
3.3 互斥量mutex
3.3.1 互斥量的接口
互斥量的初始化:
#include <pthread.h>
// 动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
// 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
互斥量的初始化分为动态分配和静态分配,若我们想让互斥量为全局变量或是静态变量时,通常使用静态分配也就是使用宏来进行初始化。若我们想让互斥量为局部变量时,通常使用pthread_mutex_init函数进行初始化。
参数:
- mutex:指向要初始化的互斥量的指针。这个互斥量应该是一个已经声明但尚未初始化的 pthread_mutex_t 类型的变量。
- attr:指向互斥量属性的指针。这个参数是可选的,通常可以设置为 NULL 以使用默认属性。
返回值:
- 成功时返回 0。
- 失败时返回一个错误代码
互斥量的销毁:
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:
- mutex:指向要销毁的互斥量的指针。这个互斥量必须是被初始化过的,且当前没有被任何线程锁定。
返回值:
- 成功时,函数返回 0。
- 失败时,函数返回一个非零的错误码。
简单说明:
- 对于静态分配的互斥量,在程序结束时可以省略销毁操作,因为操作系统会在程序退出时回收所有资源。
- 对于动态分配的互斥量,则必须在不再需要时显式地销毁它,以避免资源泄露。
互斥量的加锁:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
功能:pthread_mutex_lock函数用于锁定指定的互斥量。如果互斥量已经被另一个线程锁定,调用线程将被阻塞,直到互斥量变为可用并被当前线程锁定为止。
参数:
- mutex:指向要锁定或尝试锁定的互斥量的指针。
返回值:
- 成功时,函数返回 0。
- 失败时,函数返回一个非零的错误码。
#include <pthread.h>
int pthread_mutex_trylock(pthread_mutex_t *mutex);
功能:pthread_mutex_trylock 函数也用于锁定指定的互斥量。但是,如果互斥量已经被另一个线程锁定,调用线程不会被阻塞。相反,函数会立即返回一个错误码,表明互斥量当前不可用。
参数:
- mutex:指向要锁定或尝试锁定的互斥量的指针。
返回值:
- 成功时,函数返回 0,表示互斥量已被当前线程锁定。
- 如果互斥量被另一个线程锁定,函数返回 EBUSY。
- 失败时,函数还可能返回其他非零错误码。
互斥量的解锁:
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数:
- mutex:指向要解锁的互斥量的指针。这个互斥量必须是当前线程已经锁定的。
返回值:
- 成功时,函数返回 0。
- 失败时,函数返回一个非零的错误码。
3.3.2 互斥量的使用
这里我们对上面的代码进行了改进,对临界区进行了加锁和解锁的操作,运行程序观察现象,这里我们很明显的看到了票没有被减到负数的情况,并且在程序运行的时候,我们能很明显的感受到程序的运行速度变慢了,这是因为临界区中的代码由并行变为了串行。
在加锁的过程中,我们要尽可能的给少的代码块加锁,因为加锁的代码块会由并行变为了串行,并且一般加锁都是给临界区加的。
在下面的代码中,我们就只在访问公共资源的部分加锁了,又一个线程只会进入if或else其中一个,所以我们需要在两个代码块中都带上解锁的代码。
这里提几个在使用互斥量时可能会产生的一些疑问。
问题:线程想要访问临界区资源,就需要申请锁,又由于我们这里定义的锁是全局的,这里会不会出现多个执行流同时申请锁,从而互相影响的情况呢?
答:不会,因为申请锁的操作是原子的,本身是安全的,并不会使多个执行流互相影响。
问题:那么可以可以一部分线程访问临界区资源时申请锁,而另一部分不申请锁呢?
答:不可以每个线程访问临界区资源的时候都必须要先申请锁,这是程序员自己保证的。
问题:根据互斥的定义,任何时刻只有一个线程可以申请成功,那么申请失败的线程会怎么样呢?
答:申请失败的线程会在mutex上进行阻塞,本质上就是等待。
问题:一个线程申请锁成功后,访问临界区资源时,可不可以发生切换呢?
答:可以,由于锁的存在,其他的线程无法进入该临界区中访问资源,所以是可以发生切换的。
上面我们使用的是全局变量的锁,这里我们演示一下局部变量的锁如何创建和销毁,局部变量的锁需要作为参数传给各个线程,我们可以直接将锁的地址传给线程,也可以被封装作为结构体成员,再将结构体对象的地址传给线程,我这里只是单纯看看现象,就直接传锁的地址了,观察下面的代码和程序运行结果,我们发现也没有发生剩余票数减到负数的情况。
3.3.3 互斥量实现原理探究
经过上面的例子,大家已经意识到单纯的++、- - 操作都不是原子的,有可能会有数据一致性问题为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
下面是加锁的伪代码,首先将寄存器的内容置为0,然后将寄存器中的数据与内存中的数据进行交换,就假设内存中的数据为1,代表当前可以申请锁资源,有了锁资源后才可以继续执行后面的代码。
有人就会问了,加锁过程中看着有这么多代码,加锁真的原子的?
加锁就是原子的,首先将寄存器中的内容修改为0,切换线程时会将寄存器中的内容带走,并不会影响其他线程,然后是将寄存器中的数据与数据交换,这是体系结构中提供的指令就是原子的,交换以后,寄存器中的内容为1,内存中的内容为0,代表锁资源已经被申请了,即使做完后切换线程,其他线程中的寄存器与内存中的数据进行交换后,寄存器中的内容还是0,也无法再申请到锁资源,最后需要判断线程是否申请到锁资源,没有申请到的则不能继续后面的操作,所以整体来说加锁操作就是原子的。
下面是解锁的伪代码,将寄存器中的数据与数据交换,交换以后,寄存器中的内容为0,内存中的内容为1,代表其他线程可以申请锁资源了。解锁就更不用说了,必须要有锁资源才能解锁,所以解锁过程也是原子的。
四、可重入与线程安全
4.1 概念上的区别
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。线程的安全与否描述的是线程的特征。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。是否可重入描述的是函数的特点,可重入与不可重入并没有好坏之分。
4.2 常见的线程不安全的情况
- 调用不保护共享变量的函数
- 调用函数状态随着被调用,状态发生变化的函数
- 调用返回指向静态变量指针的函数
- 调用线程不安全函数的函数
4.3 常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
4.4 常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
4.5 常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
4.6 可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
4.7 可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的
五、常见锁概念(死锁)
5.1 死锁的概念
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
下面就以两把锁为例,假设有锁1和锁2,线程1和线程2如下图需要同时申请到两把锁才能继续执行下面的代码,这时候线程1申请到了锁1,线程2申请到锁2,这样就造成了死锁了,因为线程1需要锁2,但是线程2不会释放锁2,线程2需要锁1,线程1不会释放锁1,这样两个进程都无法向后推进,都卡住了。
这里再以一把锁为例,大家是否觉得一把锁就不可能造成死锁问题了?其实不然,一把锁也可能导致死锁问题,这里假设有一个非常粗细的程序员,在一个线程已经申请到锁资源后,本应该解锁,但是它写成了加锁,这时候没有任何一个线程可以释放锁资源,这个线程也不可能申请到锁资源,这个线程就再也不可能向后执行了,所以一把锁也可以造成死锁问题。
5.2 死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
还是上面两把锁的例子,线程1申请到了锁1,线程2申请到了锁2,这两个线程都想要对方的锁,但是都不想释放自己的锁。 - 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
还是上面两把锁的例子,线程1申请到了锁1,线程2申请到了锁2,这两个线程都想要对方的锁,假设线程1的优先级更高,但是也并不会因此释放掉线程2的锁,这就是不剥夺。 - 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
还是上面两把锁的例子,线程1申请到了锁1,线程2申请到了锁2,这两个线程都想要对方的锁,这就造成了循环等待。
5.3 避免死锁
- 破坏死锁的四个必要条件
- 破坏互斥条件,就是不使用锁。
- 破坏请求与保持条件,就是线程1申请到了锁1,线程2申请到了锁2,此时线程1申请不到锁2,就将自己锁1的资源释放。
- 破坏不剥夺条件,通过监控系统的资源请求和分配情况来检测死锁,并采取适当的恢复措施,如终止涉及死锁的线程、回滚操作或重新分配资源。
- 破坏循环等待条件,就是加锁顺序一致,将上图线程1和线程2申请锁的顺序改为一致。
- 避免锁未释放的场景
- 资源一次性分配
5.4 避免死锁算法
- 死锁检测算法(了解)
- 银行家算法(了解)
六、Linux线程同步
6.1 线程同步的概念
在临界资源安全的前提下,让多线程执行的有一定的顺序性。当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作,其他线程才能对该内存地址进行操作。在这个过程中,其他线程处于等待状态。同步能够较为充分高效的使用资源。
6.2 条件变量
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
6.2.1 条件变量的相关接口
条件变量的初始化:
#include <pthread.h>
// 动态分配
int pthread_cond_init(pthread_cond_t *cond,
const pthread_condattr_t *attr);
// 静态分配
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
条件变量的初始化分为动态分配和静态分配,若我们想让条件变量为全局变量或是静态变量时,通常使用静态分配也就是使用宏来进行初始化。若我们想让条件变量为局部变量时,通常使用pthread_cond_init函数进行初始化。
参数:
- cond:指向需要被初始化的条件变量的指针。
- attr:指向条件变量属性的指针。这个参数是可选的,通常可以设置为 NULL 以使用默认属性。
返回值:
- 成功时返回 0。
- 失败时返回一个错误代码
条件变量的销毁:
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
参数:
- cond:指向要销毁的条件变量的指针。
返回值:
- 成功时,函数返回 0。
- 失败时,函数返回一个错误码。
简单说明:
- 对于静态分配的条件变量,在程序结束时可以省略销毁操作,因为操作系统会在程序退出时回收所有资源。
- 对于动态分配的条件变量,则必须在不再需要时显式地销毁它,以避免资源泄露。
条件变量的等待:
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
功能:pthread_cond_wait 函数会使调用线程进入等待状态,并自动释放与条件变量关联的互斥锁。
参数:
- cond:指向要等待的条件变量的指针。
- mutex:指向与条件变量关联的互斥锁的指针。
返回值:
- 成功时,函数返回 0。
- 失败时,返回一个错误码,但通常这个函数在成功触发条件变量之前不会返回。
条件变量的唤醒:
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
功能:pthread_cond_signal 函数用于唤醒等待某个条件变量的一个线程。如果有多个线程在等待该条件变量,那么具体唤醒哪个线程是不确定的,由操作系统的调度策略决定。
参数:
- cond:指向要唤醒等待线程的条件变量的指针。
返回值:
- 成功时,函数返回 0。
- 失败时,函数返回一个错误码。
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);
功能:pthread_cond_broadcast 函数用于唤醒等待某个条件变量的所有线程。
参数:
- cond:指向要唤醒等待线程的条件变量的指针。
返回值:
- 成功时,函数返回 0。
- 失败时,函数返回一个错误码。
6.2.2 条件变量的使用
在下面的代码中,我们创建三个新线程,让他们分别每隔一秒向显示屏中向显示屏中输出一次,运行程序观察现象,我们发现线程运行的顺序与线程创建的顺序不同,并且每一次线程运行的顺序也不一定相同。
这是因为多个线程使用cout,本质上就是多个线程向同一个显示器上打印,意味着所有的线程访问的都是同一个文件,宏观上来看显示器就是共享资源,打印的本质就是向显示器中写入,由于我们这里并没有对打印进行管理,所以这里可能会出现消息混乱的情况。
在下面的代码中,我们让线程执行之前先申请锁,申请成功后再进行条件变量的等待,被唤醒后才能继续执行后面的代码,运行程序观察现象。通过ps -aL命令来看确实有四个执行流,并且这时候显示屏上并没有任何输出,说明这时候这三个线程确实被阻塞了。
在下面的代码中,我们让线程执行之前先申请锁,申请成功后再进行条件变量的等待,被唤醒后才能继续执行后面的代码,然后我们在主线程中每隔一秒唤醒等待该条件变量的一个线程,运行程序观察现象,我们发现这次线程输出的非常有规律,并且每次输出的顺序都是相同的,这是因为它们在条件变量下进行排队。
除了每次唤醒等待该条件变量中的一个线程外,还可以唤醒等待该条件变量所有的线程,运行程序观察现象,我们发现每隔一秒就有三次输出,但是并没有每次唤醒一个线程输出那么有序。
这里我们想简单模拟一下抢票逻辑,每隔一段时间就放一次票,但这里先只放一次票,创建三个线程去抢票,有票则输出对应的线程名和票号,没抢到就输出没票了,运行程序观察现象,我们发现一瞬间票就没了,所有的线程都在输出没票了,没票了所有的线程都应该等待,而不是在这里申请锁、输出、释放锁,在这里浪费锁资源,这样会导致其他线程的饥饿问题。
在访问临界资源的时候,通常需要先判断,判断也是访问共享资源,所以先加锁,在下面的代码中,我们在临界区的前后进行了加锁和解锁,实现了线程间的互斥,但是通过下面的现象来看,单纯的互斥可能保证数据安全,但并不一定合理和高效。
这里我们对上面的抢票逻辑进行修改,使用条件变量在没票的情况下,让所有的线程都进行等待,运行代码,我们发现票被抢完后,所有的线程都输出没票后就停止下来了,这些线程确实在等待了。
这里再调整一下抢票逻辑,让主线程每隔五秒放1000张票,这里主线程也访问了共享资源所以也要申请锁,然后唤醒线程,可以唤醒一个线程,也可以唤醒全部线程,运行程序观察现象,我们发现线程抢完票后,输出没票了就停下了,然后过了几秒后,又重新进行抢票了。
在下面的代码中,我们发现线程是在临界区中进行等待的,意味着这个线程并没有解锁,是否意味着其他线程就无法再申请到这个锁资源了呢?并不是的, 线程在进行等待的时候,会自动将锁释放掉。线程被唤醒的时候是在临界区中的,所以当线程被唤醒,在pthread_code_wait函数中返回时,需要先申请并持有锁才能继续执行后序的代码。我们这里有三个线程,但是只有一把锁,所以线程被唤醒的时候,重新申请的本质就是与其他线程重新竞争锁。
结尾
如果有什么建议和疑问,或是有什么错误,大家可以在评论区中提出。
希望大家以后也能和我一起进步!!🌹🌹
如果这篇文章对你有用的话,希望大家给一个三连支持一下!!🌹🌹
原文地址:https://blog.csdn.net/qq_55401402/article/details/144221049
免责声明:本站文章内容转载自网络资源,如侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!