Linux -- 线程概念与控制
本文重点:
1. 深刻理解线程
2. 深刻理解虚拟地址空间
3. 了解线程概念,理解线程与进程区别与联系。
4. 学会线程控制,线程创建,线程终⽌,线程等待。
5. 了解线程分离与线程安全概念。
6. 掌握线程与进程地址空间布局
7. 理解LWP和原⽣线程库封装关系
1. 深刻理解线程
1.1什么是线程:线程就是进程中的一个执行分支,是一个单独的执行流。一个进程中至少有一个线程,线程在进程的内部运行,本质是在进程的地址空间运行。在Linux系统中,CPU看到的PCB比传统的进程更轻量化称为轻量化进程。线程在Linxu中用到的数据结构正是轻量型进程,并没有为它新创建一个类似TCB的数据结构,而是复用了以前的代码。进程中的地址空间是每个线程共享的,这样线程就可以看到进程的大部分资源,将进程资源进行合理分配给每个执行流,就形成了线程执行流。另外:进程是承担系统分配资源的实体,线程是系统中调度的基本单位,我们之前学到的进程都是只有一个执行分支的进程,即只有一个PCB。
2. 深刻理解虚拟地址空间
2.1在真正理解线程之前我们必须要明白内核是如何进行资源划分的,这就要从分页式存储管理谈起。
2.1.1虚拟地址和页表的由来:如果没有虚拟地址以及页表,那么我们数据和代码在内存中就应该是连续存放的,因为每⼀个程序的代码、数据⻓度都是不⼀样的,按照这样的映射⽅式,物理内存将会被分割成各种离散的、⼤⼩不同的块。经过⼀段运⾏时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎⽚的形式存在。我们希望操作系统提供给⽤⼾的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分⻚便出现了,如下图所⽰:
在32位的系统中,物理内存往往会被分割为一个个4KB大小的页框(一个储存区域),每个页框又包含一个页(数据块),每个页的大小等于页框的大小。操作系统会将虚拟内存下的逻辑地址空间分为若干页,将物理内存分为若干页框,通过页表将连续的虚拟内存映射到若干个不连续的物理内存中。这样就解决了使用连续的物理内存造成的碎片化问题。
2.2.1提到了页框又不得不提到物理内存的管理了,假设一个可以使用的物理内存大小是4GB,按照我们上面所说一个页框的大小是4KB,那么4GB的空间就应该有一百多万的页框。这么多的页框OS也必须将他们管理起来,所以在内核中使用了struct page结构表⽰系统中的每个物理⻚,出于节省内存的考虑, struct page 中使⽤了⼤量的联合体union。
/* include/linux/mm_types.h */
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;
};//内核中的一小部分
其中有几个比较重要的参数:(1)flags,⽤来存放⻚的状态。这些状态包括⻚是不是脏的,是不是被锁定在内存中等。flag的每⼀位单独表⽰⼀种状态,所以它⾄少可以同时表⽰出32种不同的状态。这些标志定义在<linux/page-flags.h>中。其中⼀些⽐特位⾮常重要,如PG_locked⽤于指定⻚是否锁定,PG_uptodate⽤于表⽰⻚的数据已经从块设备读取并且没有出现错误。(2)_mapcount,表示此时页表中有多少项指向该页,也就是该页被引用了多少次。当计数值为-1时表示当前内核并没有引用这一页,可以在新的分配中使用它。
系统中有一百多万个page,一个page占40个字节,那么储存这么多的结构体也就消耗了40MB,因此在系统中管理这么多的物理页的代价并不算太大。
page在物理内存实际上是用一个伙伴系统类似数组的形式进行管理的,所以只要找到了page就有了下标,就能够知道物理地址(下标*4KB),同样的有了物理地址就能找到这个page下标对这个页进行管理。
2.2.3页表:在32位的系统中一共有2^32个地址,每个地址是4个字节,那么页表用来储存这些地址的内存大小就应该是2^32*4=16GB,而我们的物理内存也就4GB,这显然是不合理的,所以需要采用其他的方式来对页表进行管理。在Linux中采用了多级页表来进行管理,这里用一般的二级页表进行讲解。在OS中首先有一张大页表,其中包含着1024个页目录表项,这其中的每个页目录表项又包含着1024个页表项,每个页表项存的是每个page的起始地址,使用4个字节存储,所以一个多级页表所消耗的内存是2^10*2^10*4=4MB。那么到这里大家肯定就很疑惑为什么这样就能将2^32个地址储存起来了,一个虚拟地址是32位的,我们先使用前十位进行索引在哪一个页目录,然后再用次十位进行索引页表项,接着还有最后12位作为偏移去页表项中存着的page的起始地址进行查找,4KB刚好等于2^12,所以次12位就能够查找这一个page范围内的所有地址了。Linux正是用这种极为智慧的方法来极大降低了页表的大小。一个进程是用不完4GB的内存的,所以实际上它的页表一定时不完整的。
2.3缺页异常:设想,CPU 给 MMU 的虚拟地址,在 TLB 和⻚表都没有找到对应的物理⻚,该怎么办呢?其实这就是缺⻚异常 Page Fault ,它是⼀个由硬件中断触发的可以由软件逻辑纠正的错误。假如⽬标内存⻚在物理内存中没有对应的物理⻚或者存在但⽆对应权限,CPU 就⽆法获取数据,这种情况下CPU就会报告⼀个缺⻚错误。由于 CPU 没有数据就⽆法进⾏计算,CPU罢⼯了⽤⼾进程也就出现了缺⻚中断,进程会从⽤⼾态切换到内核态,并将缺⻚中断交给内核的 Page Fault Handler 处理。
缺⻚中断会交给 PageFaultHandler 处理,其根据缺⻚中断的不同类型会进⾏不同的处理:
Hard Page Fault 也被称为 Major Page Fault ,翻译为硬缺⻚错误/主要缺⻚错误,这时物理内存中没有对应的物理⻚,需要CPU打开磁盘设备读取到物理内存中,再让MMU建⽴虚拟地址和物理地址的映射。
Soft Page Fault 也被称为 Minor Page Fault ,翻译为软缺⻚错误/次要缺⻚错误,这时物理内存中是存在对应物理⻚的,只不过可能是其他进程调⼊的,发出缺⻚异常的进程不知道⽽已,此时MMU只需要建⽴映射即可,⽆需从磁盘读取写⼊内存,⼀般出现在多进程共享内存区域。
Invalid Page Fault 翻译为⽆效缺⻚错误,⽐如进程访问的内存地址越界访问,⼜⽐如对空指针解引⽤内核就会报 segment fault 错误中断进程直接挂掉。
什么是 “页面不在内存中”?虚拟内存的核心是:程序的虚拟页面不需要全部加载到物理内存中,只有正在使用或近期可能使用的页面才会被加载,其他页面暂时存放在硬盘(如 Windows 的 “页面文件”、Linux 的 “交换分区”)。因此,“页面不在内存中” 指的是:页号是合法的(即该虚拟页面属于程序的地址空间,比如程序申请的堆内存、代码段对应的页面);但该虚拟页面当前没有被加载到物理内存,而是存在硬盘的交换区中。
3. 理解线程与进程区别与联系。
3.1线程的优点:
(1)创建一个新线程的代价比创建一个新进程的小得多。
(2)与进程切换相比,线程之前的切换需要操作系统做的工作少的多:
线程切换以后虚拟内存空间仍然是相同的,但是进程切换时不同的。这两种上下文切换的处理都是通过操作系统内核来完成的,这种切换过程伴随的最显著性能的损耗是将寄存器中的内容切出。
另一个损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,⼀旦去切换上下⽂,处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚拟内存空间的时候,处理的⻚表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在⼀段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache。
(3)线程占用的资源比进程少
(4)能充分利用多处理器的可并行数量
(5)在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务
(6)计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现
(7)I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
4. 学会线程控制,线程创建,线程终⽌,线程等待。
4.1 POSIX线程库
(1)与线程有关的函数构成了⼀个完整的系列,绝⼤多数函数的名字都是以“pthread_”开头的
(2)要使⽤这些函数库,要通过引⼊头⽂ <pthread.h>
(3)链接这些线程函数库时要使⽤编译器命令的“-lpthread”选项
4.2 创建一个线程
功能:创建⼀个新的线程原型:int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);参数:thread:返回线程IDattr:设置线程的属性,attr为NULL表⽰使⽤默认属性start_routine:是个函数地址,线程启动后要执⾏的函数arg:传给线程启动函数的参数返回值:成功返回0;失败返回错误码
错误检查:pthreads函数出错时不会设置全局变量errno(⽽⼤部分其他POSIX函数会这样做)。⽽是将错误代码通过返回值返回,pthreads同样也提供了线程内的errno变量,以⽀持其它使⽤errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要⽐读取线程内的errno变量的开销更⼩。
这里写了一个新线程与主线程同步进行的示例:
这里用pthread_self来获取了主线程和新线程各自的id,以及在命令行中查看确实是有两个线程在同步进行,所以证明确实是创建了新线程。
功能:线程终⽌原型:void pthread_exit(void *value_ptr);参数:value_ptr:value_ptr不要指向⼀个局部变量。返回值:⽆返回值,跟进程⼀样,线程结束的时候⽆法返回到它的调⽤者(⾃⾝)
功能:取消⼀个执⾏中的线程原型:int pthread_cancel(pthread_t thread);参数:thread:线程ID返回值:成功返回0;失败返回错误码
4.4线程等待
线程与进程一样需要等待,因为已经退出的线程的空间还没有被释放,仍然在进程的地址空间内,创建的新线程不会复用刚才推出线程的地址空间。
功能:等待线程结束原型int pthread_join(pthread_t thread, void **value_ptr);参数:thread:线程IDvalue_ptr:它指向⼀个指针,后者指向线程的返回值返回值:成功返回0;失败返回错误码


5. 了解线程分离与线程安全概念。
6. 掌握线程与进程地址空间布局


7. 理解LWP和原⽣线程库封装关系
LWP 是什么呢?LWP 得到的是真正的线程ID。之前使⽤ pthread_self 得到的这个数实际上是⼀个地址,在虚拟地址空间上的⼀个地址,通过这个地址,可以找到关于这个线程的基本信息,包括线 程ID,线程栈,寄存器等属性。 在 ps -aL 得到的线程ID,有⼀个线程ID和进程ID相同,这个线程就是主线程,主线程的栈在虚拟地址空间的栈上,⽽其他线程的栈在是在共享区(堆栈之间),因为pthread系列函数都是pthread库提供给我们的。⽽pthread库是在共享区的。所以除了主线程之外的其他线程的栈都在共享区。
线程库的封装: