本文主要是介绍(29)Linux--多线程对页表以及线程的理解,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
页表的理解
我们在之前一直都提到页表,知道它的作用是将虚拟地址映射到物理地址,但是它具体怎么映射的,它的结构是什么样的,并没有提及过。
char* str = "hello world";
*str = 'H';
上诉代码,会在运行时报错,原因是str指向的地址在字符常量区,字符常量区的内容是不允许用户去修改的。
代码在运行起来以后,操作系统是怎么知道用户在修改字符常量区的呢?
如上图所示的页表示意图,页表中不仅右虚拟地址和物理地址的映射关系,还有是否命中,RWX权限,U/K权限等等内容。
- U/K权限:U表示用户(user),K表示内核(kernal)。
- RWX权限:当前身份(用户或者内核)对当前地址的读,写执行权限。
- 上面代码在对srt指向的地址写内容时,先会经过页表映射到物理地址。
- 但是在页表中发现这是一个写操作,并且该地址是不允许被写的,此时MMU就发送信号,导致程序报错。
虚拟地址,物理地址以及属性所在的一行,称为条目。
仍然是这张图,需要将这张图分解进行讲解。
物理内存空间划分:
以32位系统为例,它的物理内存理论上有4GB大小,但是这4GB又被分成了多块小空间。
物理内存中会又很多个页框,并且这些页框也需要操作系统管理起来,采用的方式同样是先描述,再组织。
通过一个结构体来描述页框
struct_Page
{//内存属性--4KB
}
代码形式如上所示,每一个页框都会有这样一个结构体对象,将多个结构体对象放在一个数组中:
struct_Page mem[];
可执行文件:
我们写好的代码会经过编译器的处理形成二进制可执行文件放在磁盘中,在运行的时候加载到内存中。
编译器在处理源文件生成的二进制可执行文件,同样是以4KB为单位的,这4KB的数据块被叫做页桢。
这一切都是设计好的,所以可执行程序在加载到内存中的时候是以4KB为单位的,正好一个页帧来填充一个页框。当页框被填充了以后,就会创建对应的struct_Page结构体对象,并且放在数组中,让操作系统来管理。
页目录和页表项
再回到页表,我们知道,每个进程对应的虚拟地址空间大小都是4GB的,也就是有232个地址,如果每个虚拟地址在页表中都对应着一个物理地址:
那么页表就会有232行,每一行都是一个条目,每个条目中不仅有物理地址,虚拟地址,还有其他属性,假设一个条目的大小是10B,那么光页表就有10*232=40GB,已经超过了物理内存的大小,所以页表肯定不是这样的。
实际上,页表是由页目录和页表项组成的。
在32位机器上,地址的大小是4G字节,也就是有32个比特位:
随便写了一个地址,如上图所示,一个32个比特位。
将32个比特位分为10个比特位,10个比特位,12个比特位,共3组。
之前的的页表 = 页目录 + 页表项,如上图所示。
- 32个比特位的高10位,作为页目录的下标,如上图所示的0000 0000,通过这个下标0可以访问到页目录中的第一个条目。
- 页目录中存放的是页表项的地址,可以通过下标找到对应的页表项。
10个比特位,意味着页目录的下标范围是0~1023,最多是210也就是1KB个条目,大大减少了对内存的消耗。
- 32个比特位的中间10位,作为页表项的下标,同样可以访问页表项中的条目。
- 页表项中存放的是物理内存中页框的起始地址,可以通过下标找到物理内存中对应的页框。
同样,一个页表项最多有1KB个条目,指向1KB个页框。
- 32个比特位中的低12位,作为偏移量,在物理内存中页框的起始地址基础上进行偏移,此时就可以得到具体数据在内存中的地址。
- 这也是为什么页框和页帧的大小设置为4KB的原因,因为最低的12个比特位是212=4KB,偏移量最大就是4KB。
32位虚拟地址->物理地址的映射过程:
- 根据高10位下标找到页目录中对应页表项的地址
- 再根据中间10位下标找到页表相中对应页框的物理地址
- 再根据低12位偏移量进行偏移找到具体的物理地址。
页目录和页表项同样是采用先描述再组织的方式被操作系统管理起来的,每创建一个进程就会有一个页目录,只有在目录中存在的页表项才会被建立。
采用这种方式,大大减少了对内存的消耗。
线程的理解
线程:是进程中的一个执行流。
回忆一下,之前我们对进程的定义是:内核数据结构 + 进程对应的代码和数据。
如上图所示,此时我们创建了多个“子进程”。
- 新创建的“子进程”中的mm_struct* mm都指向父进程的虚拟地址空间。
- 也就是说,所有“子进程”和父进程共用一块虚拟地址空间。
和父进程共用一块虚拟地址空间的“子进程”,就叫做线程。
此时开始,我们就将带引号的"子进程",叫做线程。
线程的作用:执行进程中的一部分代码。
轻量级进程
从图中可以看到,每个线程都也有一个task_struct结构体对象,用来描述线程的属性(id,状态,优先级,上下文,栈等等)。那么线程要不要被操作系系统管理起来呢?
答案是要的,而且采用的方式同样是先描述再组织,描述线程的task_struct结构体被叫做TCB–线程控制块,是英文Thread Contral Block的首字母。
描述好了以后同样像PCB一样,需要用链表组织起来进行管理,并也和PCB一样,有自己的管理算法。
- 但是,TCB中的属性和PCB几乎一样,管理TCB的数据结构和算法也和PCB的一样。
此时不仅会导致代码上的冗余,而且还会增加系统的开销,所以Linux并不是使用TCB管理线程的,因为这种方式比较复杂,维护起来不方便,而且运行也不是很稳定。
- Linux中,线程是直接复用PCB的数据结构和管理方法。
- 所以在Liux操作系统中,进程和线程的描述结构体都是task_struct。
站在CPU的角度,它只关注task_struct。
CPU是一个被动的硬件,给它什么它就执行什么,所以它并不会区分当前执行的task_struct是一个进程还是一个线程,在它看来,都是进程。
站在内核的角度,称今天学习的task_struct为轻量级进程。
我们可以通过虚拟地址空间 + 页表的方式对进程进行资源划分,让不同的“轻量级进程”同时执行不同部分的代码,所以单个“轻量级进程”的执行粒度,一定要比之前的进程细。
- Linux内核中并没有线程的概念,线程是用进程PCB来模拟的。
由于Linux中,线程也是使用的PCB结构,是一种轻量化的进程,所以在Linux内核中并不存在线程的概念,也不存在线程的结构。
- 站在CPU的角度,每一个PCB都被称为轻量级进程。
CPU每次都是调度一个task_struct结构体,而这些PCB都是轻量级进程,有可能是属于进程,也有可能是属于线程,即使是属于进程,也可以看作是一个线程,因为无论是进程还是线程,都是一个个的执行流,CPU每次调度的都是一个执行流。
- 进程的重新定义:进程是承担分配系统资源的基本实体。
每创建一个进程,都会创建一个PCB,一个虚拟地址空间,一个页表,一块物理空间,而线程是属于这个进程中的执行流,它使用的是这个进程的资源。
所以此时的进程就包括因为创建它而产生的一系列开销(PCB,虚拟地址空间,页表,物理空间),这些都是属于这个进程的。
- 当这个进程中的某个线程申请新资源的时候,也是以该进程的名义去申请,而不是也这个线程的名义。
一个进程内可以有多个执行流,这些执行流都共用一个虚拟地址,一个页表。
- 最初的进程执行流被叫做主线程。
- 之后创建的执行流被叫做新线程。
主线程和新线程都属于一个进程,都是一体的,就像一个家庭中,有不同的成员,他们的工作是不同的,但是目的都是一样的–为了这个家好。
同样,多个线程同时工作的目的也是相同的–为了完成这个进程的任务。
线程引入Linux
我们知道,在Linux内核中是不存在线程这一个概念的,因为没有TCB数据结构以及管理算法,而我们所说的线程,都是在宏观层面,代指所有操作系统。
Linux操作系统中也没有提供创建线程的系统调用。
- 无论是宏观操作系统,还是用户(程序员)都只认线程的概念,但是Linux内核中并没有线程的概念。
我们(程序员)在编程的时候,仍然会使用线程的概念,那么我们在创建线程的时候,Linux内核中是怎么创建出轻量级进程的呢?
- 我们在创建进程的时候,会调用一个线程库,库中再通过一些系统调用创建出轻量级进程。
这样一来,程序员创建线程,Linux中创建轻量级进程,双方的要求就都满足了。
这个线程库是所有Linux操作系统必须自带的,所以也叫做原生线程库。
下面我们来看看线程的样子,创建线程使用到的库函数接口是:
pthread_t* thread:线程标识符tid,是一个输出型参数。
const pthread_attr_t* attr:线程属性,当前阶段一律设成nullptr。
void* (*start_routine)(void *):是一个函数指针,线程执行的就是该函数中的代码。
void* arg:是上面函数指针指向函数的形参。
返回值:线程创建成功返回0。
//Makefile
mythread:mythread.cg++ -o $@ $^ -std=c++11 -lphread
.PHONY:clean
clean:rm -f mythread
//mythread.c
#include<iostream>
#include<cassert>
#include<pthread.h>
#include<unistd.h>using namespace std;void* start_routine(void* args)
{while(1){cout<<"我是新线程,我在执行!"<<endl;sleep(1);}
}int main()
{pthread_t tid;int n=pthread_create(&tid,nullptr,start_routine,(void*) "new thread");assert(n==0);while(1){cout<<"我是主线程,我正在进行!!!"<<endl;sleep(1);}return 0;}
运行结果:
- 主线程执行的任务是在一个死循环中,新线程执行的任务也在一个死循环中。
如果只有一个执行流的话,程序会陷入一个死循环中,另一个死循环就不会再执行,而我们创建新线程就是为了让新线程和主线程同时执行两个不同的死循环。
新线程和主线程在同时运行,并没有陷入某一个死循环中。
原生线程库
查看可执行程序的链接属性。可以看到是动态链接,链接的库是原生线程库,如上图绿色框中所示。
根据线程库的路径去查看该路径下的所有文件,可以看到还有静态库,我们使用的线程库是一个软链接文件,它所链接的库才是真正的原生线程库。
在创建新线程的时候,传递的最后一个参数作为新线程执行函数的形参,如上图所示。
可以看到,新线程中打印的name内容正式在主线程中创建新线程时传过来的字符串。
LWP和PID
主线程和新线程在同时运行,此时存在两个执行流。
但是在查看该进程的时候,发现mythread进程只有一个,pid,ppid等值也只有一个。
这也证明,线程是进程中的一个执行流,线程属于进程的一部分。
给mythread进程发送9号信号,主线程和新线程都结束了。
- 所有信号针对的都是进程,而线程属于进程。
- 当一个进程结束以后,它的所有资源都会被回收,所以线程也就不存在了。
那我们想看到线程该怎么办呢? ???
使用指令ps -aL来查看线程。L必须大写
此时名字为mythread的线程有两个,它们的PID值相同,LWP不同。
- PID:进程标识符
- LWP:轻量级进程表示符,LWP是英文Light Weight Process的首字母。
- 可以看到,第一个线程的LWP和PID是一样的,这个线程就被叫做主线程。
- 第二个线程的LWP和PID不一样,这个线程就被叫做新线程。
那么CPU在调度PCB的时候,根据的是LWP呢还是PID呢?
- CPU在调度PCB的时候是根据LWP为标识符表示一个特点的执行流的。
因为CPU调度的都是轻量级进程,而每个轻量级进程也就线程的根本区别就在于LWP不同,但是不同线程的PID却有可能相同。
- 我们之前学习的进程,它只有一个执行流,也就是主线程,所以它的PID和LWP是相同的,即PID = LWP,我们使用哪个都无所谓。
- 而现在我们学习了线程,就不能再只使用PID了,而是使用LWP。
从这里可以再次看出线程是属于进程的一部分。
线程和进程的关系如下图:
一个框表示一个进程,一条波浪线表示一个线程。
线程的公有资源和私有资源
公有资源:
所有线程都共享一个虚拟地址空间,一个页表,所以进程中的绝大部分资源都是所有线程共享的,先来看看共享的情况:
写一个函数,分别在主线程和新线程中调用这个函数。
可以看到,主线程和新线程都可以调用这个函数。
- 该进程中只有一份虚拟地址空间,该函数放在代码段中。
- 所有线程共享一个代码段。
- 创建一个全局变量,在主线程和新线程中都打印,并且使用后置++。
- 再将全局变量的地址在主线程和新线程中打印出来。
- 新线程和主线程看到的全局变量是一个,当任意一个线程改变这个变量的值时,都会影响另一个线程使用这个值。
- 主线程和新线程中,全局变量的地址是相同的,说明它们使用的是同一个全局变量。
根据上面现象以及分析,可以知道,数据段也是被所有线程共享的。
进程中的绝大部分资源都是和所有线程共享的。
私有资源:
- 因为所有线程都共享一个虚拟地址空间以及页表,线程之间有私有资源吗?答案肯定是有的。
- PCB属性私有
- 所有线程都有各自的PCB,所以PCB中的属性肯定是私有的,属于各自线程。
- 上下文数据私有
- CPU在调度PCB的时候,采用轮转时间片的方式,当一个线程被换下时,该线程的上下文一定是私有的,防止被其他线程修改而导致恢复上下文的时候出现错误。
- 栈结构私有
- 不同线程各自的临时变量一定是私有的,而临时变量存放在栈结构中,所有栈也是私有的。
理解是能理解,但是都是同一块虚拟地址空间,怎么就让不同线程的栈结构私有了呢?这就涉及到了原生线程库的实现,
系统调用clone是用来创建子进程的,这里的子进程是轻量级进程,也就是没有独立的虚拟地址空间。
clone中有一个参数,如上图中绿色框中所示,该参数就是用来自定这个子进程的栈空间的。
所以我们在使用pthread_create创建新线程的时候,底层会调用clone,并且会指定属于该线程的私有栈结构。
线程的优缺点
优点:
与线程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
- 进程切换:PCB切换 + 上下文切换 + 虚拟地址空间切换 + 页表切换
- 线程切换:PCB切换 + 上下文切换
可以看到,线程切换比进程切换少了两项。 除此之外,线程切换时cache不用太更新。
- cache:硬件缓冲区,其实就是我们所说的高速缓存。
- 它存在于CPU中,速度只是比CPU慢一点,但是比内存快很多。
- 进程间切换:不仅上面提到的四项内容需要切换,而且cache中的内容也需要重新缓存。
- 线程间却换:切换PCB和上下文,但是cache中缓存的数据不需要切换。
所以线程都共用一个虚拟地址空间和一个页表,而cache中的内容也是根据虚拟地址和页表缓存进来的,所以不同进程之间是可以共用的。
这样一来,大大节省了cache从内存中缓存数据的时间,并且也节省了操作系统的大量工作。
当然还有很多其他的优点,比如:
- 创建一个新线程的代价要比创建一个新进程小得多,因为线程不会创建新的虚拟地址空间和页表。
- 线程占用的资源要比进程少很多。
- 能充分利用多处理器的可并行数量。
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
- 计算密集型应用:主要体现在CPU的高频工作,如加密,解密,算法等。
- I/O密集型应用:主要体现在和外设的交互上,如访问磁盘,显示器,网卡等。
上面很多线程的优点,进程也是拥有的。
缺点:
健壮性或者鲁棒性较差:
- 在新线程中,对空指针指向的地址进行写入。
- 我们知道,这个操作在运行时肯定会出错。
- 新线程中发送端错误异常,收到了11号信号SIGSEGV。
- 但是不仅新线程结束了,主线程也结束了。
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。而多进程就不存在,一个进程的退出并不会影响另一个进程。
除此之外,线程还有一些其他的缺点:
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销(线程切换),而可用的资源不变。
- 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
- 一般情况下,CPU有几个核就创建几个线程。
- 核:只的是一个CPU中的运算器个数,即使是多核,而控制器也是一个CPU只有一个。
总结
在这篇文章中,一定要明白线程是什么,它和进程的区别。并且要知道线程是站在宏观操作系统而言的概念,而具体到Linux操作系统中是没有线程这一个概念的,也没有线程对应的数据结构和系统调用。概念上的线程和内核中的轻量级进程是通过线程库建立的联系。
这篇关于(29)Linux--多线程对页表以及线程的理解的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!