目录
背景知识
堆空间的划分
缺页中断
虚拟地址到物理地址的映射
线程是什么
线程是什么
理解线程
创建线程
再次理解线程
重新理解进程
背景知识
下面的三个问题实际是堆虚拟地址空间的再一次理解!
堆空间的划分
再虚拟地址空间的那一块,我们知识粗浅的说了一下进程地址空间。
进程地址空间分为用户级地址空间与内核级地址空间,用户级地址空间由低到搞分别是:
代码区、已初始化全局数据区、未初始化全局数据区、堆区、栈区、命令行参数与环境变量区。
而已初始化和未初始化全局数据区其实都是全局数据区,知识划分力度稍微细一点。
还有就是堆栈是相对而生的,堆栈中间还有一大段镂空,其中 中间的这一段是共享区,共享区里面一般放的都是加载的动态库,或者是共享内存。
这就是我们之前说了关于进程地址空间里面区域的内容。
那么我们现在还有一个问题,堆区是我们 malloc / new 出来的,那么我们只是向OS申请了空间,我们并没有给OS说我们什么时候使用完,我们申请后也只是有一个起始位置的地址,那么操作系统再释放的时候能知道我们当时申请了多少吗?
之前我们对堆区的划分是粗粒度的,实际上堆区还可以更细力度的划分,其实在操作系统中由一个结构体,专门管理堆空间的使用。
struct vm_area_struct
{
unsigned long start;
unsigned long end;
struct page* vm_area_struct, * vm_area_struct;
}
其中就是这个结构体专门来管理堆空间的使用,而上面的字段是里面比较重要的字段,当然里面还有其他的字段。
而操作系统选择使用双链表的结构,将堆空间可以链接起来,而且里面也有 start 和 end 标记,所以其实可以对堆空间进行细粒度的划分。
缺页中断
我们知道,想要启动一个程序需要OS为该进程创建对应的内核数据结构,然后将代码和数据加载到内存,那么加载的时候是怎么加载,加载到哪里?一次加载多少呢?
这里我们就要说一下关于物理内存了,和编译后的可执行程序了(文件)。
其实物理内存并不是我们看到的一整块,实际上,物理内存是被划分为一块一块的,每一块的大小为4KB。
那么光有划分一定是不够的,而且操作系统知道哪些物理内存被使用过吗?所以OS一定需要将这些物理内存管理起来——先描述,在组织。
struct page
{
unsigned long flag; // 32 位 可以用来表示 32 中状态,例如:是否被使用等...
automatic_t count; // 计数器 表示有多少个在使用该内存...
...
}
可以使用这个描述起来,里面还会有其他字段。
然后我们可以使用一个数组然后将该物理内存一个一个表示起来。
// 假设物理内存现在就是 4 G
// 然后被划分为 4KB 大小,所以最多可以被划分为 4G = 1024 * 1024 * 1024 * 4, 4KB = 1024 * 4
// 4G / 4KB = 1,048,576 块
int array[1,048,576];// 所以对于物理内存的管理,现在就变成了对数组的管理
其实每一块被划分号的物理内存就叫做 “页框”。
所以我们已经知道物理内存实际上是被划分的,而且也知道哪些物理内存被使用了,所以我们就可以加载到没被使用的物理内存上。
那么既然可执行程序实际上就是一个在磁盘上的文件,那么怎么加载呢?每一次加载多少?
既然物理内存被划分为了 4KB,那么可执行程序当然也一次性被加载 4KB 是最好的,所以在编译的时候,其实可执行程序也就被划分为了 4KB 大小。
所以可执行程序一次被加载也是按照 4KB 为单位。
而可执行程序被划分为 4KB 的单位,被称为 “页帧”。
页表
前面我们在谈进程地址空间的时候,一直说的是CPU用的是虚拟内存,也就是需要通过页表来映射。
在我们知道的页表中,至少有这三个字段,其中就是虚拟内存,物理内存的映射,还有就是 flag 标记位,看是否能访问,如果能访问的话,就正常访问,如果不可以的话,那么就发生缺页中断,如果这块内存是被置换出去了,但是可以访问,那么就需要将在磁盘上的数据先换入到内存中,然后访问。
虚拟地址到物理地址的映射
既然页表是虚拟内存到物理内存的映射,那么以 32 位操作系统来说,地址总线一共有 32 位,就是全 0 到全 1 ,那么一共下来就是有 2 ^ 32 次方个地址。
那么以上面的页表结构来说的话,至少也需要9字节来表示一行,那么又因为一共有2 ^ 32 位地址,然后再乘 9 字节,那么是需要很多内存才可以表示一张页表,但是每个进程都会有一张页表,那么物理内存一定是存储不下的,所以那应该如何存储呢?
0000 0000 00 00 0000 0000 0000 0000 0000
其实 32 位操作系统和 64 位操作系统的虚拟内存到物理内存的映射原理都是相同的,所以我们还是以 32 位为例:
一次性将2 ^ 32 次方的地址存入显然是不太现实的,而操作系统也确实没有这么干。
实际上,操作系统是将前 10 个比特位映射,将前 10 个比特位映射的页表叫做 ”一级页表“, 在将后面 10 个比特位继续映射形成“二级页表”,而一级页表的 v 就是二级页表,那么还剩下 12 个比特位,还需要映射吗?
实际上就不需要映射了,这时候,我们就可以通过一个地址的前 10 个比特位就可以找到 二级页表,然后再通过 后10个比特位找到对应的 二级页表,由于物理内存是以 4KB 划分的,而后面 12 个比特位刚好是 4KB,所以当找到对应的 二级页表的时候,就可以通过最后的 12 个比特位找到对应的地址。
线程是什么
线程是什么
线程是什么,再很多操作系统的书上往往总结下来可以用一句话来概括:
线程就是轻量级进程,一般是再进程内运行的,是CPU调度的基本单位。
上面就是线程的内核中的一般被总结的。
但是这样其实是说不清楚线程是什么的,再 Linux 下,我们还是需要从进程的调度去看待线程。
理解线程
那么什么是线程呢?
之前我们再学习进程的时候,我们一直再说,进程实际上就是内核的数据结构加代码和数据。
然后我们继续学习进程,我们就学习的是里面有哪些数据结构,里面的这些数据结构分别管理的是哪些模块。
例如:mm_struct 表示的是进程的地址空间、struct file_struct 里面就是关于文件的内容等等...
而且再前面,我们说的是CPU调度的基本单位就是PCB,那么我们说的有没有问题呢?
其实在前面,我们说的一直都是单执行流的进程。
现在有一个问题,当我们在创建一个进程的时候,那么我们会做什么呢?
首先创建一个进程,操作系统会为给进程创建对应的内核数据结构,然后将进程的代码和数据加载到内存即可,而内核数据结构里面有些什么呢?
包括:PCB、进程地址空间、文件相关内容、虚拟到物理转化的页表等...
那么创建一个进程的时候当然也需要将这些东西也一同创建,所以创建一个进程的开县还是很大的。
而且每个进程都有自己的页表,进程地址空间,然后每个进程都指向自己的进程地址空间,这样就可以实现每个进程独立性,那么假设我们现在只创建进程的PCB,但是不创建那些页表,进程地址空间等等呢?
我们就是让新创建的进程使用老进程的进程地址空间,还有页表那会怎么样呢?
那么我们只创建 PCB 不创建其他的数据结构,然后让他们的PCB 共用一个进程地址空间和页表
那么此时,CPU 还是调度的是 PCB ,但是这些PCB 所指向的进程地址空间以及用的页表都是相同的,那么如果我们让这些PCB可以执行不同部分的代码呢?
所以我们其实就可以将这些 PCB 理解为线程,而这些线程的创建也只需要创建一个 PCB 就可以了,并不需要创建进程的其他数据结构,也不需要向系统在申请资源。
所以通过上面的藐视,我们也就理解了为什么 Linux 的线程被称为轻量级进程了。
那么在 Linux 中有真实的线程吗?没有!
在Linux中由于进程和线程基本是一样的,所以 linux 中采用了复用,使用进程的 PCB 来描述线程,随意在 Linux 中实际上是没有线程的,用进程来代替。
但是当CPU调度的时候,还是以PCB来调度的。
创建线程
上面既然我们说了一下关于什么是线程,并且浅浅的理解了一下线程,而且我们也知道线程是CPU调度的基本单位,下面我们来实操一下,也就是创建线程,然后让他们分别做一些事情。
NAME
pthread_create - create a new thread
SYNOPSIS
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
-
该函数就是创建一个线程的函数。
-
说这个函数之前,我们先说一下,由于Linux中没有提供正真意义上的线程,所以也是没有线程的一系列接口的,只有关于轻量级进程的一系列接口,但是由于由于我们不懂轻量级进程,所以就有人写了一批接口,专门就是为了线程写的。
-
下面我们看第一个参数,第一个参数是一个 pthread_t 的指针,这个是一个输出型参数,我们将一个 pthread_t 的对像传进去,就会将线程的 id 返回。
-
第二个参数是设置线程属性的,如果使用默认属性传NULL即可。
-
第三个参数是一个函数指针的类型,其中返回值是 void* 参数也是 void*,这个参数就是创建的这个线程后让它去执行那个函数。
-
最后一个是一个void* 的指针,而这个参数就是在创建线程的时候就会将这个参数传入到回调函数的参数中。
下面我们就创建线程,然后我们让线程执行回调函数:
void* handler(void* args)
{
string str((char*)args);
// 这里也循环打印pid,如果这些线程打印的pid相同的话,说明是同一个进程里面的
for(int i = 0; ; ++i)
{
cout << str << " pid: " << getpid() << endl << endl;
sleep(1);
}
}
void test1()
{
// 线程 id,创建的时候会返回新线程的 id
pthread_t tid;
// 这里循环创建线程
for(int i = 0; i < 5; ++i)
{
string str = "thread ";
str += to_string(i);
pthread_create(&tid, nullptr, handler, (char*)str.c_str());
sleep(1);// sleep 是为了缓解传参的一个问题
}
// 主线程循环打印,自己的pid
while(true)
{
cout << "我是主线程 pid: " << getpid() << endl;
sleep(3);
}
}
这里我们看上面的这段代码,循环的创建线程,然后让主线程和新线程都打印pid,查看打印的pid是否相同。
这里其实再g++编译的时候,是有一个问题的,如果我们什么都不带的话,那么是编译不了的,为什么呢?
还是我们前面说的那个原因,因为Linux中没有线程,所以没有关于线程的库,而只有关于轻量级进程的库,但是其他人写了一个关于线程的库,但是i这个库并不属于语言,也不属于系统,所以需要我们指定要 link 哪一个库,所以我们需要带选项。
下面看一下 makefile 怎么写的:
thread:mythread.cc #依赖
g++ -o thread mythread.cc -std=c++11 -lpthread #依赖方法
.PHONY:clean #伪目标
clean: #clean 没有依赖
rm -rf thread #依赖方法
上面应该带 -lpthread,表示要 link 的库为 pthread 库。
下面看一下结果:
[lxy@hecs-348468 thread]$ ./thread
thread 0 pid: 11358
thread 1 pid: 11358
thread 0 pid: 11358
thread 0 pid: 11358
thread 1 pid: 11358
thread 2 pid: 11358
thread 0 pid: 11358
thread 1 pid: 11358
thread 2 pid: 11358
thread 3 pid: 11358
thread 0 pid: 11358
thread 1 pid: 11358
thread 2 pid: 11358
thread 3 pid: 11358
thread 4 pid: 11358
我是主线程 pid: 11358
这里看到所有的线程打印的pid都是相同的,所以我们也知道它们是同一个进程。
我们也可以使用命令来查看他们的id:
ps -aL #查看所有的线程
我们下面启动上面的程序,然后查看一下线程的id:
[lxy@hecs-348468 thread]$ ps -aL | head -1 && ps -aL | grep thread
PID LWP TTY TIME CMD
11364 11364 pts/1 00:00:00 thread
11364 11365 pts/1 00:00:00 thread
11364 11366 pts/1 00:00:00 thread
11364 11367 pts/1 00:00:00 thread
11364 11374 pts/1 00:00:00 thread
11364 11375 pts/1 00:00:00 thread
这里发现他们的 pid 是一样的,其中第一个线程的 pid 和 lwp(Lightweight Process) 是相同的,所以第一个线程是主线程。
再次理解线程
通过上面的试验,我们也能看出一下线程与进程的不同,我们现在重新理解一下进程。
我们现在知道,线程是CPU的基本调度单位,那么我们现在知道,同一个进程里面的线程的pid是相同的,所以我们CPU使用的是lwp还是pid呢?
我们现在知道,我们一定使用的是 lwp,因为同一个进程里面的 pid是相同的。
那么关于线程的内核级理解(线程也是轻量级进程,线程运行于进程的内部,线程是cpu调度的基本单位)。
我们可以再一次理解一下这句话:
线程是轻量级进程:是因为创建线程只需要创建PCB,不需要创建进程的其他资源,而且使用的资源也是来自于进程申请的,所以说线程是轻量级进程。
线程运行于进程内部:同一个进程内部的线程使用的是同一个进程地址空间,还有页表等,所以同一个进程里面的线程看到的资源是相同的也就是同一个进程里面的线程都运行于这个进程的地址空间中,所以线程运行于进程内部。
线程是CPU调度的基本单位:通过上面的试验,我们也能发现,CPU确实是调度的是线程,而并非进程。
那么为什么说线程的切换,或者说创建比进程创建要简单呢?
如果是同一个进程的话,那么所有的线程看到的进程地址空间是相同的,但是当CPU切换进程的时候,需要将进程的上下文都切换,而CPU运行一个线程需要它代码和数据,还有就是进程的地址空间以及页表,但是线程切换不需要切换地址空间和页表这些,实际上CPU寄存器上的内容切换其实并不是多么的困难,其实CPU上是有缓存的,但是当我们切换进程的时候,我们是需要将缓存数据也全部切换的,但是切换线程的时候,由于是同一个进程,又根据局部性原理,即使线程切换了,但是代码和数据还有很大一部分是相同的,所以缓存里面的数据是不需要切换的,但是进程切换的话,那么代码和数据一定是不一样的,所以一定需要切换CPU中的缓存,而缓存的切换就不是那么的简单的。
同一个进程里面的线程看到的是同一个进程地址空间,那么也就是说同一个进程中的像代码,以及全局数据等都是所有线程所共享的,那么线程有没有私有的数据呢?
一定是有的,为什么?
因为每个线程的运行起来是不同的,所以每个线程一定需要自己的上下文数据(一组寄存器),包括运行到那行代码等这些数据都是每个线程所私有的,那么还有什么是私有的呢?还有就是线程的私有栈结构里面的数据也是私有的,其实还有线程的id也是私有的,当然假设一个线程出错了,那么当然是需要有错误返回码的,那么如果错误返回码是共享的,那么就是有问题的,所以每个线程的错误码也是私有的,当然还有就是每个线程的重要性是不同的,所以线程的优先级也是不同的,所以线程的优先级也是私有的。
线程的共享数据有哪些呢?
线程看到的进程地址空间是相同的,那么也就是说进程地址空间里面的很多数据都是共享的。
其中有堆的数据是共享的,还有就是全局的数据也当然是共享的,还有静态变量,以及线程打开的文件描述符也是共享的。
重新理解进程
前面我们说的进程就是内核数据结构加代码和数据,但是我们现在还需要重新理解一下。
现在我们所说的进程还是代码加数据,但是里面可能不止一个PCB,里面可能有多个执行流,当然我们之前说的也不是错的,我们之前说的都是一个执行流的进程,当然多个执行流可能就包含一个执行流,所以现在我们所说的进程就是下面这样的。
这整个才算我们所说的进程,我们之前说的进程里面只有一个PCB,也就是只有一个执行流。
当然这里我们自己的理解,下面我们看一下内核级的理解。
再内核里面是这样说进程的:进程是系统资源分配的基本实例。
为什么这样说呢?
因为我们再创建进程的时候需要创建对应的内核数据结构,还需要为进程分配所需的资源。
而线程使用的就是进程申请号的资源,所以当我们学习到线程的时候,我们也就知道了,线程是CPU调度的基本单位,而进程就是系统资源分配的基本实例。