Linux:线程的概念与控制
目录
编辑
1.Linux线程概念
1.1什么是线程
1.2分页式存储管理
1.3线程的优点
1.4线程的缺点
1.5线程异常
1.6线程用途
2.Linux进程VS线程
2.1进程和线程
2.2进程的多个线程共享
3.Linux线程控制
3.1POSIX线程库
3.2创建线程
3.3线程终止
3.4线程等待
3.5分离线程
4.线程ID及进程地址空间布局
1.Linux线程概念
1.1什么是线程
• 线程(thread)是程序中的一条独立执行路径。更准确地说,线程是"进程内部的控制序列"
• 每个进程至少包含一个执行线程
• 线程运行在进程内部,确切地说是在进程的地址空间内运行
• 在Linux系统中,CPU视角下的PCB(进程控制块)比传统进程更加轻量级
• 通过进程虚拟地址空间可以查看进程的大部分资源,将这些资源合理分配给各个执行流,就形成了线程执行流

1.2分页式存储管理
(1)虚拟地址和页表的由来
思考一下,如果没有虚拟内存和分页机制,每个用户程序在物理内存中的存储空间必须是连续的,如下图所示:

由于不同程序的代码和数据量各不相同,采用直接映射方式会导致物理内存被分割成大小不一的离散区块。随着程序运行一段时间后,部分程序退出会释放它们占用的内存空间,这些回收的内存会形成大量碎片。
为了解决这个问题,我们需要操作系统为用户提供连续的内存空间,同时避免物理内存的碎片化问题。这时就引入了虚拟内存和分页机制,具体实现方式如下图所示:

物理内存被划分为固定长度的页框(也称物理页)。每个页框对应一个物理页,且两者大小相同。在32位体系结构中,通常支持4KB的页大小;而64位体系结构则通常支持8KB的页大小。需要注意的是,"页"和"页框"是两个不同的概念,必须加以区分。
• 页框是内存中的一个固定存储区域;
• 页则是数据块,既可存放于页框也可存储于磁盘。
有了这种机制,CPU便并⾮是直接访问物理内存地址,⽽是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执⾏的进程分配的⼀个逻辑地址,在32位机 上,其范围从0~4G-1。
操作系统通过建立虚拟地址空间与物理内存地址之间的映射关系(即页表),记录每个页与页框的对应关系,使CPU能够间接访问物理内存地址。
简而言之,该机制的核心思想是将虚拟内存的逻辑地址空间划分为若干页,同时将物理内存空间划分为若干页框。通过页表的映射,连续的虚拟地址空间可以被分配到多个不连续的物理内存页框中,从而有效解决了因使用连续物理内存而产生的碎片问题。
(2)物理内存管理
假设可用物理内存空间为4GB,以4KB为一个页框单位进行划分,总页框数为4GB/4KB=1,048,576个。操作系统需要有效管理这些物理页框,包括跟踪哪些页框正在使用、哪些处于空闲状态等信息。
内核使用 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;};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 */...}
关键参数包括:
-
flags:用于记录页面的状态信息,包括脏页标记、内存锁定状态等。该字段采用位掩码机制,每个比特位代表一种独立状态,因此至少可同时表示32种不同状态。相关标志定义在<linux/page-flags.h>头文件中,其中关键标志位包括:PG_locked标识页面锁定状态,PG_uptodate表示页面数据已成功从块设备读取且无错误。
-
_mapcount:记录页表项中指向该页的引用计数。当计数值变为-1时,表明当前内核不再引用此页,该页便可重新分配使用。
-
virtual:存储页面对应的虚拟地址。对于常规内存,该地址即为其在虚拟内存空间中的固定映射地址;而高端内存由于不永久映射到内核地址空间,此字段值为NULL,需在使用时动态建立映射关系。
需要注意的是,struct page 关联的是物理页而非虚拟页。系统中的每个物理页都需要分配这样一个结构体,让我们计算一下所有物理页采用这种机制会占用多少内存。
假设每个 struct page 占用 40 字节内存,系统物理页大小为 4KB。对于 4GB 物理内存的系统来说,共有 1,048,576 个物理页(即 1 兆个)。存储这些页面的 page 结构体仅消耗约 40MB 内存,仅占系统总内存的 1%左右。由此可见,管理如此多物理页面的内存开销其实非常有限。
需要注意的是,页面大小直接影响内存利用率和系统性能。过大的页面会导致较多未被利用的空间(页内碎片),而过小的页面虽然能减少页内碎片,但会增加页表长度,占用更多内存,同时频繁的页面转换也会增加系统开销。因此,页面大小应当适中,通常在512B到8KB之间。例如,Windows系统的页框大小就设置为4KB。
(3)页表
页表中的每个表项都指向一个物理页的起始地址。在32位系统中,每个用户程序都拥有4GB的虚拟内存空间。要使整个4GB虚拟内存空间都可访问,页表需要包含4GB/4KB=1,048,576个表项。如下图所示:

