前言:本篇是关于Linux操作系统中的线程,不同操作系统实现线程的方式是不一样的!Linux中是通过轻量级进程来实现线程的。这里谈的线程本质是轻量级进程
1. 从感性角度认识一下Linux中的线程
举个例子:
一个家庭就是一个进程,家庭中的每名成员就是线程,为了使得这个家庭更好,每位成员都需要各司其职。对于进程而言,进程就相当于家庭,线程就相当于家庭成员,为了让进程运行好,每个线程也都必须做好自己的事情
注:
不同家庭之间,会各自独占自己的资源,对于同一个家庭内的成员来说,大多数资源是可以共享的。
对于进程也是如此,不同进程间强调资源的独占,而单一进程内的线程则强调资源的共享
2. 从理性角度认识一下Linux中的线程
2.1 操作系统如何对物理内存做管理
Linux操作系统中,磁盘以及物理内存都是以4kb划分的,这种4kb大小的数据块被称作页框/页帧,磁盘和物理内存间是以4kb进行IO交换的。假设物理内存的大小为4GB,那么整个内存一共就会有1048576个页框/页帧。
每个物理内存是否被占用、哪些是作为共享内存被使用、哪些是未被使用的都需要做管理。
因此对于每个4kb的页框/页帧而言,操作系统是通过 struct page 来对每个页框/页帧的属性进行描述,这样 1024个page 是通过 struct page mem[1024]大小的数组来管理的。
但每个页框/页帧中不直接记录当前页框/页帧的实际物理地址!
而是通过:物理内存的起始地址 + 4kb*数组下标,就可以知道每个page的真实地址!
注:操作系统申请内存永远都已4kb为单位,用户访问操作系统可以单一字节访问,所以某个变量发生写实拷贝时,不是给变量申请单一变量大小的空间,而是申请4kb的资源空间。
问:有了上述认识后,请问申请物理内存是在做什么?
答:查数组(查是否被占用),修改page(改页框/页帧信息),建立内核数据结构到物理内存的映射关系
2.2 重新认识Linux中的页表
假设物理内存和虚拟内存的大小均为4GB,先前的学习中,我们知道,虚拟地址和物理内存之间是通过页表来建立映射关系的,每个虚拟地址对应一个物理地址。
问:那么页表究竟是什么样子的?难道是一个虚拟地址就对应一张表和一个物理地址吗?
答:显然不是,如果这样,光页表数量就占据了所有的空间。
Linux操作系统中,真实的页表样貌:
虚拟地址的构成:
👉:高10位为一级页号,也叫页目录,CPU在查号时,先查找前1024个,页目录中一共1024个位置,每个位置存储的是下一级页表的地址
👉:下来10位位二级页号,也叫页表项,页表项一共1024个位置,每个位置存储的是物理内存中页框/页帧的起始地址!因为物理内存一共4GB,每个页框/页帧4kb,对应一共有1048576个,而虚拟地址的前20位每个正好对应1024*1024 = 1048576 正好和物理内存中页框/页帧的个数对应,因此这样就能建立映射关系。
👉:后12位为偏移量,上面我们说了,页表中真实存储的是每个物理内存中每个页框/页帧的起始地址,通过页框/页帧的起始地址+后12为的偏移量就能访问具体数据
问:为什么是12位?
答:一个页框/页帧的大小为4kb,4kb对应的比特位为 0000 0000 0000 即2^12,因此是12位,通过12位就能访问单个页框中所有的地址。
认知刷新:
有了上述认识就该明白:其实页表中没有虚拟地址,也没有真实的物理地址,他是通过虚拟地址下标、页框地址、也框内地址偏移量来维护的。每个进程由:一张页目录+n张页表项构建的映射体系,虚拟地址是索引,物理地址是目标。
问:虚拟地址用用户层由虚拟地址空间给用户、内核提供虚拟地址,那么怎么找到当前进程的虚拟地址?
答:CPU中存在一个CR3寄存器,当前进程的CR3寄存器指向的是当前进程的页目录,CR3又称为当前进程的硬件上下文,所以进程一旦切换,PCB、页表所有东西都切换了。CPU内部还集成了MMU内存管理单元,负责将虚拟地址转为物理地址,CPU内部的是虚拟地址,从CPU出来时已经是物理地址了,是通过MMU硬件转换完成了虚拟地址向物理地址的转化
注1:现在再来想想页表查询失败是什么情况?根据虚拟地址去查页表,发现对应页表不存在,虚拟地址合法是因为该地址位于虚拟空间中,但是页表不存在,说明磁盘当中的代码和数据没有加载到内存中,所以就发生了写实拷贝,就触发了虚拟地址向物理地址转化失败,所以就触发中断,执行对应的调度算法,申请算法,然后申请内存, 就会访问内存对应的数据结构,去找哪些内存块(page)没有被使用,有了下标,整个页框的物理地址就有了,因为真实的物理地址 = 起始地址+下标*4, 然后再把页框地址填到页表中。
注2:写实拷贝、缺页中断、内存申请,背后都是可能要重新建立新的页表和映射关系的操作。
注3:MMU 是 CPU 内部的一个硬件组件,用于管理虚拟内存与物理内存之间的映射关系。它的主要任务是 将虚拟地址转换为物理地址,并在此过程中实现内存的保护、分配、共享以及隔离等功能
2.3 有关页表的其他注意点
2.3.1 多级页表的优缺点
单级页表对连续内存要求高,于是引入了上面介绍的多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
问:有没有一种提升效率的方法?
答:计算机科学中的所有问题都可以通过引入一层中间层来解决。MMU引入了TLB(本质是缓存,Translation Lookaside Buffer)。当cpu给MMU传新的虚拟地址之后,MMU会先去访问TLB那边有没有,如果有就直接拿到物理地址发到总线给内存。但是TLB的容量很小,难免会发生Cache Miss,这时候只能通过查页表,找到之后除了把地址传给内存外,还要把这条映射关系发给TLB。
2.3.2 如何区分是越界访问还是缺页中断
首先需要知道的是:new 和 malloc 函数不会在真实的物理内存中开辟空间,而是在虚拟地址空间中。其次:越界了不一定会崩溃,因为越界的部分可能存在合法数据,但是OS不知道你其实越界访问了,这个只能通过程序员自己debug去发现问题。
问:如何区分是越界访问 or 缺页中断?
答:
1.页号合法性的检查:OS在处理中断或者异常时,首先检查触发中断事件的虚拟地址的页号是否合法。如果页号合法但页面不存在内存中,则会缺页中断;如果非法则为越界访问。
2.内存映射检查:OS还可以检查出发时间的虚拟地址是否在当前进程的内存映射范围内。如果地址在映射范围内但页面不存在内存中,则为缺页中断;如果地址不在映射范围内,则为越界访问。
注:问 -> 如何判断页号是否合法?答 -> 虚拟地址空间会通过vm_struct 对每块空间进行划分,页号是否合法只要判断是否处于对应的区域内即可。
2.4 Linux中的线程
有了页表的详细认识,现在再来谈谈Linux中的线程。
线程进行资源划分:本质是划分地址空间,获得一定 范围的合法虚拟地址,再本质就是划分页表
线程进行资源共享:本质就是对地址空间的共享,再本质:就是对页表条目的共享
认知:在一个进程中,存在多个轻量级进程,对一块虚拟地址空间进行区域的划分
轻量级进程共享进程的:
①.虚拟地址空间
②.文件描述符
③.信号
④.优先级
⑤.时间片
⑥.当前进程的pid
⑦.信号量
轻量级进程独占的资源:
①.线程的id
②.寄存器,存储当前线程的属性(其实就是这些独占的资源),保证当前线程暂停后能够恢复执行
③.栈
④.优先级
注:②、③是比较重要的。
2.4.1 线程的优点
①.创建一个新的线程的代价会比创建一个新的进程小的多
注:线程是在进程的基础上创建的,很多资源都是共享的,所以会小很多
②.线程占用的资源比进程少
③.在等待慢速 I/O 操作结束时,程序可执行其他计算任务
④.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
注:计算密集型是什么?→ 加密/解密 /将多数据的进程拆分成多份,让多份线程执行,最后再多路归并。显卡/大模型(大模型依靠的就是显卡)就是典型的计算密集型
⑤.I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的 I/O 操作
注:I/O密集型时什么? → 访问磁盘或者网站上下载资源,假设4gb资源,如果是单一线程,可能对方的数据没有准备好,这样下载就很慢,但是可以通过多线程下载,比如一个线程下载1gb,一共四个线程,这样就变成了并行下载
2.4.2 详谈为什么线程占用资源比进程小
①.最主要的原因是因为:线程调度时,因为同一进程内的线程所在的虚拟地址空间是相同的,而不同进程切换调度时的虚拟地址空间是不同的。这两种上下文切换的处理都是通过操作系统的内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
②.另一个原因是因为:上下文的切换回扰乱处理器的缓存机制,简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间就作废了。还有一个显著的区别就是当改变虚拟地址空间的时候,处理页表的TLB会被全部刷新,这将导致内存的访问在一段时间内是相当低效的。但是线程切换的时候就不会出现这些问题。
注:如果新的线程属于另一个进程的话,那就始于进程切换的范畴了,而非线程切换,所谓的线程切换一定是在同一进程当中的。
3. Linux线程控制
在介绍线程控制之前需要对一个概念进行进一步的阐述:
上面提到,Linux中只存在轻量级进程,并不存在线程,而对于其他操作系统,比如windows操作系统,线程是真实存在的,广大用户也是按照那一套操作系统学习的。当用户想使用linux操作系统时,却发现linux中不存在线程,为此linux开发人员为了能够使得linux走向大众,特别封装了一个pthread库函数,来实现对Linux中轻量级进程的创建和调用,pthread是属于用户级别的,他是库函数并非系统调用。
所以使用这些库函数时:必须包含 头文件pthread.h And 链接库函数(-lpthread)
注1:pthread底层调用的是clone,知道即可。
3.1 创建线程
int pthread_create ( pthread_t *thread, const pthread_attr_t *attr, void * (*start_routine)( void *), void *arg);功能:创建一个线程参数:thread:返回线程IDattr:设置线程的属性,nullptr表示使用默认属性start_routine:函数地址,线程启动后要执行的函数arg:传给线程启动函数的参数注:这个参数可以是任意值返回值:成功返回0,失败返回错误码demo代码:
当我们的程序在执行时,在xshell上输入ps -aL 可以查看当前正在运行的线程,得到如下图所示的结果:
可以看到两个线程,他们的PID是相同的,主线的LWP和PID相同,新线程不同。问:这个LWP是什么?答:LWP(light weight process),即轻量级进程,线程的PCB中还包含了LWP.问:线程调度时,看PID还是LWP?答:LWP,因为线程调度属于轻量级进程,单线程时,PID和LWP是相同的。注1:同一进程内的线程是等分共享进程的时间片的注2:同一进程内的任一线程出异常崩溃都会导致整个进程的崩溃。
pthread_create的第一参数是返回线程的id,当我们打印出来时,其结果如下图:
可以看到,此id既不是PID也不是LWP
问:那么这个id是什么?
答:这个id其实是pthread库给每个线程定义的进程内的唯一标识,是pthread库维持的。
举一个例子:每个人都有自己身份证,用于区别自己在社会上的身份。而每名学生在自己的学校内又有自己的学号,为什么在学校不用身份证呢?因为身份证一旦发生改变,那么学校系统的记录就也得跟着变化,所以学校可以自己做一套标准来避免出现这个问题。
注:这个id是进程级而非系统级,内核不认识你这个id。
问:那么这个很长的id是什么呢?
答:是pthread库中的一个虚拟地址。
3.2 线程终止
①. 从线程函数return
②.线程调用pthread_exit 终止自己
原型:void pthread_exit(void *value_ptr)
参数 : value_ptr:value_ptr不要指向⼀个局部变量。注:不能调用exit来终止自己,因为exit是用来终止进程的,exit会使得整个进程终止③.一个线程可以调用pthread_cancel终止同一进程中的另一个线程原型 : int pthread_cancel ( pthread_t thread);参数 : thread:线程 ID返回值:成功返回 0 ;失败返回错误码
3.3 线程等待
因为已经退出的线程,其空间没有被释放,仍然在进程的地址空间内,其次新创建的线程不会服用刚才退出线程的地址空间,因此需要线程等待去释放其地址空间,这和进程等待是类似的。
原型:int pthread_join ( pthread_t thread, void **value_ptr);功能:等待线程结束参数:thread:线程 IDvalue_ptr:它指向⼀个指针,后者指向线程的返回值返回值:成功返回 0 ;失败返回错误码注: 调⽤该函数的线程将挂起等待,直到id为thread的线程终⽌。thread线程以不同的方法终⽌,通过 pthread_join得到的终⽌状态是不同的,总结如下:1. 如果thread线程通过return返回,value_ ptr所指向的单元⾥存放的是thread线程函数的返回值。2. 如果thread线程被别的线程调⽤pthread_ cancel异常终掉,value_ ptr所指向的单元⾥存放的是常 数PTHREAD_ CANCELED。3. 如果thread线程是⾃⼰调⽤pthread_exit终⽌的,value_ptr所指向的单元存放的是传给pthread_exit的参数。4. 如果对thread线程的终⽌状态不感兴趣,可以传NULL给value_ ptr参数。注:一定要进行线程等待,否则会出现主线程中return 0; 当前线程全部终止,而子线程尚未执行的情况!
3.4 分离线程
默认情况下,新创建的线程是joinable(当前线程状态),线程退出后,需要对其进行pthread_join操作,否则无法释放资源,造成系统泄漏。
如果不关心线程的返回值,我们可以告诉系统,当线程退出时,自动释放线程资源:
int pthread_detach(pthread_t thread);
3.5 有关线程控制的其他问题
3.5.1 主新进程的优先级
问:主新线程谁先运行?
答:不确定。
3.5.2 重谈线程等待
问:pthread_join的第一参数是线程pid,而第二个参数是一个二级指针,这是为什么?
答:当一个新建线程结束时,他会返回一个一级指针,该一级指针会指向某些数据,所以我们需要一个二级指针去接收
4. 线程id以及进程地址空间布局
从上面的学习中,我们知道了Linux没有真正的线程,他使用轻量级进程来模拟的线程→OS提供的接口,不会直接提供线程接口→所以在用户层封装ptrhead库来封装轻量级进程。
而不论是我们自己写的代码,还是动态库,形成进程时,都需要动态链接 And 动态地址重定向,要将动态库,加载到内存 && 映射到当前进程的地址空间中!
同时线程id是在pthread中维护的,既然线程需要在库内部进行维护,那么必定会出现这样的情况:pthread库中需要对多个线程进行维护。因此库需要对线程进行管理,即先描述,再组织。
例如:
struct TCB
{//线程应该有属性
线程状态
线程id
线程的独立结构栈
线程的大小
...
}
当我们调用库函数 pthread_create创建线程时,一个TCB(线程控制块)结构体对象。
这就好比当初学C语言文件系统的时,FILE* fp = open()时会创建一个文件对象一样。
注:需要注意的是,上述TCB中没有出现 时间片、优先级、上下文等概念。
问:这是为什么?
答:因为tcb是用户层面的,像时间片、优先级、上下文这类信息是属于内核层面的,存储在LWP的PCB当中
4.1 如何将多个线程组织起来?
答:pthread库在mmap区域中进行维护,每当一个线程创建时,就会创建一个管理块结构体,pthread可以通过数组结构来维护多个线程的信息。
注:线程id其实就是每个线程管理块(控制块)的起始地址。
问:当我们调用pthread_create()时,发生了什么?
答:在pthread动态库中创建一个管理块(TCB),而TCB中有一个属性为 void*ret,也就是说对应控制块的代码如果执行完return时,就会把对应的返回值传到对应TCB的ret中。线程运行结束了,但是对应的管理块的数据没有被释放,所以线程需要pthread_join。其中pthread_join的第一个参数就为对应线程控制块的起始虚拟地址,第二个参数为该线程管理块tcb中ret存储的数据(当时线程对应函数退出时的返回值),最后再释放整个管理块。
注1:因为ptrhead库属于用户区,因此用户可以直接通过虚拟地址进行访问。
注2:对于主线程而言,他会使用虚拟地址空间中的栈,而对于新线程而言,他会使用线程栈
4.2 线程栈从何而来?
答:再来重谈一下pthread_creat函数。
当我们pthread_create 创建一个线程时,他会在
1.pthread库中创建线程控制管理块
2.在内核中创建轻量级进程(通过系统调用,执行什么方法,栈在哪里)
注1:在内核创建轻量级进程的过程中,内核会给线程分配一个独立的栈(通过mmap来创建栈空间)用来保存线程的局部变量(存储对应函数方法的临时变量)和执行的上下文,这个栈在用户空间进行分配,就是TCB中的线程栈
注2:pthread_create创建时将自定义方法的地址传给clone,clone会将该方法传给pcb,让pcb执行。
有了上述两个认识,当cpu调度线程时,他会转而去执行用户的自定义方法,同时形成的临时数据会自动入线程栈。
认知刷新:所谓的用户线程,其实就是描述线程的相关属性即可,剩下的就交给底层(轻量化进程)来处理,比如执行方法。 在别人看来你有线程,但其实都是底层(轻量化进程)在干事。唯一要做的就是,当轻量级进程执行完毕时,把结果返回到 TCB的 void *result(ret)当中,如果线程不分离,可以通过pthread_join的第二个参数来接收这个返回值
问:线程分离是个什么情况?
答:线程控制块中有一个线程状态属性,默认为joinable,分离后变为!joinable,本质为标记位
5. 使用pthread库函数简单封装一个线程
头文件:
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <pthread.h>
#include <functional>
namespace jc
{
static int number = 0;
class thread
{
using fun_c = std::function<void()>;//function是一个类模板 function<void()>类型, 要传一个返回值为void,无参数的函数给 fun_c
static void* Route(void* args) //如果不是非静态成员函数变量,会包含this指针,这样默认就是两个参数,会出现参数不一致的情况,所以需要改为静态函数
{
thread* self = static_cast<thread*>(args);
self->EnableRuning();
if(self->_isdetach)
self->Deatch();
self->_func();
return nullptr;
}
void EnableRuning()
{
if(_isruning)
return;
_isruning = true;
}
void EnableDetach()
{
if(_isdetach)
return;
_isdetach = true;
}
public:
thread(fun_c func)
:_tid(0),
_isruning(false),
_isdetach(false),
_func(func)
{
_name = "thread-" + std::to_string(number++);
}
//创建线程
bool Start()
{
if(_isruning)
{
std::cout << "已经运行了" << std::endl;
return false;
}
int n = pthread_create(&_tid,nullptr,Route,this);
if(n != 0)
{
std::cerr << "create error" << std::endl;
return false;
}
return true;
}
//停止正在运行的线程,但是他的内存并不会得到释放
bool Stop()
{
if(!_isruning)
return false;
int n = pthread_cancel(_tid);
if(n != 0)
{
std::cerr << "stop error" << std::endl;
return false;
}
_isruning = false;
std::cout << "stop" << std::endl;
return true;
}
//分离
bool Deatch()
{
if(_isdetach)
return false;
if(_isruning)
pthread_detach(_tid);
EnableDetach();
return true;
}
//线程等待
void Join()
{
if(_isdetach)
{
std::cout << "已经分离,无法join" << std::endl;
return;
}
int n = pthread_join(_tid,nullptr);
if(n != 0)
{
std::cerr << "create error" << std::endl;
}
std::cout << "join success" << std::endl;
}
~thread()
{
}
private:
pthread_t _tid; //线程id
std::string _name; //线程名
bool _isruning; //当前线程是否正在运行
bool _isdetach; //当前线程是否被分离
fun_c _func; //当前线程所需执行的自定义方法
};
};
主函数:
#include "thread.hpp"
#include <unistd.h>
using namespace jc;
int main()
{
//通过lambda表达式传入自定义函数
thread t([]()
{
while(true)
{
std::cout << "i am a new process, and i say : hello kivotos" << std::endl;
sleep(1);
}
});
t.Start(); //创建线程
sleep(5);
//t.Deatch();//分离线程
t.Stop();//停止线程
sleep(1);
t.Join();//回收线程
return 0;
}
5.1 代码细节
std::function的复习:
std::function是C++11中的一个类模板,它可以调用任何可以调用的目标,如普通函数、lambda 表达式、函数对象、成员函数
std::function<void()> 是一个类型,通过该类型我们可以这样定义一个变量:
std::function<void()> t; 可以将任何没有返回值且无参的函数传递给t,如图所示:
std::function<int(int x, int y) t 可以将返回类型为int,参数为 int int 的函数传递给t
lambda表达式的复习:
lambda表达式本质是一个匿名函数对象。
表达式:lambda( [] () ->return type {} );
[]:捕捉列表
():函数参数
->:返回参数,没有可以省略
{} :函数主体
上述代码中有这样一段代码:
thread t([]() { while(true) { std::cout << "i am a new process, and i say : hello kivotos" << std::endl; sleep(1); } });
该段代码的意思是:无捕捉、无参数、返回值为void、函数体为while的循环打印
thread类中,他的构造函数是这样的写的:
thread(fun_c func) :_tid(0), _isruning(false), _isdetach(false), _func(func) { _name = "thread-" + std::to_string(number++); }
其中 fun_c 的定义为:using fun_c = std::function<void()>;
也就是说:fun_c 可以接收一个返回值为void,且无参的函数,而上述代码中的lambda表达式正是如此,所以二者一一对应,该lambda表达式能够被fun_c接收。
问:对于类中由static修饰的类函数如何使用类中的成员变量,或者其他类函数?
答:可以将this指针作为参数传递给该静态函数。
class thread { public: pthread_create(&_tid,nullptr,Route,this /*this指针*/ ); static void* Route(void* args) { thread* self = static_cast<thread*>(args); if(self->_isdetach) self->Deatch(); self->_func(); return nullptr; } void Deatch() {} private: std::function<void()> _func; bool _isdetach; };