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