虚拟内存看似被虚线"分隔"成多个单元,但实际上仍是连续的地址空间。这些虚线划分仅表示虚拟内存与页表中各表项的映射关系,最终会对应到相同大小的物理内存页上。
页表中的物理地址与物理内存之间是随机映射关系,只要有可用的物理页就会被分配。虽然最终使用的物理内存是离散的,但对应的虚拟内存线性地址始终是连续的。处理器在访问数据和获取指令时使用的都是连续的线性地址,这些地址最终都能通过页表转换为实际的物理地址。
在32位系统中,每个4字节的地址对应页表中的一个表项。因此,整个页表占用的空间为1048576×4=4MB。这意味着仅页表本身就需要占用1024个物理页(4MB/4KB)。这种设计会带来哪些潜在问题?
• 回顾当初采用页表的初衷,是为了将进程划分为若干个页,从而实现非连续物理内存分配。但现在的情况是,页表本身需要占用1024个连续的页框,这与最初的设计目标似乎存在矛盾。
• 再者,根据程序运行的局部性原理,大多数情况下进程只需要访问少数特定页就能正常运行。因此,完全没有必要将所有物理页都长期驻留在内存中。
优化大容量页表的最佳方案是将页表视为普通文件,采用离散分配策略,即对页表进行二次分页,从而形成多级页表结构。
为解决此问题,可将单个页表拆分为1024个规模更小的映射表。如图所示,通过1024(单个表的表项数量)×1024(表总数)的设计,依然能够完整覆盖4GB物理内存空间。

每个表实际上就是一个真实的页表,因此系统中共有1024个页表。每个页表本身占用4MB的物理内存空间,这与先前的配置完全一致。
从总量来看确实如此,但实际应用中程序几乎不会占用全部4GB内存空间。通常只需几十个页表就足够满足需求。以典型的用户程序为例:其代码段、数据段和栈段合计约10MB内存,这种情况下仅需3个页表即可完成管理。
(4)页目录结构
目前,每个页框都通过页表中的一个表项进行映射。为有效管理这1024个页表,系统引入了页目录表,从而形成二级页表结构,如下图所示:

- 页表的物理地址由页目录表项指向
- 页目录的物理地址由CR3寄存器指向,该寄存器存储了当前执行任务的页目录地址
操作系统在加载用户程序时,不仅需要为程序内容分配物理内存,还需要为存储程序页目录和页表的空间分配物理内存。
(5)二级页表的地址转换
以下是一个逻辑地址的转换示例,展示了如何将逻辑地址(0000000000,0000000001,11111111111)转换为物理地址的过程:
-
在32位处理器架构中,若采用4KB页面大小,虚拟地址的低12位用作页内偏移,剩余高20位分为两级页表寻址,每级各占10位(10+10结构)。
-
首先通过CR3寄存器获取页目录基地址,再根据一级页号索引页目录表,定位下一级页表的物理内存位置。
-
通过二级页号索引页表,最终获取目标内存块号。
-
将内存块号与页内偏移量组合,生成最终的物理地址。

-
注意:物理页地址始终是4KB对齐的(最低12位全为0),因此实际只需记录物理页地址的高20位即可。
-
上述过程即为MMU的工作机制。MMU(内存管理单元)是一种高速硬件电路,主要负责内存管理工作,而地址转换只是其众多功能之一。
到这里还存在一个问题:MMU需要先进行两次页表查询来确定物理地址,在完成权限验证等操作后,才会将这个物理地址发送到总线。内存收到请求后开始读取对应地址的数据并返回。当页表层级增加到N级时,整个流程就变成了N次查询检索加上1次实际读写操作。由此可见,页表层级越多,查询步骤就越多,这会导致CPU的等待时间延长,整体效率降低。
总结来看:虽然单级页表对连续内存的依赖性强,但多级页表通过降低连续存储要求和节省空间的方式实现了改进,不过这种改进是以牺牲查询效率为代价的。
想提高效率吗?计算机科学告诉我们:任何问题都可以通过增加中间层来解决。MU 推出了新武器 - 快表(TLB),实际上就是一种缓存机制。
CPU 向 MMU 发送新的虚拟地址后,MMU 首先查询 TLB 是否存在对应的映射记录。如果命中(Cache Hit),则直接获取物理地址并发送到内存总线,完成寻址。
由于 TLB 容量有限,难免会出现缓存未命中(Cache Miss)的情况。此时 MMU 会转而查询页表,找到对应映射关系。在将物理地址发送到内存总线的同时,MMU 还会将该映射关系回填至 TLB,以更新缓存内容。

