🔥个人主页🔥:孤寂大仙V
🌈收录专栏🌈:Linux
🌹往期回顾🌹:【Linux笔记】------进程信号的捕捉------从中断聊聊OS是怎么"活起来"的
🔖流水不争,争的是滔滔不息
一、线程的概念
初步理解

之前学过进程,进程=内核数据结构+代码和数据,线程是进程内部的一个执行分流。进程承担分配系统资源的基本实体,线程是CPU调度的基本单位。这里的概念都是比较官方的。
Linux的线程可以采用进程来模拟,进程访问的大部分资源都是通过地址空间来访问的,将资源分配给不同的task_struct,不就是用进程模拟出了线程吗,(对资源的划分本质就是对地址空间虚拟地址范围的划分,虚拟地址就是资源的代表)。
Linux的线程就是轻量级进程,或者说是轻量级模拟实现的。一个进程的PCB(进程控制块)下有多个线程的TCB(线程控制块),这些就指向了进程地址空间的特定的区域,执行特定的任务。
Linux 线程是通过"共享地址空间的轻量级进程"模拟实现的,资源由进程统一管理,调度由 task_struct 决定。
线程简介
在Linux中,线程是一种比进程更轻量级的执行单元,线程共享同一个进程的地址空间、全局变量、文件描述符等资源,到那每个线程有自己独立的寄存器上下文、栈、线程局部存储(TLS)。
从内核角度看,Linux没有特意区分进程和线程,线程本质就是一个"特殊的进程"。Linux用task_struct这个数据结构来表示"任务",无论是线程还是进程都是task。
进程是资源的集合,进程是调度的最小单位。
Linux下的线程通常通过clone()系统调用创建(pthread底层也是clone),区分在于参数指定了资源共享的范围。这也是为什么Linux下线程其实是共享部分资源的轻量进程。
Linux线程,本质上是资源共享更紧密的进程,调度上被看作独立任务,资源管理上依赖所属进程。
简化理解
线程就是"轻量级进程",是跑在进程资源上的"执行流"。一个进程可以有多个线程,这线程共享同一个进程的地址空间(代码、数据、堆、文件描述符),但是各自有独立的栈、寄存器上下文、线程局部存储。
进程是"资源大本营"负责存放代码、数据、打开的文件、堆内存。
线程是"干活的工人"负责具体跑代码的那条"执行流 "。线程是进程的执行流
多个线程一起"逛"同一个进程的大本营,各忙各的,但是用的是同一批资源。
线程是跑在进程资源上的执行单元,本质是更轻巧的进程。
那么这个大的进程还干活吗,还是大的进程创建好,只负责资源划分了,活都让线程干了???
所谓的大进程,其实最初就是主线程,它既是'资源归属的壳',也是第一个干活的线程。后面创建的新线程大家一起干活,但本质上干活的永远是线程。
二、分页式存储管理
4KB页框
文件在磁盘存储的时候,以4kb为单位进行储存无论是属性还是内容。
不管是磁盘还是内存都是以4kb划分的,在物理内存中4kb大小的内存块叫做页框或者页帧,按照一个页框4kb那么4gb的空间就有1048576个页框,操作系统对这些页框管理起来要先描述在组织。
内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使用了大量的联合体union。
cpp
/* 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_64BIT
int pages; /* Nr of pages left */
int pobjects; /* Approximate count */
#else
short 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 与物理页相关,而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体,让我们来算算对所有这些页都这么做,到底要消耗掉多少内存。
算 struct page 占40个字节的内存吧,假定系统的物理页为 4KB 大小,系统有 4GB 物理内存。那么系统中共有页面 1048576 个(1兆个),所以描述这么多页面的page结构体消耗的内存只不过40MB ,相对系统 4GB 内存而言,仅是很小的一部分罢了。因此,要管理系统中这么多物理页面,这个代价并不算太大。要知道的是,页的大小对于内存利用和系统开销来说非常重要,页太大,页必然会剩余较大不能利用的空间(页内碎片)。页太小,虽然可以减小页内碎片的大小,但是页太多,会使得页表太长而占用内存,同时系统频繁地进行页转化,加重系统开销。因此,页的大小应该适中,通常为 512B -8KB ,windows系统的页框大小为4KB。
这个页的下标被struct page_mem存储,转化为了对数组下标的操作,每个页都会有下标,那么每个页的起始地址肯定就可以知道了。所以就要这个偏移量从哪得来呢?
页表
物理页地址也是页框的地址
把这个单⼀页表拆分成 1024 个体积更小的映射表。如下图所示。这样⼀来,1024(每个表中的表项个数) * 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。 这里的每⼀个表,就是真正的页表,所以⼀共有 1024 个页表。⼀个页表自身占用 4KB ,那么1024 个页表⼀共就占用了 4MB 的物理内存空间,和之前没差别啊?
从总数上看是这样,但是⼀个应用程序是不可能完全使用全部的 4GB 空间的,也许只要几十个页表就可以了。例如:⼀个用户程序的代码段、数据段、栈段,⼀共就需要 10 MB 的空间,那么使用 3 个页表就足够了
页目录结构
前面每个页框都被一个也表中的表项指向了,那么1024个页表也需要被管理起来,页目录是管理页表的表形成二级目录。
页目录的物理地址被CR3寄存器指向,CR3寄存器保存了当前正在执行任务的页目录地址。操作系统在加载用户程序的时候不仅要为程序内容分配物理内存,还需要为用来保存程序的页目录和页表分配物理内存。
虚拟地址到物理地址的转化
二级页表的地址转化

