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