Linux 线程与页表
一.线程的基本概念
1.概念角度认识线程
1、Linux中的线程
按照大部分操作系统教材中的概念所讲,线程就是进程内部的一个执行分支——也就是一个执行流。并且,对于进程和线程,做了如下工作的区分:
进程:承担分配系统资源的基本实体
线程:CPU调度的基本单位
进程=内核数据结构+代码和数据,最起码它会占用内存资源,CPU资源
在之前,我们认为的执行流只有一个:在创建一个进程时,会创建一系列数据结构。进程访问的大部分资源,都是通过地址空间访问的,地址空间可以看作一个“窗口”。不同的进程数据不同,是因为大家的窗口不同,创建一个进程就需要创建以下配套的内核数据结构。
那我们可不可以,创建一个“进程”,共享这个窗口呢(不分配独立的地址空间和页表)?这也必定会让所有的“进程”看到同一份资源!将资源分配给不同的task_struct,就用进程模拟了线程!
将资源进行分配,本质上就是在划分地址空间——也就是在划分虚拟地址,划分页表。
例如,用进程模拟的线程,将本来应该串行执行,并由一个task_struct独占的数据块,划分为多个块并行执行。
我们可以得出如下结论:
结论1:Linux中的线程,可以采用进程来模拟
结论2:对资源的划分,本质是对地址空间,虚拟地址范围的划分。虚拟地址,就是资源的代表!
结论3:上面的例子:什么叫把代码区划分?我们写的代码,在根本上都会由函数执行。函数就是代码块,代码块中每一行都有地址,只不过第一行叫做函数的入口地址。函数我们之前谈到过,它是以ELF格式,平坦模式进行编址的,这样看来,函数就是虚拟地址空间的集合。
正因为资源是通过窗口看到的,那么可以反向的理解,所有资源就是窗口。就是让线程未来执行ELF程序的不同的函数。
结论4:这时,我们怎么理解之前所讲的进程?进程叫做内核数据结构+代码数据,单单一个task_struct可不是进程。以前的进程:内部只有一个线程的进程!
结论5:LInux线程,就是轻量级进程,或者说用轻量级进程模拟实现的
2、Linux线程的设计理念
操作系统是一个广义的概念,只提供实现思想;具体的操作系统提供实现方案。我们目前讲的这个方案在Linux下有效,其他操作系统可能不适用。
我们思考以下问题:
问题1:为什么Linux要这么设计?
问题2:其他平台的线程,有没有自己的实现方案?
线程在内核中也需要管理,依旧是先描述,再组织。windows中为线程单独设计了数据结构——控制块TCB。相当于:一个进程PCB中添加一个链表来管理TCB。这样的设计一定会更复杂!
Linux中认为,没必要为线程专门设计数据结构,因为它的结构和操作和进程十分相似。只需要复用task_struct用进程模拟线程即可,进程内核代码,也全部复用!这样的设计一定会更加健壮。
LInux操作系统视角:都是执行流;硬件CPU视角,它根本不区分进程和线程的代码。CPU认为,执行流是<=进程的概念。CPU:轻量级进程
现在讲一个例子,用来理解线程:
我们讲一个例子:社会分配资源的基本实体:家庭!家庭内部有各种各样的人员,每个人都在做不同的事情
每个人员就是一个线程,但是大家的目的都一样——过好日子。进程强调独占,部分共享(例如通信)。而线程强调共享,部分独占(每隔线程由自己的入口函数)
二.页表与内存管理
1、内存划分与page
磁盘以4kb为一个块大小。物理内存被操作系统划分为4kb的内存块,不仅是对磁盘数据的规定,也会影响物理内存。因此磁盘和内存进行数据交换,是以4kb为单位进行IO交换的,我们把一个4kb的大小,叫做页框或页帧。
可执行程序就是文件,文件就在磁盘存储。可执行程序在存储时无论是内容还是属性天然都是按照4kb进行存储的。
对于一个4GB的内存,一个块大小为4kb,那么大概有1048576块,也就是2^10 * 2^10块。
那么内存是怎么划分的?
4kb划分是操作系统划分的,其实磁盘也是由操作系统划分的。
操作系统就需要管理内存中一个一个的页框。先描述再组织——struct page。
在内核中定义了一个数据结构struct page用来管理内存中的页框。
struct page {/* 原子标志,有些情况下会异步更新 */unsigned long flags;union {struct {/* 换出页列表,例如由zone->lru_lock保护的active_list */struct list_head lru;/* 如果最低位为0,则指向inode* address_space,或为NULL* 如果页映射为匿名内存,最低位置位* 而且该指针指向anon_vma对象*/struct address_space *mapping;/* 在映射内的偏移量 */pgoff_t index;/** 由映射私有,不透明数据* 如果设置了PagePrivate,通常用于buffer_heads* 如果设置了PageSwapCache,则用于swp_entry_t* 如果设置了PG_buddy,则用于表示伙伴系统中的阶*/unsigned long private;};struct { /* slab, slob and slub */union {struct list_head slab_list; /* uses lru */struct { /* Partial pages */struct page *next;
#ifdef CONFIG_64BITint pages; /* Nr of pages left */int pobjects; /* Approximate count */
#elseshort int pages;short int pobjects;
#endif};};struct kmem_cache *slab_cache; /* not slob *//* Double-word boundary */void *freelist; /* first free object */union {void *s_mem; /* slab: first object */unsigned long counters; /* SLUB */struct { /* SLUB */unsigned inuse : 16; /* 用于SLUB分配器:对象的数目 */unsigned objects : 15;unsigned frozen : 1;};};};...};union {/* 内存管理子系统中映射的页表项计数,用于表示页是否已经映射,还用于限制逆向映射搜索 */atomic_t _mapcount;unsigned int page_type;unsigned int active; /* SLAB */int units; /* SLOB */};...#if defined(WANT_PAGE_VIRTUAL)/* 内核虚拟地址(如果没有映射则为NULL,即高端内存) */void *virtual;
#endif /* WANT_PAGE_VIRTUAL */...
};
这里我们保留一个问题:
struct page:为什么没有虚拟地址或者物理地址呢?
只要知道了每个page的下标,就天然知道了page的起始物理地址:下标*4kb
具体的物理地址=起始地址+页内偏移
所以不需要再page中记录物理地址
在内核中还有一个struct page mem的数组,其中用于存储page。我们对page的管理就相当于对该数组进行增删查改。
2、申请物理内存的动作
操作系统申请内存也是按照4kb进行的的!写时拷贝在进行拷贝时,不仅仅是把单个的变量拷贝,而是把变量对应的整个页框也进行写时拷贝。
这样我们也能理解为什么ELF格式文件要把section合并成segment:就是为了合成一个一个4kb大小的块方便加载。
申请物理内存,是在做什么?
1.查page mem,改page。建立数据结构的对应关系能让我们快速查到对应的page。申请内存,载体是以进程或者线程的,那么进程和线程是怎么跟内存串联起来的?
2.一个打开的文件struct file中,有一个address_space,它是一个指向树状结构的指针,每一个树的结点中有slots,会指向具体的一个page。
3、页表的设计理念与虚拟地址映射
页表绝不能是单张页表!试想:我们要存储4GB个映射关系,而且还得同时有物理地址和虚拟地址,假如一个地址由4字节存储,光一个页表就至少有32GB了。
虚拟地址:在32位机器下,是32位数字;我们知道一个单页表是无法实现的,所以我们不可能把整个32位数字一次性整体用来映射物理地址。操作系统的做法:在逻辑上将32位地址划分三个区域:CPU先拿虚拟地址前10位索引页目录,此时页目录最多只有1024个表项,表项保存着下一级页表的地址,此时就有第二级的页表,再用虚拟地址中间10位查第二个页表,此时的表项中存放着页的地址。
最后,根据虚拟地址的末12位拿到页地址的页内偏移量,就找到了具体的字节。
虚拟地址从哪里提供?用户层,虚拟地址空间提供,既可以由用户使用,也可以由内核使用。
当前进程的整个页目录,就代表了页表的起始地址,那如何知道页目录的起始地址?
CPU中有一个叫CR3的寄存器,指向了当前进程的页目录。CR3是当前进程的硬件上下文,切换进程,就会切换进程的硬件上下文,对应的地址空间就都会切换。
页表结构是软件建立的,CPU内部还集成了MMU内存管理单元,负责虚拟地址和物理地址的转换,也就是说,整个索引工作都是MMU硬件转换完成的。CPU直连地址总线。
接下来讲一些细节:
细节1:缺页中断。某个虚拟地址在虚拟地址空间中是合法的,但在查页目录以及页表时却没有对应的物理地址,于是触发中断,申请物理内存,查找page数组,找到没有被使用的page,找到page的索引就找到了物理页框地址,将其填充到页表中。
细节2:写时拷贝,缺页中断,内存申请等操作可能都需要重新建立新的页表和建立映射关系的操作。
细节3:对于进程而言,是一张页目录+n张页表构建的映射体系,虚拟地址是索引,物理地址页框是目标,虚拟地址低12位+页框地址=具体的物理地址。
细节4:为什么是低12位这个数字?这与ELF的编译有关。
4kb,取值范围就是0-4095,正是2^12,正好是一个页框大小。同一个页框内的数据,前20位一定相同,差异仅在末12位,编址就是对数据进行有序化。
访问的很多数据都是基于同一页框的:方便局部性原理的实现,这也是程序预加载的原理。
一个进程只会使用一小部分内存,所以页表的数量一定会远小于1024
执行流看到的资源,本质是:在合法情况下,能看到多少虚拟地址,就代表有多少资源。虚拟地址是资源的代表。
虚拟地址 mm_struct +vm_area_struct本质:进行资源的统计和数据整合。
资源划分:本质就是地址空间划分
自用共享:本质就是虚拟地址共享
多级页表的缺点和优化:
单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
为了优化查询效率,MMU引入了快表TLB:其实就是缓存。当CPU给MMU传新的虚拟地址之后,MMU会先查询TLB中有没有,如果有就直接拿到物理地址发到总线,传给内存;但TLB毕竟容量较小,难免发生缓存不命中的情况。这时MMU会使用自己的保底页表,在页表找到地址之后,MMU做的工作除了通过地址总线讲地址传给内存,还会把这条映射关系写入TLB,让他记录下来。
三.深刻理解线程
1、线程资源划分的本质
线程的资源划分:本质就是划分地址空间,获得一定范围的合法虚拟地址,再深的看,本质就是在划分页表
• 在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是“⼀个进程内部的控制序列”
• ⼀切进程⾄少都有⼀个执⾏线程 • 线程在进程内部运⾏,本质是在进程地址空间内运⾏ • 在Linux系统中,在CPU眼中,看到的PCB都要⽐传统的进程更加轻量化 • 透过进程虚拟地址空间,可以看到进程的⼤部分资源,将进程资源合理分配给每个执⾏流,就形成了线程执⾏流
2、虚拟地址的作用
我们可以分析,在没有虚拟地址空间等映射结构的存在,操作系统如何管理内存资源。
如果我们直接使用物理地址直接映射,由于每个程序的代码和数据长度都不同,这样做物理内存会被分割成各种离散、大小不一的块。经过一段时间运行后有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。
虚拟地址的诞生,还有利于进程独立性的实现。因为在不使用虚拟地址的情况下,使用的一切地址都是物理地址。如果某个进程所指向的代码段指针发生错误,就有可能修改掉其他进程的代码
把物理内存按照⼀个固定的⻓度的⻚框进⾏分割,有时叫做物理⻚。每个⻚框包含⼀个物理⻚(page)。⼀个⻚的⼤⼩等于⻚框的⼤⼩。
⼤多数 32位 体系结构⽀持 4KB 的⻚,⽽ 64位 体系结构⼀般会⽀持 8KB 的⻚。区分⼀⻚和⼀个⻚框是很重要的:
• ⻚框是⼀个存储区域;
• ⽽⻚是⼀个数据块,可以存放在任何⻚框或磁盘中。
有了这种机制,CPU 便并⾮是直接访问物理内存地址,⽽是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执⾏的进程分配的⼀个逻辑地址,在32位机上,其范围从0 ~ 4G-1。
操作系统通过将虚拟地址空间和物理内存地址之间建⽴映射关系,也就是⻚表,这张表上记录了每⼀对⻚和⻚框的映射关系,能让CPU间接的访问物理内存地址。
总结⼀下,其思想是将虚拟内存下的逻辑地址空间分为若⼲⻚,将物理内存空间分为若⼲⻚框,通过⻚表便能把连续的虚拟内存,映射到若⼲个不连续的物理内存⻚。这样就解决了使⽤连续的物理内存造成的碎⽚问题。
3、page中的重要成员
page的结构体中含有多个标记位,我们可以根据标记为了解到对应物理内存的情况,比如是否空闲,是否锁定等等
1. flags :⽤来存放⻚的状态。这些状态包括⻚是不是脏的,是不是被锁定在内存中等。flag的每⼀位单独表⽰⼀种状态,所以它⾄少可以同时表⽰出32种不同的状态。这些标志定义在
<linux/page-flags.h>中。其中⼀些⽐特位⾮常重要,如PG_locked⽤于指定⻚是否锁定,PG_uptodate⽤于表⽰⻚的数据已经从块设备读取并且没有出现错误。
2. _mapcount :表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。当计数值变为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它。
3.virtual:是页的虚拟地址。通常情况下它就是页在虚拟内存中的地址。有些内存(例如一些高端内存)并不永久映射到内核地址空间上。这种情况下这个域的值为NULL。需要的时候必须动态地映射这些页。
自此我们对线程和页表的基本概念有了较为系统的认识,下章我们讲解线程的控制。