如图虚拟地址的前10位查页目录表,找到对应的页表地址。中间10位查页表,找到对应的页框的地址。最后12位页框内部的偏移量。这时就拿到了页内偏移量,就能找到对应的物理内存的地址了(页框地址+页内偏移量)。
页目录的索引:前10位查页目录表,找到对应的页表地址。
页表的索引:中间10位查页表,找到对应的页框的地址.
页内偏移:最后12位,页框内部偏移量。
第一阶段:根据CR3寄存器 查找到页目录的地址,然后通过页目录索引(下级页表的地址),查到页表的地址。
第二阶段:根据页表的地址+页表的索引,查找到页框的物理地址。
第三阶段:页框的物理地址+(页内偏移)最后12位,反问要找的物理内存。
MMU(内存管理单元) 就是这套虚拟地址 → 物理地址映射的**"自动执行者"**。一旦分页开启(CR0.PG = 1),后续这些页表查找、地址换算的"繁琐活"全都扔给 MMU 了,CPU 自己都不操心。
CPU:哥只管指令和虚拟地址,物理内存你 MMU 自己看着办!
MMU里面有TLB(快表),缓存最近用过的虚拟地址到物理页的映射。如果没有TLB重复查页表会慢,有了TLB命中一次在查表查到重复的时候就瞬间映射了。TLB没有命中才需要MMU去走查表流程。
前面提到过CR3寄存器,这个就是告诉MMU这个进程的页目录表在哪里,进程切换的时候操作系统会更新CR3寄存器,MMU就会去新的页目录去映射虚拟地址了。
申请内存的时候要先查找数组就是上面所说的那个存储页框下标的数组,找到没有被使用过的页框,然后走上面虚拟地址到物理地址的一套流程,找到物理内存的地址。
写时拷贝、缺页中断、内存申请等等,背后都可能要有重新建立新的页表和建议映射关系的操作。顺带提一嘴,这个写时拷贝,申请物理内存的时候是拷贝一块页框。
上面的一张页目录+n张页表构成了映射体系,虚拟地址是索引,物理地址页框是目标。
对线程的深刻一点的认识**有了分页储存的知识,线程进行资源划分,本质是划分地址空间,获得一定范围的合法的虚拟地址,本质上是在划分页表。**线程的资源共享,本质是对地址空间的共享,是对页表条目的共享。
三、线程的优缺点
-
资源共享,通信高效
同一进程的线程共享:代码段、数据段、堆、打开的文件描述符等资源。
线程之间的数据交换不需要复杂的进程间通信(IPC),直接读写共享数据即可,天然高效。
📝对比一下:
进程通信靠管道、消息队列、共享内存,麻烦又有性能损耗;
线程通信,指针一传,内存一读写,简单粗暴,快得离谱。
-
创建销毁开销小
线程的创建、销毁、切换所需的时间、内存都比进程小得多。
因为线程本质上是"共用进程资源的小分身",系统不用像创建进程那样"从头来过"。
📝 举个例子:
nginx、MySQL、高性能服务器动不动上千上万的并发连接,要是每个都用进程,那光是切换调度就能把 CPU 熬死。
-
并发编程的利器
多线程可以充分利用多核 CPU 的计算能力,把任务分解后并行处理,大幅提升程序吞吐量。
尤其是 I/O 密集型、计算密集型场景,多线程是提升性能的标配。
-
更细粒度的控制
用线程可以灵活地拆分任务,比如前端渲染、后端计算、网络通信各跑各的线程,让程序逻辑更清晰。还能配合线程池、异步模型,打造高性能、高并发的架构。"线程 = 共享资源下的高效并发武器",相比动不动就"各自为政"的进程,线程的优雅在于:共用、快速、并行、灵活。
线程的缺点:甜蜜背后也有苦果
- 共享带来的"安全隐患"
线程虽然共享资源,但**"你也动、我也改"**,就容易出事。
数据竞争、死锁、脏数据、野指针......这些多线程的噩梦都来自于共享带来的同步问题。
📝 举个栗子:
两个线程都在更新一个全局变量,如果没有加锁,那谁先谁后完全看心情,结果就是------诡异的bug,必现!
-
同步机制带来的性能消耗
为了解决线程安全问题,只能用锁、信号量、原子操作这些"同步大法"。
然而同步本身就意味着等待、阻塞、上下文切换,过多的锁竞争反而会拖慢性能。
📝 面试高频问:
"为什么锁要小而精(细粒度锁)?因为大锁一加,线程都排队了,没法并发了。"
-
调试、排错极其困难
单线程的 bug 一般都是顺序可复现的,多线程的 bug 属于"玄学"级别:
时序依赖、偶现性、复现难度极高。
多线程代码一旦出错,经常是"开发怀疑人生,运维夜不成寐"。
-
线程数量不是想开多少就开多少
线程虽然轻量,但不是无限轻。
每个线程都有自己的栈(通常是几百KB~1MB),开太多线程会导致内存耗尽,反而拖垮系统。
📝 这就是为什么需要"线程池",防止野蛮生长。
-
线程调度依然有开销
虽比进程轻,但线程的上下文切换、调度竞争仍然消耗 CPU。在高并发下,不合理的线程数会导致线程风暴,反而让系统性能雪崩。
"线程带来高效,也埋下隐患。想用好多线程,得有同步的智慧、调度的节制、代码的洁癖。"