轻松Linux-11.线程(上)
学习总结一下
来啦来啦
1. 分页式管理
1.1 虚拟内存与页表
1.2 页表与页目录
1.2.1 页表
1.2.2 页目录结构
1.2.3 地址查找
1.3 缺页异常
1.4 物理页的管理
2. Linux线程
2.1 线程概念
2.2 线程的优缺点
2.2.1 线程的优点
2.2.2 线程的缺点
2.3 进程与线程
3. 线程控制
3.1 POSIX线程库
3.2 线程控制相关函数
3.2.1 线程创建
3.2.2 线程终止
配套函数:
3.3.3 线程等待
3.3.4 线程分离
4. 进程的地址空间布
1. 分页式管理
1.1 虚拟内存与页表
前面我们有简单谈过虚拟地址,我们知道操作系统直接使用的是虚拟地址,而虚拟地址又是通过页表来映射到物理内存的。通过页表映射,我们可以将一个程序的不同部分,存放到非连续的内存块中。如下图↓
通过映射关系,可以将物理内存中不连续的内存空间,映射到连续的虚拟内存空间中。如果每个程序都必须使用一段连续的空间来存储,由于每个程序需要占据的内存空间大小不同、数据长度不一样,就会将内存分割为各种长度的内存碎片。为了解决这种内存碎片化的问题,虚拟内存和分页管理便应运而生。所以我们希望操作系统提供给用户的内存空间最好是连续的,而对应的物理内存最好是不连续的。如下图↓
操作系统将把物理内存按一个固定长度的页框进行切割管理,也可以叫物理页,每个页框内含一个物理页,在32位操作系统下,每个页框大小为4KB;在64位操作系统下,每个页框大小为8KB。
注意:页框和页的区别
-
页框是一个存储区域。
-
页是一个数据块,存放于任意页框或磁盘中。
因为页表有着物理内存中页框的信息,CPU就可以通过虚拟内存(逻辑地址,32位机中,它的范围为0 到 4G - 1)间接访问物理内存。
这套机制的核心思想就是通过将虚拟内存(逻辑地址空间)分为数个页,将物理内存划分为数个页框,再通过页表将连续的虚拟内存映射到不连续的物理内存页上,从而解决内存碎片化的问题。
1.2 页表与页目录
1.2.1 页表
对于页表,页表中的每个页表项都指向一个物理块的起始地址,在32位操作系统下,虚拟内存的最大空间为4G。为了让每个程序使用上自己这4G的虚拟内存,页表就必须表示这4G的所有空间,那么页表就需要 4G / 4K(一个页框大小) = 1,048,576个页表项。如下图↓
那么这个页表的大小就是 4bit * 1048576 = 4MB,那么这个页表本身就需要占用4MB / 4KB = 1024个物理页,这好像就和设计页表的初衷背道而驰了。那么怎么去解决这个问题呢?
我们只需要对这个页表再分页,也就是把它当作一个普通文件即可,采用多级页表的思想。将它分为多个更小的映射表,让每个页表仅占一页,即1024 * 4 = 4KB,这样就可将它分为1024个页表。如下图↓
也许程序用不到这4G的虚拟内存,也许用户仅使用15MB的内存,那么只需使用4个页表(每个页表可映射4MB)即可。
但这样设计显然很不优雅,所以引入页目录结构。
1.2.2 页目录结构
这里就引入页目录结构将这1024个页表管理起来,这样就形成了二级页表,称为页目录表,所有的页表的起始地址都被页目录表项指向,如下图↓
-
CR3寄存器:它保存了当前执行任务的页目录表的物理地址。
所以我们知道,操作系统在加载用户程序的时候,不仅要给程序内容分配空间,还要给保存程序信息的页目录表和页表分配物理内存。
1.2.3 地址查找
在32位机下,线性地址被分为三部分:
-
页目录索引(PD Index):高10位,用于定位页目录表中的项。
-
页表索引(PT Index):中间10位,用于定位页表中的项。
-
页内偏移(Page Offset):低12位,表示页内地址。
通过这三部分记录的信息,我们就可以通过页目录表以及页表来查找对应的物理地址。
逻辑地址(以0000000000,1111111110,111111111111为例)转换为物理地址的过程:
-
CR3寄存器读取页目录表的起始地址。
-
结合页目录索引,从页目录表中获取页表基址。
-
结合页表索引,从页表中获取物理页框基址。
-
结合逻辑地址后12位,得到业内偏移量,找到物理地址。
注意:一个物理页的大小4KB,并且肯定是对齐的(物理地址的低12位全为0),所以只需要记录物理的高二十位即可,逻辑地址低12位刚好可以表示物理页内地址偏移。
以上过程就是查找物理地址的过程(简化,实际还有其他事要做),这个过程其实是MMU(是一种硬件电路,其速度很快, 主要工作是进行内存管理,地址转换只是它承接的业务之一)的工作流程。
MMU要先进行两次页表查询确定物理地址,在确认权限问题后,再将这个地址发到总线,内存收到之后开始读取对应地址的数据并返回,那么在读取到物理地址的时候就已经做了两次检索加一次读写了。当页表级数来到N级时,则需要N次检索加一次读写。显然页表层级越多,查询的次数就越多,CPU等待的时间也就更长,效率就越低。
所以多级页表虽然降低了对连续内存的需求,但查询效率也会降低。
不过也有相应的解决方案(缓存机制),就是TLB(缓存)也可以叫快表。
当CPU给MMU传递虚拟地址时,MMU会先查看TLB中有没有,如果有则直接发送到总线给内存(缓存命中),如果没有(Cache Miss)再到页表中查找,找到后MMU会将该物理地址发送到总线,同时会把这个映射关系给TLB,让它记录下来,刷新缓存。
1.3 缺页异常
在CPU给MMU发送虚拟地址时,MMU在TLB以及页表中都未找到相应的物理页或者物理页存在但无相应的访问权限,CPU无法获取数据,CPU就会报出一个Page Fault
错误(由硬件中断触发的可以由软件逻辑纠正的错误)。因为CPU无法读取数据,CPU无法工作,用户进程就会出现缺页中断。
CPU检测到缺页异常后,立即暂停当前进程执行,保存现场(如程序计数器、寄存器状态等),进程会从用户态切换至内核态,确保操作系统可以访问到系统资源,并将控制权转移至内核的缺页异常处理程序(Page Fault Handler
)。
内核根据异常原因将缺页分为三类,并采取不同策略:
-
硬缺页(Hard Page Fault/Major Page Fault):
-
原因:目标物理页不在内存中,需要从硬盘中读取。
-
处理:需要CPU打开磁盘设备读取到内存中,并让MMU建立虚拟内存到物理内存的映射。若物理内存不足,内核通过页面置换算法(如LRU)选择一个牺牲页,将其写回磁盘(若被修改过)。
-
-
软缺页(Soft Page Fault/Minor Page Fault):
-
原因:目标物理页已经在内存中了,但未建立映射。
-
处理:内核直接在页表中创建虚拟页到物理页的映射,无需磁盘I/O。一般出现在多进程共享内存区域。
-
-
无效缺页(Invalid Page Fault):
-
原因:访问非法地址(如越界、空指针解引用)。
-
处理:内核终止进程,并抛出
Segment Fault
错误。
-
1.4 物理页的管理
在内核中,用struct page
结构表示系统中的每个物理页,为节省空间,该结构体使用了大量的联合体union
。可以见到过一遍源码,已带上注释和翻译。
内核Linux-5.0-rc3 /include/linux/mm_types.h
struct page {unsigned long flags; /* 原子标志,有些可能会被异步更新 *//** 这个联合体中有5个字(20/40字节)可用。* 警告:第一个字的第0位被PageTail()使用。这意味着* 该联合体的其他使用者绝不能使用该位,以避免冲突* 和误判PageTail()。*/union {struct { /* 页缓存和匿名页 *//** @lru: 页换出列表,例如由zone_lru_lock保护的active_list。* 有时被页所有者用作通用列表。*/struct list_head lru; //一个双向链表,是页面回收机制的核心,用于实现高效的内存页面老化、扫描和回收。/* 参见page-flags.h中的PAGE_MAPPING_FLAGS */struct address_space mapping;pgoff_t index; /* 我们在映射中的偏移量 *//** @private: 映射专有的不透明数据。* 如果PagePrivate,通常用于buffer_heads。* 如果PageSwapCache,用于swp_entry_t。* 如果PageBuddy,表示伙伴系统中的阶数。*/unsigned long private;};struct { /* slab, slob和slub内存分配器 */union {struct list_head slab_list; /* 复用lru */struct { /* 部分页 */struct page *next; //用于管理页的链接,空闲页连接管理起来,可将多个4KB的小页合并成一个大页来缓解内存碎片问题
#ifdef CONFIG_64BITint pages; /* 剩余页数 */int pobjects; /* 近似计数 */
#elseshort int pages;short int pobjects;
#endif};};struct kmem_cache slab_cache; /* 不用于slob *//* 双字边界对齐 */void freelist; /* 第一个空闲对象 */union {void s_mem; /* slab: 第一个对象 */unsigned long counters; /* SLUB */struct { /* SLUB */unsigned inuse:16;unsigned objects:15;unsigned frozen:1;};};};struct { /* 复合页的尾页 /unsigned long compound_head; / 第0位被设置 *//* 仅用于第一个尾页 */unsigned char compound_dtor;unsigned char compound_order;atomic_t compound_mapcount;};struct { /* 复合页的第二个尾页 */unsigned long _compound_pad_1; /* compound_head */unsigned long _compound_pad_2;struct list_head deferred_list;};struct { /* 页表页 */unsigned long _pt_pad_1; /* compound_head */pgtable_t pmd_huge_pte; /* 由page->ptl保护 */unsigned long _pt_pad_2; /* mapping */union {struct mm_struct pt_mm; /* 仅x86 pgds */atomic_t pt_frag_refcount; /* powerpc */};
#if ALLOC_SPLIT_PTLOCKSspinlock_t *ptl;
#elsespinlock_t ptl;
#endif};struct { /* ZONE_DEVICE页 *//* @pgmap: 指向托管设备页映射 */struct dev_pagemap pgmap;unsigned long hmm_data;unsigned long _zd_pad_1; /* 复用mapping */};/** @rcu_head: 可以用这个通过RCU释放页 */struct rcu_head rcu_head;};union { /* 这个联合体大小为4字节 *//** 如果页可以映射到用户空间,编码该页被页表* 引用的次数。*/atomic_t _mapcount;/** 如果页既不是PageSlab也不能映射到用户空间,* 这里存储的值可能有助于确定该页的用途。* 参见page-flags.h中当前存储在这里的页类型列表。*/unsigned int page_type;unsigned int active; /* SLAB /int units; /* SLOB */};/* 使用计数。*请勿直接使用*。参见page_ref.h */atomic_t _refcount;#ifdef CONFIG_MEMCGstruct mem_cgroup *mem_cgroup;
#endif/** 在所有RAM都映射到内核地址空间的机器上,* 我们可以直接计算虚拟地址。在具有高端内存的机器上,* 有些内存是动态映射到内核虚拟内存的,* 所以我们需要一个地方来存储该地址。* 注意,在x86上这个字段可能是16位... ;)** 乘法运算慢的体系结构可以在asm/page.h中* 定义WANT_PAGE_VIRTUAL*/
#if defined(WANT_PAGE_VIRTUAL)void virtual; /* 内核虚拟地址(如果没有映射,即高端内存,则为NULL)*/
#endif /* WANT_PAGE_VIRTUAL */#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGSint _last_cpupid;
#endif
} _struct_page_alignment;
几个比较重要的参数:
-
flags:原子标志位,表示页的状态(如是否被锁定、是否脏等),它的每个位都可以表示一种状态,所以它至少可以表示32种状态,如
PG_locked
用于指定页是否锁定,PG_uptodate
用于表示页的数据已经从块设备读取并且没有出现错误。 -
_mapcount:记录物理页被映射到用户空间页表项(PTE)的次数,就是该页被引用了多少次,它的初始值为-1,即当前内核没有引用该页,在分配时可用。
-
virtual :页的虚拟地址。它一般就是页在虚拟内存中的地址。有些内存(即所谓 的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的 时候,必须动态地映射这些页。
struct page
是直接与物理页相关的,每个物理页都需要分配一个page结构体来管理,page
结构体按40字节来算,每4G会消耗40M左右的内存来存储page
结构体。
对于操作系统,页太大会剩余较大不能利用的空间(页内碎片);页太小,即使可以减小页内碎片的大小,但会导致页表太长而占用过多内存,系统也会频繁地进行页转换,加大系统开销。所以页的大小会直接影响内存利用和系统开销,因此,页的大小应该适中,通常为 512B - 8KB ,windows系统的页框大小为4KB。
2. Linux线程
2.1 线程概念
线程是什么呢?
-
线程是进程内的执行单元,是并发执行的基本单位,一个进程至少有一个线程。
-
每个线程有自己独立的栈、程序计数器和寄存器。
-
线程是在进程内部运行的,本质是在进程的地址空间内运行的。
-
线程在进程内是共享地址空间以及虚拟地址空间,本质是共同划分地址空间和共享虚拟地址。
-
线程对地址空间的划分其实是对页表的划分。
-
共享虚拟地址空间其实是共享页表映射。
-
-
线程更加轻量化,线程的创建、切换和销毁开销更小,因为它们不需要分配独立的内存空间。
线程基本是用做并发编程:
-
在CPU密集型程序(数学计算、图像处理等等)中,使用多线程可以提高执行效率。
-
在IO密集型程序(网络爬虫、数据库操作、文件处理等等)中,使用多线程异步处理可以提高数据吞吐量。
2.2 线程的优缺点
2.2.1 线程的优点
-
与进程相比,创建线程的代价要小,线程间切换的工作更少。
-
之所以代价更小,是因为线程切换时,虚拟地址空间是不变的,而进程是会改变的。它们的上下文切换都是需要操作系统内核来完成的,伴随内核将寄存器中的内容换出的是最显著的性能消耗。
-
伴随着上下文的切换,处理器的缓存机制也会被扰乱。上下文切换时,处理器缓存中所有的内存地址都会作废。并且进程改变虚拟地址空间时,页表缓存TLB(快表)也会被刷新,会使内存访问在一段时间内变得低效,也包括硬件Cache,但线程切换时不会出现这种问题。
-
-
模块化,可以将一个复杂的任务拆分为多个子任务,每个子任务由一个线程来处理,可以使程序的结构更加清晰,易于理解和维护。
-
可并行处理,在多核处理器系统中,线程可以并行执行不同的任务,能充分利用多核心处理器的可并行数量。
-
I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
-
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
-
2.2.2 线程的缺点
-
存在性能损耗,计算密集型线程是很少被外部阻塞的,它们往往无法与其它线程共用一个处理器,当线程数大于处理器数量时,切换上下文时的缓存失效,IO阻塞等问题会带来较大的性能开销。
-
线程的并发执行特性容易引入难以预测的错误,降低程序的健壮性,可能存在内存模型问题、线程饥饿和死锁等等问题。也有可能因某个线程的崩溃而导致进程异常退出。
-
线程编程需要处理复杂的同步和并发问题,对开发者要求较高。
2.3 进程与线程
-
进程是资源分配的基本单位。
-
线程是调度的基本单位。
-
线程也有独属于自己的资源:
-
线程ID
-
一组寄存器
-
栈
-
errno(错误码)
-
信号屏蔽字
-
调度优先级
-
在一个进程的地址空间,是进程内线程共享的:
-
例如
Text Segment
、Data Segment
等等。 -
文件描述符表
-
每种信号的处理方式
-
当前工作目录
-
用户ID以及组ID
3. 线程控制
3.1 POSIX线程库
POSIX Threads,简称Pthreads,Pthreads作为POSIX标准的一部分,定义了约100个以pthread_
为前缀的API函数,涵盖线程创建、互斥锁、条件变量、屏障等操作。
-
使用需包含头文件
<pthread.h>
-
编译时需添加
-lpthread
选项链接线程库,或使用-pthread
标志自动完成链接与预处理设置。 -
Linux内核通过
clone
系统调用实现线程创建与管理,减少抽象层开销,提升性能。
3.2 线程控制相关函数
3.2.1 线程创建
创建一个新的线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void * (*start_routine)(void*), void *arg);
参数:thread:输出型参数,返回线程ID。 attr:设置线程的属性,attr为NULL表示使用默认属性 。start_routine:是个函数地址,线程启动后要执行的函数 。arg:传递给 start_routine 的参数,类型为 void*。若无需传递参数,可传 NULL。
返回值:成功返回0;失败返回错误码(需要手动接收)它并不会设置全局errno,而是将errno返回。
这里打印出来的线程tid
,是通过pthread_self
得到的,它返回的是一个pthread_t
类型的值,表示该线程ID。这个ID是由pthread库维护的,是进程内(没错,只是在进程内,并不是系统级别的)线程的唯一标识。
我们可以使用ps指令来查看-L表示打印线程信息。
在Linux中,LWP(Light Weight Process,轻量级进程) 是操作系统实现多任务的核心机制,本质是内核支持的用户线程,兼具进程的隔离性与线程的高效性。
没错,在Linux中线程并不是真正的线程,而是轻量级进程。
这里的LWP才是真正的线程ID,pthread_self返回的其实是虚拟空间上的一个地址,通过这个地址可以找到该线程的基本信息。
可以看到有一个线程的PID与LWP相同,这个就是主线程。与其它线程不同的是,主线程的栈就在虚拟地址空间的栈上,而其它线程的栈是在虚拟地址空间的共享区内。
3.2.2 线程终止
只终止线程而不终止进程的话,常用4种办法:
-
在线程执行函数中使用
return
,这个不会对主线程造成影响。 -
使用
pthread_cancle
,这是最标准、最常用的终止线程的方法,也可以一个线程调用该函数结束另一个线程。
功能:取消一个执行中的线程
int pthread_cancel(pthread_t thread);
参数: thread:线程ID
返回值:成功返回0;失败返回错误码。⚠️ 注意:线程必须能到达取消点才能被终止。若在线程中长时间进行计算而无取消点,可用 pthread_testcancel() 插入检查点。
配套函数:
- pthread_setcancelstate():设置是否允许取消。
- pthread_setcanceltype():设置取消类型(延迟取消或异步取消)。
尽量不要使用pthread_cancle结束线程,除非必要。
它就像你通过把电源线关电脑一样,即使是关掉了线程,但里面的资源却不会自己释放。即使可以通过使用
pthread_cleanup_push()或pthread_cleanup_pop() 注册清理函数来解决这个问题,可是很容易忘记去
注册这个清理函数。
3. 在线程函数中调用pthread_exit
可以立即终止当前线程。
功能:线程终止
void pthread_exit(void *value_ptr);
参数: value_ptr:value_ptr不要指向一个局部变量。
返回值: 无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)。
注:不能在子线程中调用 exit(),否则会终止整个进程!
4. 使用标志位+条件判断的协作式终止,通过共享变量的方式来通知线程终止。可靠性强的同时也够安全,缺点就是需要线程配合轮询标志位。
看代码--简单易懂
volatile int should_stop = 0;void* thread_func(void* arg) {while (!should_stop) {//业务代码printf("Working...\n");sleep(1);}printf("Thread exiting gracefully.\n");return NULL;
}// 主线程中
should_stop = 1; // 设置退出标志
pthread_join(thread_id, NULL); // 等待线程结束
3.3.3 线程等待
每次新建线程都会开辟一段空间给线程使用,在线程退出之后,它的空间没有被释放,依旧在进程的地址空间内,如果不进行等待回收,就会泄露资源,进而泄露系统资源。
看代码--简单易懂
volatile int should_stop = 0;void* thread_func(void* arg) {while (!should_stop) {//业务代码printf("Working...\n");sleep(1);}printf("Thread exiting gracefully.\n");return NULL;
}// 主线程中
should_stop = 1; // 设置退出标志
pthread_join(thread_id, NULL); // 等待线程结束
调用该函数的线程会挂起等待,直到tid
为thread
的线程退出才恢复。
通过pthread_join
得到的返回值一般有以下几种:
-
如果
thread
以return
返回,那么value_ptr
则指向线程函数的返回值。 -
如果
thread
线程是因pthread_cancle
而异常退出,那么value_ptr
则指向常数PTHREAD_CANCLE
。 -
如果
thread
是自己调用pthread_exit
函数退出的话,value_ptr
则指向传递给pthread_exit
函数的参数。 -
如果不关心
thread
的返回值,可以传NULL
。
3.3.4 线程分离
正常情况下,我们新建的线程都是joinable的,在线程退出后,我们需要去调用pthread_join去等待回收。
如果我们不关心线程的返回值,这时候去join线程,反而是一种负担,我们可以给线程设置detach,让它自动释放资源。
int pthread_detach(pthread_t thread);
返回值:
- 0:表示成功。
- 错误代码:如果函数失败,则返回错误代码。常见错误包括:ESRCH:指定的线程 ID 不存在。 EINVAL:线程已处于分离状态。
在线程内也可以传递pthread_self()
,来把自己分离。pthread_detach(pthread_self());
。
4. 进程的地址空间布局
我们前面提到在pthread_create
时会产生一个线程ID,而这个线程ID(本质上用户空间线程的抽象标识符)通常是指向struct pthread
的指针,而这个结构体内含真正的线程ID、栈地址、退出状态等元数据,那么这个结构体内存单元肯定就在进程地址空间内。
线程库NPTL提供函数来获取线程自身的ID:
pthread_t pthread_self(void);
如下图↓
每个包含了pthread头文件的程序,都会将动态库文件加载到自己的地址空间内,这样进程就可以调用对应的方法来创建线程,通过相应的结构体来管理线程,这也是为什么线程ID只在进程内部生效,因为它就是库来管理的,而库是加载到进程内的地址空间内的。
对于线程栈,先看进程的栈,在进程fork
出子进程之后,子进程实际是复制了父进程的stack
,它依旧是动态增长的,一旦超出了大小,是会报错的。子线程的栈,它实质是在进程共享区中通过mmap
函数map
出来的空间,一旦用尽就没了。线程创建时glibc
通过mmap
得到stack
后会调用sys_clone
系统调用将这个stack
指针传给另一个系统调用do_fork
,交给它去创建线程。
下期见