(6)页面缺失异常
当CPU向MMU提供的虚拟地址在TLB和页表中均未找到对应的物理页时,系统将触发缺页异常(Page Fault)。这种异常由硬件中断引发,但可以通过软件逻辑进行修正处理。
当目标内存页在物理内存中不存在对应物理页,或虽有对应页但缺乏访问权限时,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)并终止进程。
1.3线程的优点
• 创建线程的成本远低于创建进程
• 线程切换所需的操作系统开销比进程切换更小
- 关键区别在于线程切换时虚拟内存空间保持不变,而进程切换则需要改变虚拟内存空间。这两种上下文切换都由操作系统内核处理,寄存器内容切换会带来显著的性能开销
- 另一个潜在影响是上下文切换会破坏处理器缓存机制:切换发生后,处理器中所有缓存的内存地址都将失效。当虚拟内存空间改变时,TLB(快表)会被完全刷新,导致一段时间内内存访问效率降低。线程切换则不会出现这些问题,硬件缓存机制也能保持稳定
• 线程的资源占用比进程更少
• 能够充分利用多处理器的并行计算能力
• 在等待慢速I/O操作完成时,程序可以继续执行其他计算任务
• 计算密集型应用可将任务分解到多个线程中,以充分利用多处理器系统
• I/O密集型应用可通过线程并发等待不同I/O操作,实现操作重叠以提高性能
1.4线程的缺点
• 性能影响 ◦ 计算密集型线程若很少被外部事件阻塞,通常难以与其他线程共享处理器资源。当此类线程数量超过可用处理器时,可能导致显著性能下降,主要表现为同步和调度开销增加,而系统资源总量并未改变。
• 稳定性风险 ◦ 多线程编程需更全面深入的考量。由于时序分配的细微差异或不当的共享变量访问,极易引发程序异常。线程间缺乏有效隔离机制,使得系统健壮性面临挑战。
• 权限控制局限 ◦ 操作系统以进程为基本权限控制单元。线程中调用的某些系统函数可能影响整个进程的运行状态。
• 开发复杂度 ◦ 相比单线程程序,多线程程序的开发与调试难度显著增加,对开发者提出更高要求。
1.5线程异常
• 如果单个线程出现除零或野指针等问题导致崩溃,整个进程也会随之终止
• 线程作为进程的执行分支,其异常会触发信号机制终止进程,进而导致该进程下的所有线程退出
1.6线程用途
• 合理运用多线程技术可显著提升CPU密集型程序的执行效率
• 多线程在IO密集型程序中能改善用户体验(例如编程时同时下载开发工具,正是多线程运行的典型应用场景)
2.Linux进程VS线程
2.1进程和线程
• 进程是资源分配的基本单位
• 线程是调度的基本单位
• 线程共享进程数据,但也拥有⾃⼰的⼀部分数据:
◦ 线程ID
◦ ⼀组寄存器
◦ 栈
◦ errno
◦ 信号屏蔽字
◦ 调度优先级
2.2进程的多个线程共享
同一地址空间内,TextSegment和DataSegment都是共享的。这意味着:
- 定义的函数可被所有线程调用
- 定义的全局变量可被所有线程访问
此外,各线程还共享以下进程资源和环境:
• ⽂件描述符表
• 每种信号的处理⽅式(SIG_IGN、SIG_DFL或者⾃定义的信号处理函数)
• 当前⼯作⽬录
• ⽤⼾id和组id
进程和线程的关系如下图:

3.Linux线程控制
3.1POSIX线程库
• 线程相关函数构成完整系列,绝大多数以"pthread_"前缀命名
• 使用这些函数需包含头文件<pthread.h>
• 编译时需添加"-lpthread"链接选项
3.2创建线程
功能:创建⼀个新的线程
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: 传给线程启动函数的参数
返回值:成功返回 0 ;失败返回错误码
错误检查:
• 传统函数通常采用以下错误处理方式:成功返回0,失败返回-1,并通过设置全局变量errno来指示具体错误类型。
• pthreads函数的错误处理机制有所不同:它们不会设置全局变量errno(这与大多数其他POSIX函数不同),而是直接通过返回值传递错误代码。
• 虽然pthreads也提供了线程局部存储的errno变量以兼容使用errno的代码,但建议优先通过返回值来判断错误。因为读取返回值的性能开销要小于访问线程局部errno变量。

