【Linux篇章】线程操控术1:如何让代码如千军万马听你号令(精讲线程概念)
本篇文章将介绍之前学的进程和如今的进程是什么关系;如何进行过渡;先从感性认识进程;然后引入物理内存;分块管理;虚拟地址空间以及对之前讲述的页表展开详细结构的讲解;最后拿线程与进程对比差同优缺点总结;把认知从之前的单线程进程过渡到多线程进程;为下一篇线程控制打下理论基础铺垫。
博主主页:羑悻的小杀马特.-CSDN博客
欢迎拜访:羑悻的小杀马特.-CSDN博客本篇主题:秒懂百科之探秘Linux线程的相关概念(通俗易懂版)
制作日期:2025.05.26
隶属专栏:linux之旅
目录
一·从感性出发来认识线程:
1·1浅谈线程:
1·2线程感性认识总结:
二·理性的去认识线程:
2.1如何进行内存加载:
2.2引入页表:
2.2.1页表样式:
2.2.2虚拟地址如何通过页表完成到物理地址的映射:
2.2.3现代寻址优化:
2.2.4写实拷贝,缺页异常,申请内存等方面结合页表的进一步理解:
写实拷贝:
申请内存(如malloc):
关于是缺页异常还是越界访问问题:
2.3对线程深刻理解:
2.3.1线程优点:
2.3.2线程缺点:
2.4线程异常:
2.5线程用途:
2.6Linux的进程VS线程:
三·本篇小结:
之前我们讲述了进程相关的话题;这次我们来谈一谈和它密切相关的线程话题!
一·从感性出发来认识线程:
1·1浅谈线程:
首先,大致理解下线程:
主要任务从某个函数入口去执行;有共享和独占;比如全局资源共享。可以理解成:一个进程内部的控制序列。
那它到底和之前的进程是什么关系:
进程:内核数据结构+代码和数据(执行流)
线程:是进程内部的一个执行分值(执行流)
注:tast_struct!=进程
如果从内核与资源角度可以这么理解:
进程:承担分配系统资源的基本实体。
线程:CPU调度的基本单位。
对于我们之前学的进程来说;它在执行的时候就是在自己的虚拟地址空间来回跳转;而线程呢?
之前的单线程进程现在的多线程进程,线程也是在自己的虚拟地址空间(这里可以理解成它所属于的进程的那块虚拟内存地址空间)完成跳转运行的。
线程与进程是什么样的对应关系呢?
一个进程可以有多个线程(包括主线程);在数量上线程是大于进程的;也就是说一个进程至少有一个线程;而从大等级方面可以类似想象成进程是大于线程的;后面我们会从本质讲清楚它们之间的对应关系。
线程又是如何形成的呢?
透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
对于每种平台对于线程的实现等方面还是略有差异的:
在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化;也就是说我们是用这个轻量化进程来模拟的“线程”。(主打一个复用自己的进程来实现)
在windows系统中,它是实打实的来实现线程的。(重新描述组织来实现真正的线程)
下面我们就主要从linux的视角下去认识线程:
虚拟内存空间:进程访问的大部分资源,都是通过地址空间访问的(地址空间是“窗口”)创建一个“进程”,也就是共享窗口。
线程就是将资源通过页表映射到进程的虚拟内存地址空间;然后创建一批轻量级进程;把资源主要的去划分给他们(虚拟内存空间);然后就模拟出了线程。
下面看张图:
这里页表虽然不像我们画的这么简单;暂时先这么理解;这样我们就给这些新形成的轻量级进程分配了一些资源就看到了线程。
可以理解为:执行流数量大于进程(一般;但绝不小于)﹔但是规模小于等于进程。
类比下我们之前学习进程的那张图;就是只有一个进程指向这个mmstruct;现在呢是有多个了;
1·2线程感性认识总结:
下面来总结下我们上面所讲的知识点:
1· Linux"线程"可以采用进程来模拟。
2·对资源的划分,本质是对地址空间虚拟地址范围的划分。
3·函数就是虚拟地址(逻辑地址)空间的集合!就是让线程未来执行ELF程序的不同的函数即可。
4·设计的好处(这里我们暂时简单说下,后面会具体来讲):
进程被cpu重新调度后t1b以及cache都要被刷新;还有页表等等;但是线程这些资源都不变化;它属于的线程共亨。暂时可以理解成线程操控起来比搞成多个进程或者一个进程要快要方便即可。
5·对于linux为什么用进程来模拟实现线程:用进程模拟实现线程会更加健壮(大部分都是套用进程那一套)。
6·线程与进程特点简述:
进程强调独占,部分共享(比如通信的时候)。
线程强调共享:部分独占,大部分共享如全局变量等等。
【线程天然就能互相看到;就比如它们所属的那个进程的资源是都可以看到的。】
二·理性的去认识线程:
我们下面就要从系统,内核角度去认识线程:
首先先明白资源是如何与物理内存交互的:
2.1如何进行内存加载:
页框是一个存储区域;而页是一个数据块,可以存放在任何页框或磁盘中。
大多数 32位 体系结构支持4EB的页,而64位体系结构一般会支持8KB 的页。区分一页和一个页框是很重要的。
下面我们就以32位体系结构为例来讲述。
我们只需要记住;不仅是磁盘是4kb分块;对于加载到物理内存也是如此。
那么加载到物理内存中的这些块要不要管理起来呢?
当然;还是先描述后组织的方式。
物理内存有4GB:4GB/4KB =1048576;因此就可以分成这麽多块;物理内存一共被分这些块;即会构成对应下标。
下面我们先来看看这些块是如何被描述的:
内核源码:
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 */
...
}
那么;这些块整体也要被描述起来:
当然了这些页也有其他几个重要参数;了解下即可:
1·flags :⽤来存放⻚的状态。这些状态包括⻚是不是脏的,是不是被锁定在内存中等。flag的每⼀位单独表⽰⼀种状态,所以它⾄少可以同时表⽰出32种不同的状态。这些标志定义在
中。其中⼀些⽐特位⾮常重要,如PG_locked⽤于指定⻚是否锁定,PG_uptodate⽤于表⽰⻚的数据已经从块设备读取并且没有出现错误。 2. _mapcount :表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。当计数值变为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它。
3. virtual :是⻚的虚拟地址。通常情况下,它就是⻚在虚拟内存中的地址。有些内存(即所谓的⾼端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的时候,必须动态地映射这些⻚。
页太大,页页必然会剩余较大不能利用的空间(页内碎片)。页太小,虽然可以减小页内碎片的大小,但是页太多,会使得页表太长而占用内存,同时系统频繁地进行页转化,加重系统开销。因此,页的大小应该适中,通常为512B-8KB ,mindows系统的页框大小为4EB
因此页大小要适度!!!
这样不就实现了我们上面所讲的对它们的管理还是先描述后组织。
因此,我们的物理内存寻址不就转化成了:
具体物理地址=起始物理地址(数组下标4*kb)+页内(4KB)偏移
所以我们申请物理内存是怎么做的:
1.查数组,改page。
2.建立内核数据结构的对应关系,页表等。
因此我们可以大致看懂它的结构了:
但是对于页表为什么这个结构;和我们之前学的不一样(之前不是只保存虚拟地址和物理地址吗):真实是只保存物理地址然后根据虚拟地址去寻址;以及保存了对应物理内存某些位置的权限等;下面我们细谈一下页表!
有个细节问题来解答下:
通常都是先有虚拟在物理:比如游戏运行时8gb但物理内存只有4gb;故先加载一部分之后用到再覆盖加载;因此此时就走缺页中断
2.2引入页表:
2.2.1页表样式:
假设没有页表的话:
这样数据和内存就是这样的对应关系!
因为每一个程序的代码、数据长度都是不一样的,按照这样的映射方式,物理内存将会被分割成各种离散的、大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。
因此,防止这种情况发生;我们就引入了页表的概念:
这就是我们之前学习的简易版的页表;但真实情况并不是这样的。
那么下面我们来看一下它真实结构:
总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片间题。
可以把这个单一页表拆分成1024 个体积更小的映射表。如下图所示。这样一来,1024(每个表中的表项个数)*1024(表的个数)仍然可以覆盖4GB的物理内存空间。
下面我们上图来理解下:
这就是我们真正页表的结构。
2.2.2虚拟地址如何通过页表完成到物理地址的映射:
这里以32位机器 四字节地址也就是32位来理解如何从虚拟地址到物理地址:
下面我们先看张博主总结的图:
细谈一下:
我们以32位为例;那么也就是地址是4字节;因此把它分32比特位:
上面也是提到了mmu:
MMU(Memory Manage Unit)是⼀种硬件电路,其速度很快,主要⼯作是进⾏内存管理,地址转换只是它承接的业务之⼀。
我们可以这么来理解:前十位就是帮助我们找到对应的页表目录的页项->也就是在哪张页表;而再往后走十位就帮助我们找到对应的1024张页表中的一个位置;里面放着对应的物理4kb块的起始物理地址;然后 根据后12位当做偏移量就可以根据虚拟地址精确到物理地址了。
因此,结论就是虚拟地址只是作为对应物理地址的寻址方法而已;虚拟地址前十位相同就在同一个页表内;中间也相同的话就在同一个物理内存块里面;根据这种对应关系就简化了我们之前存在的问题了。
最后我们根据这张图也得到了一些结论:
1·申请内存->查找数组->找到没有被使用的page-> 数组index ->物理页框地址->完成对应映射。
2·对进程寻址而言:一张页目录+ n张页表构建的映射体系,虚拟地址是索引,物理地址页框是目标,虚拟地址(低12)+页框地址=物理地址。
那么问题来了;为什么32位比特位要这样来表示寻址关系呢?
对于前十位*中间十位那么对应的标识也就是1024*1024正好是1048576;那么就完成了可以填写每个物理内存块的起始地址了;对于后12位 有2^12也就是4096也就是4kb;这下就明白了这样表示的用处了。
总结下:
单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
2.2.3现代寻址优化:
当然了这种方式每次都交给mmu去寻址也是比较麻烦的;现代版本对于cpu内部有优化了;下面我们就谈谈。
为了解决这种困难;cpu引入了江湖人称快表的TLB(其实,就是缓存)
当CPU给MMU传新虚拟地址之后,MMU先去问TLB那边有没有,如果有就直接拿到物理地址发到总线给内存,齐活。但TLB容量比较小,难免发生(Cache Miss,这时候MMu还有保底的老武器页表,在页表中找到之后MMU除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存。
总的来说就是;tlb里面会存有一定量虚拟地址直接到对应物理地址关系;mmu先去快表;;如果没有再去走查询老路;然后把查到结果刷新到tlb中。
下面看图清晰了解:
当然了,计算机cpu还有cache也可以用来缓存数据及指令;方便查找地址等 ;这些都是帮助mmu快速拿着虚拟地址来找打物理地址的方案。
2.2.4写实拷贝,缺页异常,申请内存等方面结合页表的进一步理解:
下面总结下类似缺页异常,申请内存(ma11oc等),写实拷贝等等是如何进行的以及如何区分越界访间还是缺页中断:
写实拷贝:
首先是有两个进程;其中他们虚拟地址不同但是可以找到同一块物理地址(此时页表就会识别到有两个进程指向物理地址﹔因此把权限改成只读;当发现有进程想要改变它,就会找个空的4kb块然后把原先数据拷贝过来;把新的物理内存块的首地址放到原先对应的二级页表的对应位置)。
申请内存(如malloc):
同理;首先先加载虚拟地址:然后os能知道对应的内容然后从磁盘对应4kb4kb加载到物理内存对应的位置。 如果是malloc也就是只会先给它虚拟地址(改变对应堆区vm开始结束指向)﹔然后当用到这个内存的时候才申请新的物理块放入对应的二级页表对应位置;否则就是null 。
关于是缺页异常还是越界访问问题:
可以认为你的越界,操作系统都不知道。
对于缺页中断:
跑到对应位置发现无对应的起始地址;因此就会联系上下文内容;然后申请个空的物理块;加载进来;把地址给它放到对应位置。
一张图理解下缺页中断:
缺页中断会交给PageFaultHandler 处理,其根据缺页中断的不同类型会进行不同的处理。
下面我们来谈一下PageFaultHandler:
1`Hard Page Fault 也被称为 Major Page Fault ,翻译为硬缺⻚错误/主要缺⻚错误,这时物理内存中没有对应的物理⻚,需要CPU打开磁盘设备读取到物理内存中,再让MMU建⽴虚拟地址和物理地址的映射。(上面所讲正常加载)
2`Soft Page Fault 也被称为 Minor Page Fault ,翻译为软缺⻚错误/次要缺⻚错误,这时物理内存中是存在对应物理⻚的,只不过可能是其他进程调⼊的,发出缺⻚异常的进程不知道⽽已,此时MMU只需要建⽴映射即可,⽆需从磁盘读取写⼊内存,⼀般出现在多进程共享内存区域。(如动态库被进程共享映射到各自地址空间:比如一个进程用着动态库;然后另一个也要和它连接就会发现页表无地址;因此她不会直接再从磁盘到物理内存加载;而是告诉另一个进程一下;把它物理内存的库映射过来)
3`Invalid Page Fault 翻译为⽆效缺⻚错误,⽐如进程访问的内存地址越界访问,⼜⽐如对
空指针解引⽤内核就会报 segment fault 错误中断进程直接挂掉。(虚拟地址映射到的物理内存无效即空)
这些也是了解即可。
2.3对线程深刻理解:
线程进行资源划分:本质是划分地址空间,获得一定范围的合法虚拟地址。再本质,就是在划分页表,线程进行资源共享,本质是对地址空间的共享,再本质,就是对页表条目的共享。
2.3.1线程优点:
1·创建一个新线程的代价要比创建一个新进程小得多。
2·与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多(比如:·线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能员耗是将寄存器中的内容切换出;也就是进程切换远远麻烦于线程)
3·上下文的切换会扰乱处理器的缓存机制(一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲TLB(快表)会被全部刷新,这将导致内存的访问在段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache)即进程切换tlb和cache都要变;但是线程被cpu切换就不会变;而且线程占用的资源要比进程少很。
4·能充分利用多处理器的可并行数量:
①在等待慢速I/0操作结束的同时,程序可执行其他的计算任务 即多线程任务。
②计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现 cpu核数越多;线程就能越多。
cat /pro/cpuinfo-->查看cpu核数
③I/0密集型应用,为了提高性能,将I/0操作重叠。线程可以同时等待不同的I/0操作。多线程操作不会完全闲下来。
2.3.2线程缺点:
1·性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性线程多难控制能损失指的是增加了额外的同步和调度开销,而可用的资源不变。(线程多难控制)
2·健壮性降低: 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
3·缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些0S函数会对整个进程造成比如可重入函数;原子性;进行的快慢不同影响。(比如可重入函数;原子性;进行的快慢不同)
4·编程难度提高:编写与调试一个多线程程序比单线程程序困难得多。
2.4线程异常:
1·单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃等;如信号处理都交给了它所属的进程了。(故线程不能像进程那样查看退出信息等;因为都交给它所属于的进程了;因此使用线程要更加严谨)
2·线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
2.5线程用途:
1·合理的使用多线程,能提高CPU密集型程序的执行效率。
2·合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。
2.6Linux的进程VS线程:
还是上面我们一开始所说:
进程是资源分酡的基本单位;线程是调度的基本单位。
线程共享进程数据,但也拥有自己的一部分数据:比如:①线程ID ②⼀组寄存器 ③栈 ④errno ⑤信号屏蔽字 ⑥调度优先级。
其中比较重要的就是这一组寄存器(说明线程是独立被调度的)和栈(它有独立的栈即线程是个动态的)
而线程对进程的绝大部分资源都是共享的;因为每个线程能看到同一个进程的虚拟地址空间(因为它是轻量级进程也是进程)
同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
如:文件描述符表;每种信号的处理方式(SIG IGN、SIG DFL或者自定义的信号处理函数);当前工作目录;用户id和组id。
下面以一张抽象的图结束本篇对线程概念的学习:
三·本篇小结:
本篇通过从之前学过的进程的概念;不断引入到了线程;通过此篇;可以了解到线程的相关理论概念;有了一定认识后;下面为后面线程控制;使用一些函数接口等打下了一定基础;也欢迎关注博主下篇畅谈线程控制。