目录
一. 预备知识
1.1 OS对于地址空间的细粒度划分管理
1.2 IO的基本单位
1.3 一级页表和二级页表
二. 线程的基本概念
2.1 什么是线程
2.2 Linux环境下对于进程和线程的重新理解
2.3 线程的创建
2.4 线程和进程的特性比较
三. 总结
我们知道,对于每一个正在运行的进程,OS都会为它创建一个PCB,并为其分匹配虚拟的进程地址空间和页表,而进程地址空间中,根据每个虚拟地址处数据类型的不同,又可以被划分为:内核空间、命令行参数和环境变量区、栈区、堆区、共享区、未初始化全局数据区、已初始化全局数据区和常量区。
我们以堆区为例,堆区空间是由用户 malloc / new 动态申请得来的,假设我们执行多次malloc / new,就可以在堆区申请到多块空间,但是,通过 malloc / new 动态申请内存,通过返回值只能获取到申请空间的起始位置,那么怎样知道每一块空间的起始地址和结束地址呢?
换言之,OS对进程地址空间中某个区域(堆区)的管理,是将其视为一个统一的整体来管理,还是将其划分为更小的区块,来进行细粒度的管理?
OS通过内核级数据结构struct vm_area_struct,来实现对进程地址空间的细粒度划分管理。vm_area_struct 中,存有指向前一个和后一个 vm_area_struct 的指针,其中还包括start和end,表示这个vm_area_struct所管理的地址空间的起始地址和结束地址,将每一个vm_area_struct当做一个双链表节点来处理,就可以实现对地址空间的细粒度划分管理。
据此,我们可以设想一下连续malloc在堆区申请5块动态空间,OS做了什么。OS会先后创建5个vm_area_struct对象,每次调用malloc创建空间后,就获得一个vm_area_struct,并将其插入到链表的中去,这样就将每次malloc申请的堆区空间进行了细粒度划分管理。
结论:Linux通过vm_area_struct记录进程地址空间中每个小块空间的属性信息,并通过特定的数据结构对每个vm_area_struct节点进行管理,以实现对进程地址空间的细粒度管理。
编译器对源代码进行编译后,会生成可执行文件,而一旦这个可执行文件被载入到内存之中去运行,就会形成一个进程,这个进程又有属于其自身的进程地址空间。
进程地址空间中的虚拟地址,是由编译器赋予的,而不是进程运行起来后由操作系统分配。每个可执行文件中的数据,都会由编译器以4KB为基本单位来进行划分,同时,OS对物理内存的管理,也是以4KB为单位的。如图1.2所示,可执行程序中的数据,在运行期间会以4KB为单位载入到内存中去,每个叶帧,对应一个页框。
结论:IO的基本单位为4KB。
我们假设物理内存的大小为4G,物理内存按照4KB为单位划分为一个个页框,粗略估算,内存中大概有100W左右个页框。那么,这100W左右的页框是否要被管理起来呢?答案是要的!
Linux提供struct Page来对每个页框进行管理,每个页框都有一个struct Page与之对应,通过一定的内核数据结构,来对所有的页框进行管理。
据此,我们可以推断,可执行程序成为一个进程在CPU中运行时,映射关系的建立流程为:OS为进程分配地址空间和页表 -> 将虚拟地址写入页表中 -> 从磁盘中找数据,载入内存 -> 将载入到内存中的物理地址填入到页表。
如果在通过页表中虚拟地址映射的页面不在物理内存中,即:页表中的虚拟地址还没有和物理地址成功建立映射关系,就会触发缺页中断。缺页中断发生在进程运行起来之前,物理地址载入页表与虚拟地址建立映射的时候,图1.3为缺页中断的处理流程。
触发缺页中断的原因:页表中的虚拟地址没有映射到物理地址。
以32位环境为例,一个进程地址空间中有个地址,假设只有一张页表来进行虚拟地址和物理地址之间的映射,就需要组映射关系,对于页表中的每组映射,至少要有一个虚拟地址(4bytes)、一个物理地址(4bytes)和一些描述信息,就假设一组映射关系需要8字节,那么组映射关系就需要约32G的页表空间,而页表是存储在内存中的,这显然无法实现!
为此,在32位环境下,操作系统给出的解决方案是将每个虚拟地址的32个二进制比特位划分为三组,第1 ~ 10个bit为对应一级页表,第11 ~ 20个比特位对应二级页表,最后12个bit位为偏移量。
用前10个bit位,建立的一级页表,去映射第 11 ~ 20 个bit位建立的二级页表,通过一级页表和二级页表的映射,可以找到物理内存中每个4KB页框的起始地址,最后12个bit位为实际要映射的物理地址相对于页框起始地址的偏移量,这样,通过 一级页表 + 二级页表 + 页内偏移量 的方式,就实现成了虚拟地址到物理地址的映射。
结论:虚拟地址映射到物理地址的方式为 一级页表 + 二级页表 + 页内偏移量
线程:线程是在进程内部执行的执行流,是OS调度的基本单位。
我们知道,OS会为每一个进程,都创建一个PCB,OS中各个进程都拥有独自的PCB,并且,每个进程都有属于其本身的数据和代码,以及一份地址空间,即使是父子进程之间的进程地址空间也是独立的。
如图2.1所示的场景,几个task_struct共享一份地址空间,分别通过页表,映射执行这个地址空间指向的不同的函数方法,即:通过一定的技术手段,将一个进程地址空间的资源分配给多个task_struct共享,共享同一份进程地址空间资源的task_struct,就是隶属于同一个进程的不同线程。
也就是说,Linux严格意义上讲并不区分进程和线程,只认PCB。如果几个线程属于同一个进程,那么它们的PCB会指向同一份地址空间,如果两个PCB属于不同的进程,那么他们所使用的的地址空间一定不是同一份。
可以这样理解,线程就是进程中的执行流,多线程环境下,每个线程可以执行进程中的不同方法以实现并发执行,这样就可以提高运行速度。
结论:Linux没有独立的线程数据结构,是通过进程来模拟线程的。
采用进程模拟线程是Linux独有的方式,在windows等其他操作系统中,还是会有专门的数据结构,用于描述线程的。
根据2.1章节的内容,我跟可以归纳出这些结论:
在没有涉及到多线程的时候,我们认为一个进程内部只有一个执行流,这样的进程被称为单线程进程,同时,在不涉及多线程时,我们认为 进程 = PCB + 进程的代码和数据。
现在,我们有了多线程概念,知道了一个进程中可能有多个线程在运行,而每个线程都具有属于其自身的PCB,因此,在用户视角,可认为 进程 = 其内部所有线程的PCB + 进程的代码和数据。
一个进程内部的线程共享同一份地址空间的资源,而地址空间和页表对于进程来说是独立的,因此,在内核视角,可认为进程是资源分配的基本单位。
Linux采用进程PCB来模拟实现线程这种独特的方式,这样我们就可以认为,相对于其它的操作系统,Linux下的进程为轻量级进程。因为,如果一个进程中只有一个线程,那么这个线程的就对应进程地址空间的全部地址,而如果是多线程进程,那么进程中的不同方法可以由不同的线程来并发执行,这样一个线程就对应于进程地址空间的一部分,相对于其它操作系统采用专门的数据结构来管理线程,一个PCB就是对应于全部地址空间而言,Linux下的PCB可以对应较小的地址空间范围,而Linux又不严格意义上区分进程和线程,因此我们可以是Linux下的进程为轻量级进程。
也正是由于Linux采用这种“轻量级进程”的方式,所以Linux本身只会提供创建这种轻量级进程的系统调用接口。如果要创建线程,就需要使用Linux自带的第三方线程库,使用线程库必须包含头文件#include
线程创建函数:pthread_create
函数原型:int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void* (*start_routine)(void*), void *args);
本文暂时不关心第一个参数和第二个参数,暂时先知道pthread_t为线程id就好,其中start_routine是线程的入口函数地址,args为传入到start_routine函数中的参数。
如代码2.1所示,在一个进程中调用pthread_create创建五个线程,这样算上main函数的执行流,就有6个线程在这个进程中运行,设置RunThread函数为线程入口,在RunThread中输出线程的pid,这样就可以看到,pthread_create创建的线程和原本就存在的主线程pid是相同的,这样就证明了这些线程隶属于同一进程。
结论:在同一进程中运行的线程,它们的pid相同。
代码2.1:调用pthread_create函数创建线程
#include
#include
#include
#include
void *pthreadRun(void *args)
{
std::cout << (char*)args << ", pid:" << getpid() << std::endl;
sleep(1);
}
int main()
{
pthread_t tid[5];
char para[64]; // 线程入口函数参数
for(int i = 0; i < 5; ++i)
{
snprintf(para, 64, "%s %d", "thread ", i);
pthread_create(tid + i, nullptr, pthreadRun, (void*)para);
sleep(1);
}
std::cout << "main thread, pid:" << getpid() << std::endl;
return 0;
}
我们对代码2.1进行更改如代码2.2所示,在pthreadRun函数和main函数的结尾都加入while(true)死循环,如图2.3所示,另起一个终端,通过指令 ps axj | head -1 && ps axj | grep mythread.exe | grep -v grep来监视mythread.exe进程,可以看到,虽然有多个线程被创建,也就是说创建了多个PCB,但依旧只能看到一个进程。如果希望看到每一个线程信息,则需要-L选项,-L选项的功能是查看轻量级进程,通过ps -aL | head -1 && ps -aL | grep mythread.exe | grep -v grep指令,就可以看到6个线程了。
其中,LWP的意义是Light Weight Process,轻量级进程pid。
我们可以看到,主线程的LWP和线程的PID是相同的,而通过pthread_create创建的线程,它们的LWP和进程的PID则是不同的。
代码2.2:用于监视多线程PID和LWP的代码
#include
#include
#include
#include
void *pthreadRun(void *args)
{
std::cout << (char*)args << ", pid:" << getpid() << std::endl;
sleep(1);
while(true);
}
int main()
{
pthread_t tid[5];
char para[64]; // 线程入口函数参数
for(int i = 0; i < 5; ++i)
{
snprintf(para, 64, "%s %d", "thread ", i);
pthread_create(tid + i, nullptr, pthreadRun, (void*)para);
sleep(1);
}
std::cout << "main thread, pid:" << getpid() << std::endl;
while(true);
return 0;
}
多线程共享的资源:
每个线程独有的资源:
线程的优点:
线程的缺点: