文章目录
- [1. 线程概念](#1. 线程概念)
- [2. Linux下线程的概念与实现](#2. Linux下线程的概念与实现)
- [3. 理解资源划分](#3. 理解资源划分)
-
- [3.1 虚拟地址和页表的由来](#3.1 虚拟地址和页表的由来)
- [3.2 物理内存的管理](#3.2 物理内存的管理)
- [3.3 页表](#3.3 页表)
- [3.4 缺页中断](#3.4 缺页中断)
- [4. 线程的相关问题](#4. 线程的相关问题)
- [5. 进程VS线程](#5. 进程VS线程)
- [6. 线程控制](#6. 线程控制)
-
- [6.1 线程创建](#6.1 线程创建)
- [6.2 线程终止](#6.2 线程终止)
- [6.3 线程等待](#6.3 线程等待)
- [6.4 线程分离](#6.4 线程分离)
- [7. 线程ID及进程地址空间布局](#7. 线程ID及进程地址空间布局)
1. 线程概念
在之前的学习中,我们知道进程是一个运行起来的程序。我们将进程定义为:进程 = 内核数据结构+代码和数据。
那什么叫线程呢?
线程首先是一个执行流,它的执行粒度比进程更细,是进程内部的一个执行分支。
在今天,我们要更新一些认识:
- 进程是承担分配系统资源的基本实体。
- 线程是操作系统调度的基本单位。
2. Linux下线程的概念与实现
我们知道,当在Linux中创建一个进程时,我们实际上是创建了进程的task_struct、mm_struct、vm_area_struct、页表,然后加载数据和代码,将上面所说的合起来统称为进程。
如果Linux要支持线程,那么线程就必须要被管理起来,就要重新搞一套类似于进程的管理方式tcb(类似pcb这样的东西),并且进程中要挂很多tcb这样的结构(因为线程是进程的执行分支),可是这样也太复杂了吧。
那能不能直接将pcb等价于tcb呢?
既然线程的本质并不是调度,因为进程能被调度;线程的本质是在进程内运行,而且粒度比进程更细;而且进程的结构(状态、优先级等)线程都要有,所以在Linux中,线程是使用进程模拟实现的。
在进程创建线程时,在进程的地址空间内,只需要创建task_struct结构体,然后把各种属性一填,只不过线程和进程共享同一个地址空间;如果再能将代码区拆分给每个线程,此时进程就被拆解了,我们就把进程的每一个执行分支叫线程。
这样一来,就省去了很多的事情,直接复用进程的即可。线程是进程内部的一个执行分支,也就是线程在进程的地址空间上运行。
可是进程和线程是一样的task_struct,那它们怎么区分呢? - - 不区分,它们都叫做执行流,统一叫做线程。
进程应该是:task_struct+地址空间+页表+代码和数据,进程根本不是一个PCB能够描述的。
那之前学的进程是错的吗? - - 不是,只不过之前学习的进程中只有一个执行分支,即只有一个线程。
之前学习的关于进程的话题,都是当前线程话题的一种特殊情况。
既然都是"线程",那CPU看到的 task_struct的数量应该 >= 进程数量 (只有一个线程时,task_struct = 进程;多个线程时,看到的task_struct只是进程的一部分),所以Linux中的执行流,统一称为轻量级进程
(Light Weight Process)。
叫做轻量级进程是因为Linux中,没有真正的线程,只有线程的概念,是使用LWP模拟实现的。
验证线程:
函数:pthread_create
,编译链接时需指明库:-lpthread
要想查看轻量级进程,可使用命令:ps -L
,a选项是显示所有进程的线程信息;不加a选项,显示当前 shell 会话中的进程及其线程信息。
现在看来,CPU调度区分执行流唯一性,不能再使用PID了,应该使用LWP。
3. 理解资源划分
透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
那进程究竟是如何将资源划分给线程的呢?
3.1 虚拟地址和页表的由来
思考⼀下,如果在没有虚拟内存和页表机制的情况下,每⼀个用户程序在物理内存上所对应的空间必须是连续的。如果程序在物理内存中的分布不是连续的,那么这种直接的地址映射将变得非常复杂,因为每次内存访问都需要进行复杂的地址转换,以确定实际访问的物理内存位置。
又因为每⼀个程序的代码、数据⻓度都是不⼀样的,按照这样的映射⽅式,物理内存将会被分割成各种离散的、大小不同的块。经过⼀段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎⽚的形式存在。
怎么办呢?我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续 。此时虚拟内存和页表便出现了,如下图所示:
3.2 物理内存的管理
在可执行程序运行时,它要被加载到内存,但并不是按照字节加载的,而是按照基本的数据块为单位进行的,因为文件系统是按照4KB数据块进行IO的,所以物理内存也是按照4KB划分好的,每个4KB叫做页框,如上图。
那操作系统是如何管理这么多4KB的内存块呢?
为了管理内存块,每个页框都要有⼀个对应的物理页(page),⼀个页的大小等于页框的大小。
- 内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page中使用了⼤量的联合体union,大小大概为40Byte。
- 就算 struct page 占40个字节的内存吧,假定系统的物理页大小为 4KB ,系统有 4GB 物理内存。那么系统中共有页框 1048576 个(1M个),所以描述这么多物理页的page结构体消耗的内存只不过1M × 40 Byte ≈ 40MB,相对系统 4GB 内存而言,仅占百分之一。
所以,对内存块的管理,就转换成了对struct page的增删查改。
实际上,在Linux源码中,page是使用数组存储的,只要找到了page,就会有一个下标,有了下标,就能够找到物理内存了。物理地址 = 4KB×下标, 下标 = 物理地址 / 4KB
3.3 页表
在以前我们学习页表时,只是粗粒度的认为:利用它能从虚拟地址转成物理地址,页表是一整块。
如果它是一整块的话
,那虚拟地址就需要与物理地址进行一 一映射,假设一个地址占4Byte,如果想将4GB物理内存全部映射,至少需要内存大小为: 4Byte × 2^32^ = 16GB。可是物理内存总大小才4GB,根本存不下,那页表究竟要怎么存呢?
为了解决这个问题,可以把这个单一页表拆分成 1024 个体积更小的映射表,如下图所示。
可是利用这两个表,也就只能覆盖4MB的空间,那一个32位的地址,究竟要怎么转呢?
这样一来,1024(每个表中的页表项个数) * 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。
而且将所有的页表4MB的空间都填满,我就可以找到任何一个页框,也就找到了任何一个地址。
但一个进程不可能使用物理内存的全部地址(有OS、page等等),这就意味着,进程可能只使用一个页目录表项+页表项
,就可以覆盖1024×4KB = 4M的内存,就足够一个进程用了。
以上根据虚拟地址查页表完成地址转换,其实就是 MMU 的⼯作流程。MMU(Memory Manage Unit)是⼀种硬件电路,其速度很快,主要⼯作是进行内存管理,地址转换只是它承接的业务之⼀。
到这里有个问题,MMU要先进行两次页表查询确定物理地址,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当页表变为N级时,就变成了N次检索+1次读写。可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低,
于是MMU 引入 TLB (Translation Lookaside Buffer,转换后备缓冲器,存储最近使用过的虚拟地址到物理地址的映射),当 CPU 给 MMU 传入新的虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,转换完毕。
如果TLB中没有找到匹配的虚拟地址(即TLB未命中),处理器需要访问页表来查找相应的物理地址。找到后,除了拿到物理地址发到总线给内存处理器以外,还会将这个新的虚拟地址到物理地址的映射存入TLB中,以便将来可以快速访问(因为代码可能存在循环)
。
3.4 缺页中断
思考一个问题:页表项(unsigned long 类型的一个数)
中存的是页框的地址,是32位。可是这32位真的全都能用到吗?
4GB / 4KB <====> 2^32^ / 2^12^ = 2^20^,所以,其实只需要20个比特位就可以表示所有页框的地址,最后的12 位全部为0。
那最后的12位能用来干什么呢?
至于页表项中剩下的12位,它们通常用于存储与页面相关的其他属性信息,包括但不限于:
- 存在位(是否命中):用于指示该页表项是否有效,即对应的页面是否存在于物理内存中。如果该位为0,则表示页面不存在于物理内存中,访问该页面时会引发
缺页异常
。- 读写位(权限标记位):用于控制对该页面的访问权限。如果设置为1,则页面可读写;如果设置为0,则页面只读(或可执行,具体取决于其他位)。
- 用户/超级用户位(U/K):用于控制哪些用户模式或特权级别的代码可以访问该页面。如果设置为0,则只有超级用户(如操作系统内核)可以访问;如果设置为1,则用户模式代码也可以访问。
在以前写的代码中,我们经常犯一个错误:
bash
char* str = "hello world";
*str = 'A';
此时我们的程序就会崩溃掉,那是什么原因呢?
当CPU给了MMU变量str的地址时,MMU会去页表中查,查到后对比权限位。
只读权限你却想写,MMU直接报错,CPU也就出错了,CPU直接把读写权限出错转换成软中断。然后中断向量表中直接预设一些中断服务,一旦触发就给OS发信号,然后OS发送给指定进程,进程崩溃。
所以,如果我们要访问的代码和数据只有一部分加载到内存了,若想访问剩余部分,此时查页表就无法命中,MMU会引发一个页错误中断。CPU这个捕获中断,并跳转到相应的中断服务程序,调用对应的中断服务(加载动作),加载完成后重新运行程序,就可以读取到剩余部分了。
这种没有命中而要求操作系统,通过软中断执行新加载逻辑的过程,叫做缺页中断。
所以回到最初的问题:进程是如何将资源划分给每一个线程的呢?
首先,进程根本不需要刻意的去划分资源。
因为我们在创建线程的时候,每一个线程都会去执行相应的函数,各个函数的虚拟地址本就不同,根据虚拟地址去页表中查,查的结果不同,每个线程都只会查页表的一部分,也就映射到不同的物理地址上,此时资源不自然的就被划分了吗?
4. 线程的相关问题
线程优缺点:
- 优点
- 创建⼀个新线程的代价要比创建⼀个新进程小得多
- 线程占用的资源要比进程少很
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多:(面试题 )
- 最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下⽂切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
- 另外⼀个隐藏的损耗是上下⽂的切换会扰乱处理器的缓存机制。简单的说,⼀旦去切换上下文,处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓存
TLB
会被全部刷新,这将导致内存的访问在⼀段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件CaChe(根据局部性原理,缓存代码和数据)
。
- 缺点
- 性能损失:线程过多,切换成本变高。
- 健壮性降低。在⼀个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制。因为资源是共享的,那么在⼀个线程中调用某些OS函数会对整个进程造成影响。
线程异常:
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终⽌,该进程内的所有线程也就随即退出。
线程用途:
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提搞IO密集型程序的用户体验(如⽣活中我们⼀边写代码⼀边下载开发⼯具,就是多线程运行的⼀种表现)
5. 进程VS线程
- 进程和线程
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的⼀部分数据
- 线程ID
⼀组寄存器
栈
- errno
- 信号屏蔽字
- 调度优先级
- 进程的多个线程共享资源
共享同⼀地址空间,Text Segment、Data Segment都是共享的,如果定义⼀个函数,在各线程中都可以调用,如果定义⼀个全局变量,在各线程中都可以访问到。除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前⼯作目录
- 用户id和组id
进程和线程的关系如下图:
6. 线程控制
6.1 线程创建
参数:
- thread:返回线程ID
- attr:设置线程的属性,attr为NULL表示使用默认属性
- start_routine:是个函数地址,线程启动后要执行的函数
- arg:传给线程启动函数的
参数
返回值:成功返回0;失败返回错误码
线程启动后要执行的函数start_routine的参数和返回值为void*的原因是:可以是任意类型的参数和返回值(变量、结构体、对象等等)!
pthread_self:获取当前线程的"ID"
。
这个"ID"是 pthread 库给每个线程定义的进程内唯⼀标识,是 pthread 库维持的。这个数实际上是⼀个地址,在虚拟地址空间上的⼀个地址,通过这个地址,可以找到关于这个线程的基本信息,包括线程ID,线程栈,寄存器等属性。
在 ps -aL 得到的线程ID,有⼀个线程ID和进程ID相同,这个线程就是主线程,主线程的栈在虚拟地址空间的栈上,而其他线程的栈在是在共享区(堆栈之间),因为pthread系列函数都是pthread库提供给我们的。而pthread库是在共享区的,所以除了主线程之外的其他线程的栈都在共享区。
6.2 线程终止
在某一个线程中调用exit终止时,进程将终止,所有线程都会终止。
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从
线程函数return
。这种方法对主线程不适用,从main函数return相当于调用exit。 - 线程可以调用
pthread_ exit
终止自己。 - ⼀个线程可以调用
pthread_ cancel
终止同⼀进程中的另⼀个线程。被取消的线程一定是处于运行状态!
需要注意:pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
cpp
std::string ToX(pthread_t id)
{
char buffer[32];
snprintf(buffer,sizeof(buffer),"%#lx",id);
return buffer;
}
void* func1(void* args)
{
//1.线程return
std::string name = reinterpret_cast<char*>(args);
int n = 1;
while(n--)
{
std::cout <<"我是new线程,name:" << name << ",id" << ToX(pthread_self()) << std::endl;
sleep(1);
}
std::cout <<"func1 return" << std::endl;
return nullptr;
}
void* func2(void* args)
{
//2.调用pthread_exit()
std::string name = reinterpret_cast<char*>(args);
int n = 3;
while(n--)
{
std::cout <<"我是new线程,name:" << name << ",id" << ToX(pthread_self()) << std::endl;
sleep(1);
}
std::cout <<"func2 调用pthread_exit" << std::endl;
pthread_exit((void*)10);//传10,retval = 10
}
void* func3(void* args)
{
//3.调用pthread_cancel()
std::string name = reinterpret_cast<char*>(args);
int n = 5;
while(n--)
{
std::cout <<"我是new线程,name:" << name << ",id" << ToX(pthread_self()) << std::endl;
sleep(1);
}
std::cout <<"func3 调用pthread_cancel" << std::endl;
pthread_cancel(pthread_self());//取消自己,retval = -1
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
int n1 = pthread_create(&tid1,nullptr,func1,(void*)"thread_1");
int n2 = pthread_create(&tid2,nullptr,func2,(void*)"thread_2");
int n3 = pthread_create(&tid3,nullptr,func3,(void*)"thread_3");
while(true)
{
std::cout <<"我是main线程,id:" << ToX(pthread_self()) << std::endl;
sleep(1);
}
return 0;
}
6.3 线程等待
为什么需要线程等待?
-
子线程可能无法完成执行
由于主线程不会等待子线程执行完成就继续执行,可能会导致子线程在还未完成其任务时,主线程就已经结束,从而使整个进程结束。这会导致子线程没有机会完成其执行,进而使得程序的行为与预期不符。
-
资源泄露与"僵尸线程"
如果一个线程是非分离的(默认情况下创建的线程都是非分离的),并且没有对该线程使用pthread_join的话,该线程结束后并不会释放其内存空间。这会导致该线程变成了"僵尸线程",造成系统资源的浪费。因为系统需要为这些僵尸线程保留一定的资源,直到整个进程结束。如果进程中存在大量的僵尸线程,将会严重占用系统资源,降低系统的性能。
参数:
- thread:线程ID
- retval:二级指针,它指向⼀个指针,
指针中存放线程的返回值
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到指定的thread线程终⽌。
thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,retval所指向的单元⾥存放的是thread线程
函数的返回值
。- 如果thread线程被别的线程调⽤pthread_ cancel异常终掉,retval所指向的单元⾥存放的是
常数-1
,即PTHREAD_CANCELED。- 如果thread线程是自己调⽤pthread_exit终⽌的,retval所指向的单元存放的是传给
pthread_exit的参数
。- 如果对thread线程的终⽌状态不感兴趣,可以传NULL给retval参数。
6.4 线程分离
- 默认情况下,新创建的线程是joinable的,线程退出后,主线程必须对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
- 线程组内其他线程对⽬标线程进⾏分离,也可以是线程自己分离:
pthread_detach
7. 线程ID及进程地址空间布局
在前面我们说可以使用pthread_self()获取线程的ID,可是得到的ID为什么这么长呢?它不像内核中轻量级进程的ID呀!它到底是什么呢?
其实我们使用pthread_self()得到的线程ID是一个地址。
在我们自己的可执行程序要使用线程时,是要依
赖第三方库的libpthread.so
的。
- 在线程运行的时候,线程库要被加载内存中,映射到进程虚拟地址空间的共享区。所有的线程就可以访问映射到共享区中的库
我们在上面也说了,我们要线程的ID,可是Linux中没有线程,它只有轻量级进程LWP。
但是用户需要用线程,所以库提供了线程相关的接口;但用户要想访问线程的属性呢?
为了不让用户直接接触到LWP的属性集(为了解耦
),pthread库对底层的LWP也进行了封装,供用户使用线程的属性。
所以pthread库也要对线程属性进行:先描述,再组织。
那线程在库中也应该是一个结构体
在线程等待
那里,我们传递的第二个参数void* retval是怎么得到另一个线程的退出结果的呢?
在线程的tcb中,存在一个void* ret成员,存放当前线程的退出结果,对当前线程进行等待的线程直接去目标线程的tcb中读取,读取到我们传递的第二个参数中即可。
所以,所有线程的用户级属性都是在库中维护的,所得到的线程"ID"就是库中线程结构体的地址!
线程等待pthread_join()要传递线程的ID,除了释放LWP,还通过线程ID释放库中对应的属性集!
那库中是如何组织线程的属性集的呢?
在struct pthread中一定会封装一个LWP。
栈只有一个,每个线程是如何保证都有一个栈呢?
在地址空间中的栈,我们称主线程栈
,进程启动时的第一个执行流会使用该栈;其余线程都是独立创建自己的栈,动态申请。
虽然 Linux 将线程和进程不加区分的统⼀到了 task_struct ,但是对待其地址空间的 stack 还是有些区别的。
- 对于 Linux 进程或者说主线程,简单理解就是main函数的栈空间,在fork的时候,实际上就是复制了父亲的 stack空间地址,然后写时拷贝以及动态增长。如果扩充超出该上限则栈溢出会报段错误(发送段错误信号给该进程)。
- 然⽽对于主线程生成的子线程而言,
其 stack 将不再是向下⽣⻓的,⽽是事先固定下来的
,⼀般而言就是默认的8M。线程栈⼀般是调⽤glibc/uclibc等的 pthread 库接口, pthread_create 创建的线程,在文件共享区。- 因此,对于⼦线程的 stack ,它其实是在进程的地址空间中map出来的⼀块内存区域,原则上是线程私有的,但是同⼀个进程的所有线程⽣成的时候,是会浅拷贝生成者的 task_struct 的很多字段,如果愿意,其它线程也还是可以访问到的。
在每个线程结构中,还有一个线程局部存储,它是做什么的呢?
在之前,我们定义一个全局变量,一个线程修改变量,另一个线程也就变了
_ _thread 编译型关键字,只能修饰内置类型,被其修饰的变量,每个线程在局部存储中都会有自己独立的一份。
此时两线程看到的就不是一个变量了。
每个线程都有自己的一个errno,存储自己的退出码,就使用了局部存储。
对于经常使用的线程属性,为了避免每次去库中找,可以局部缓存起来,提高访问效率。