打印出来的tid是通过pthread库中有函数 pthread_self 得到的,它返回⼀个pthread_t类型的 变量,指代的是调⽤pthread_self函数的线程的“ID”。
怎么理解这个“ID”呢?这个“ID”是pthread库给每个线程定义的进程内唯⼀标识,是pthread库维持的。
由于每个进程有⾃⼰独⽴的内存空间,故此“ID”的作⽤域是进程级⽽⾮系统级(内核不认识)。
其实pthread库也是通过内核提供的系统调⽤(例如clone)来创建线程的,⽽内核会为每个线程创建系统全局唯⼀的“ID”来唯⼀标识这个线程。
使⽤PS命令查看线程信息:

什么是LWP?LWP获取的是真实的线程ID。而之前使用的pthread_self返回的值实际上是一个虚拟地址空间中的地址,通过该地址可以访问到线程的基本信息,包括线程ID、线程栈和寄存器等属性。
在ps -aL命令输出的线程ID中,若某个线程ID与进程ID相同,则该线程即为主线程。主线程的栈位于虚拟地址空间的栈区域,而其他线程的栈则位于共享区(介于堆与栈之间的区域)。这是因为pthread系列函数均由位于共享区的pthread库提供,因此除主线程外,其他线程的栈都位于共享区。
3.3线程终止
要终止某个线程而不影响整个进程,可采用以下三种方法:
-
通过线程函数返回退出。注意此方法不适用于主线程,从main函数返回相当于调用exit。
-
线程可通过调用pthread_exit函数主动终止自身。
-
线程可以使用pthread_cancel函数终止同一进程中的其他线程。
(1)pthread_exit函数
功能:
线程终⽌原型:void pthread_exit(void *value_ptr);参数: value_ptr:value_ptr不要指向⼀个局部变量。返回值:
⽆返回值,跟进程⼀样,线程结束的时候⽆法返回到它的调⽤者(⾃⾝)
需要注意:通过 pthread_exit 或 return 返回的指针必须指向全局变量或 malloc 分配的内存,不能指向线程函数的栈内存。因为其他线程获取该指针时,原线程函数已经退出,栈内存会被释放。
(2)pthread_cancel函数
功能:取消⼀个执⾏中的线程原型:int pthread_cancel(pthread_t thread);参数: thread:线程ID返回值:成功返回0;失败返回错误码
3.4线程等待
为什么需要线程等待?
• 已退出的线程仍会占用进程地址空间,其资源未被释放
• 新创建的线程无法复用已退出线程的地址空间
功能:等待线程结束原型:
int pthread_join(pthread_t thread, void **value_ptr);参数: thread:线程IDvalue_ptr:它指向⼀个指针,后者指向线程的返回值返回值:成功返回0;失败返回错误码
调用该函数的线程将进入挂起等待状态,直到指定ID的线程终止。不同终止方式会导致pthread_join返回不同的状态值,具体如下:
-
当线程通过
return语句正常退出时,value_ptr指向的内存单元将存储该线程函数的返回值。 -
若线程被其他线程通过
pthread_cancel异常终止,value_ptr指向的单元会存入常量PTHREAD_CANCELED。 -
如果线程通过调用
pthread_exit自行终止,value_ptr将保存传递给pthread_exit的参数值。 -
若不关心线程的终止状态,可将
value_ptr参数设为NULL。
3.5分离线程
• 新创建的线程默认处于可连接(joinable)状态,线程终止后必须调用pthread_join进行回收,否则会导致资源无法释放,引发内存泄漏。
• 若无需获取线程返回值,手动调用join操作会带来额外负担。此时可设置线程属性,使其在退出时自动释放资源。
int pthread_detach(pthread_t thread);
线程分离可通过两种方式实现:线程组内其他线程主动分离目标线程,或由线程自身执行分离操作。
pthread_detach(pthread_self());
joinable和分离是冲突的,⼀个线程不能既是joinable⼜是分离的。
4.线程ID及进程地址空间布局
• pthread_create函数会生成一个线程ID,并存储在第一个参数指定的地址中。需要注意的是,该线程ID与前文提到的线程ID概念不同。
• 前文所述的线程ID属于进程调度范畴。由于线程是轻量级进程,作为操作系统调度器的最小单位,需要一个唯一标识符来区分不同线程。
• pthread_create函数的第一个参数指向一个虚拟内存单元,该内存地址即为新创建线程的线程ID。这个ID属于NPTL线程库的管理范畴,线程库后续的所有操作都基于这个ID进行。
• NPTL线程库提供了pthread_self函数,用于获取线程自身的ID。
pthread_t pthread_self(void);
pthread_t 的具体类型取决于系统实现。在 Linux 的 NPTL(Native POSIX Thread Library)实现中,这个线程 ID 实际上对应着进程地址空间中的一个内存地址。



