【Linux】系统部分——线程概念与地址空间
26.线程概念与地址空间
学习笔记,非技术文档,不能作为准确的技术参考
文章目录
- 26.线程概念与地址空间
- 线程的基础概念
- 分页式存储管理
- 空间划分
- 物理内存空间管理
- 页表与页目录结构
- 缺页中断机制
- 写时拷贝与缺页中断
- 内存管理与进程解耦
- 线程优点
- 线程异常
线程的基础概念
讲义内容:
- 在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是“⼀个进程内部的控制序列”
- ⼀切进程⾄少都有⼀个执⾏线程• 线程在进程内部运⾏,本质是在进程地址空间内运⾏
- 在Linux系统中,在CPU眼中,看到的PCB都要⽐传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的⼤部分资源,将进程资源合理分配给每个执⾏流,就形成了线程执⾏流
在之前的学习阶段,我们对进程的概念是:进程是一个执行起来的程序,进程 = 内核数据结构 + 代码和数据。而现在我们学习线程,当前学习阶段给出的线程定义是:线程是一个执行流,执行粒度比进程更细,是进程内部的一个分支。更新认识:进程是承担分配系统资源的基本实体,而线程是OS调度的基本单位。
理解:
-
进程与线程
线程是进程内部的一个执行分支,其执行粒度比进程更细。进程被重新定义为承担系统任务的基本单位,而线程则是操作系统调度的基本单位。在Linux系统实现中,线程是进程内部的执行分支,共享进程的地址空间等资源,但拥有独立的执行流。系统通过task_struct等数据结构管理线程,包括调度、状态变化、优先级设置等。
Linux系统通过管理task structure等内核数据结构来实现对线程的管理,包括调度、状态变化、优先级设置等各个方面。线程的存在使得系统需要管理更多的执行实体,一个进程可能包含多个线程,系统需要对这些线程进行有效管理。
所以,需要区分一下进程和线程的结构:进程是由
task_struct
、地址空间、页表、代码数据等组成的整体,而线程是其中的一个task structure执行流。一个进程可以包含多个线程,这些线程共享进程的地址空间和资源。 -
操作系统中的线程管理与Linux设计思路
线程在操作系统中需要进行管理,操作系统必须对线程进行描述和组织(先描述,再组织)。操作系统内需要设计线程控制块(tcb)来管理线程,tcb结构体需要包含id值、优先级、状态、调度记账信息、代码数据等属性。线程作为进程内部的执行分支,进程内创建的多个线程需要维护子链表来连接各自的tcb。
Linux设计者考虑线程本质并非调度,而是要在进程内运行且执行力度更细,因此将进程控制块(pcb)与线程控制块(tcb)等价处理。在Linux中创建线程时,只需在进程地址空间内创建task structure结构体,共享地址空间和代码区,将代码区拆分为多个部分由不同执行流执行。这种方式避免了为线程单独创建数据结构、管理队列和设计调度算法,复用了历史代码,提高了可维护性。Windows等操作系统在内核中确实实现了线程和tcb结构,相比之下Linux的设计更为简洁高效。
在Linux中,线程就是在进程地址空间内运行的执行流,共享进程的资源。进程是由task structure、地址空间、页表、代码数据等组成的整体,而线程是其中的一个执行流。历史上学习的单线程进程是现在多线程进程的特殊情况。进程是承担分配系统资源的基本实体,而线程只是划分进程已有资源。创建进程时需要创建页表、地址空间、加载代码和数据,而创建线程时只需划分已有资源即可。
所以总结一下:Linux将线程实现为共享地址空间的task_struct,不再为线程单独创建数据结构和管理机制
-
线程使用举例
#include <pthread.h> int pthread_create(pthread_t *restrict thread,const pthread_attr_t *restrict attr,void *(*start_routine)(void *),void *restrict arg);
在Linux中创建线程需要使用pthread_create接口,这个接口来自原生线程库而不是系统调用。创建线程时需要指定线程ID、线程属性(通常设为NULL)、线程要执行的函数(回调函数)以及传递给该函数的参数。线程函数是一个返回void指针、参数为void指针的函数。创建线程后,主线程和新线程会并发执行,各自有自己的执行流。
#include <iostream> #include <unistd.h> #include <pthread.h>// 新线程 void *run(void *args) {while(true){std::cout << "new thread, pid: " << getpid() << std::endl;sleep(1);}return nullptr; }int main() {std::cout << "我是一个进程: " << getpid() << std::endl;pthread_t tid;pthread_create(&tid, nullptr, run, (void*)"thread-1");// 主线程while(true){std::cout << "main thread, pid: "<< getpid() << std::endl;sleep(1);}return 0; }
user@iZ7xvdsb1wn2io90klvtwlZ:~/lesson29/thread$ make g++ -o mythread mythread.cc -std=c++11 -lpthread #相较于之前的编译选项,多了一个-lptherd user@iZ7xvdsb1wn2io90klvtwlZ:~/lesson29/thread$ ./mythread 我是一个进程: 32984 main thread, pid: 32984 new thread, pid: 32984 new thread, pid: 32984 main thread, pid: 32984 new thread, pid: main thread, pid: 3298432984new thread, pid: 32984 main thread, pid: 32984 new thread, pid: 32984 main thread, pid: 32984 new thread, pid: 32984 main thread, pid: 32984 new thread, pid: 32984main thread, pid: 32984 ^C
-
为了编译线程程序,需要在编译时链接pthread库(使用-lpthread选项)。pthread库是Linux系统默认提供的动态库(如libpthread.so),它封装了线程操作的各种功能。
-
运行线程程序时,可以看到主线程和新线程的两个死循环同时执行,这证明了多执行流的存在。
-
通过ps命令查看进程时,虽然程序中有多个线程,但系统只显示一个进程条目。通过输出线程的PID可以验证它们确实属于同一个进程。
-
结论:Linux线程是通过轻量级进程实现的,它们共享进程上下文但有自己的执行流
-
-
新概念:轻量级进程
Linux系统中,执行流不再区分进程和线程,而是统一称为轻量级进程(Lightweight Process,简称LWP)。
- 传统的进程概念实际上是由一个轻量级进程加上页表、地址空间、代码和数据等共同构成的。一个进程内部可以同时存在多个轻量级进程。Linux系统中并没有真正意义上的线程实体,线程的概念是通过轻量级进程模拟实现的。当在Linux中创建一个线程时,系统实际上创建的是一个轻量级进程。这种实现方式符合操作系统理论中关于线程的定义和特性,即执行粒度比进程更细,是进程内部的一个执行分支。轻量级进程就是进程内部的一个执行流。在Linux系统中创建线程实际上就是在进程内创建一个轻量级进程
- 在Linux系统中,查看进程时使用ps命令,通过不同的选项可以查看不同类型的进程。使用ps -a可以查看所有进程,而使用ps -a -l可以查看轻量级进程(LWP)。轻量级进程的PID(进程ID)和LWP(轻量级进程ID)可能相同也可能不同,PID和LWP相同的通常是主线程,不同的则是新线程。操作系统在调度执行流时,实际上是通过LWP来区分的,而不是PID。这是因为在Linux系统中,多个执行流可能共享同一个PID,但每个执行流都有唯一的LWP。因此,LWP是区分执行流唯一性的关键。
分页式存储管理
想要了解线程,就需要了解线程是如何共享进程地址空间的。
空间划分
虚拟地址空间的引入解决了物理内存碎片化的问题。如果没有虚拟地址空间,进程的代码、数据和栈直接在物理内存中分配,会导致物理内存被切割成许多不连续的小块,产生内存碎片。内存碎片分为外部碎片和内部碎片,严重影响内存管理的效率。虚拟地址空间通过页表将物理内存中不连续的块映射为连续的虚拟地址空间,使得进程和用户看到的地址空间是连续的。这种方式提高了内存管理的效率,避免了碎片化问题。虚拟地址空间的代码段、数据段、堆区和栈区按照特定区域划分,进程只需关心虚拟内存,而不必直接管理物理内存。
在之前文件系统部分,我们了解到磁盘的每个分区是被划分为⼀个个的”块”。⼀个”块”的⼤⼩是由格式化的时候确定的,并且不可以更改,最常⻅的是4KB。类似的,对于物理内存的管理,操作系统把物理内存按照⼀个固定的⻓度的⻚框进⾏分割,有时叫做物理⻚。每个⻚框包含⼀个物理⻚(page)。⼀个⻚的⼤⼩等于⻚框的⼤⼩。⼤多数 32位 体系结构⽀持 4KB 的⻚,⽽ 64位 体系结构⼀般会⽀持 8KB 的⻚。有了这种机制,CPU 便并⾮是直接访问物理内存地址,⽽是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执⾏的进程分配的⼀个逻辑地址,在32位机上,其范围从0 ~ 4G-1。
总结一下:一般情况下,在磁盘中的空间被划分成一个一个的块,物理内存被划分为一个一个的页框,虚拟地址空间也被划分为一个一个的页,通常大小是4KB。将虚拟内存下的逻辑地址空间分为若⼲⻚,将物理内存空间分为若⼲⻚框,通过⻚表便能把连续的虚拟内存,映射到若⼲个不连续的物理内存⻚。
物理内存空间管理
既然物理内存要被划分为多个页框,操作系统就需要对这些页框或物理内存进行管理,以此知道哪些页正在被使用,哪些页空闲,与进程和文件系统一样:先描述,再组织。操作系统通过定义一个结构体struct page
来描述每个4KB的内存块,这个结构体包含多个字段,如标志位、链表和引用计数等。这个结构体并不是重点。
/* include/linux/mm_types.h */
struct page
{/* 原⼦标志,有些情况下会异步更新 */unsigned long flags; /* ⽤来存放⻚的状态flag的每⼀位单独表⽰⼀种状态,所以它⾄少可以同时表⽰出32种不同的状态。这些标志定义在<linux/page-flags.h>中。其中⼀些⽐特位⾮常重要,如PG_locked⽤于指定⻚是否锁定,PG_uptodate⽤于表⽰⻚的数据已经从块设备读取并且没有出现错误 */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; //表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。当计数值变为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它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 与物理⻚相关,⽽并⾮与虚拟⻚相关。⽽系统中的每个物理⻚都要分配⼀个这样的结构体
- 操作系统通过数组来管理内存块的结构体,每个内存块的下标与其物理地址有直接对应关系。物理地址可以通过下标乘以4KB的起始地址计算得出。这种映射关系使得操作系统可以高效地管理内存块的分配和释放。文件缓冲区的本质也是由page结构体组成的列表,通过struct file可以找到对应的page结构体从而访问文件缓冲区
页表与页目录结构
⻚表中的每⼀个表项,指向⼀个物理⻚的开始地址。在 32 位系统中,虚拟内存的最⼤空间是 4GB ,这是每⼀个⽤⼾程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可⽤,那么⻚表中就需要能够表⽰这所有的 4GB 空间,那么就⼀共需要 4GB/4KB = 1048576 个表项。
在 32 位系统中,地址的⻓度是 4 个字节,那么⻚表中的每⼀个表项就是占⽤ 4 个字节。所以⻚表占据的总空间⼤⼩就是: 1048576*4 = 4MB 的⼤⼩。这4MB是每个进程都需要单独拥有的!如果你同时运行100个进程,仅页表就要占用 100 × 4MB = 400MB 的物理内存。这还只是32位系统,如果是64位系统,这个数字会膨胀到完全无法想象的程度(2^52 个页表项),单级页表根本不可行。
解决需要⼤容量⻚表的最好⽅法是:把⻚表看成普通的⽂件,对它进⾏离散分配,即对⻚表再分⻚,由此形成多级⻚表的思想。到⽬前为⽌,每⼀个⻚框都被⼀个⻚表中的⼀个表项来指向了,那么这 1024 个⻚表也需要被管理起来。管理⻚表的表称之为⻚⽬录表
虚拟地址到物理地址的转换需要经过多步操作。首先,32位虚拟地址被拆分为前10位、中间10位和后12位。前10位用于查找页目录,页目录包含1024个项,每个项指向一个页表。页表同样包含1024个项,每个项指向物理内存中的4KB页框。虚拟地址的中间10位用于在页表中查找对应的页表项,从而找到页框的起始地址。后12位则作为页框内的偏移量,用于定位具体的字节地址。整个转换过程通过页目录和页表的层级结构实现,使得32位虚拟地址能够映射到物理内存中的任意位置。操作系统可以根据需要动态分配和释放页表,进程通常不会使用全部的页表空间。
地址转换过程需要逐级查询页表,现代CPU通过TLB(转换后备缓冲器)缓存最近使用的地址映射,加速转换过程。操作系统的内存管理需要同时维护页框分配状态和多级页表结构,确保虚拟地址到物理地址的高效转换。
缺页中断机制
程序运行时不需要将所有代码和数据一次性加载到内存,而是按需加载。例如10万行代码可能只加载前1万行并构建页表映射。当访问未加载的代码(如第10001行)时,页表查询会显示不命中状态。此时MMU虚拟到物理地址转换失败,CPU内部产生软中断(中断号101表示缺页中断)。操作系统通过中断向量表执行加载逻辑:从外设读取剩余代码、分配物理内存、建立新的映射关系、更新命中标志位,最后重新执行原指令。整个过程对CPU透明,称为缺页中断机制。new和malloc操作只需申请虚拟地址空间,实际物理内存分配通过缺页中断机制延迟到首次访问时进行。
写时拷贝与缺页中断
写时拷贝技术中,父子进程初始共享页表和内存数据。数据区被标记为只读权限,当进程尝试写入时会触发缺页中断。操作系统此时会分配新内存并拷贝原始数据,同时更新页表权限为可写。内存管理以4KB为单位进行写时拷贝,虽然可能造成空间浪费,但能避免频繁拷贝操作,实现空间换时间的优化。物理内存页框通过引用计数管理,当没有页表项指向某个页框时(引用计数为0),该内存空间可被释放。
内存管理与进程解耦
申请内存本质是至于位物理内存,用户进程不需要关心物理内存的申请和管理,由操作系统自主决定。用户进程只需要管好自己,实际物理内存的申请释放和管理过程完全由操作系统负责。进程申请内存和内存管理解耦,用户进程不需要关心物理空间申请。操作系统自行管理进程和内存管理,两者互相解耦。权限问题和缺页中断在操作系统层面如何区分,指针越界不一定导致程序崩溃,但缺页一定会触发中断。MU会触发软中断检查要访问的页号是否合法,如果页号合法但页面不在内存里是缺页中断,如果页号非法就是越界访问。操作系统会检查触发异常的虚拟地址的页号是否合法,以及虚拟地址是否在当前进程的内存映射范围内。每个进程的虚拟地址范围不同,代码区和数据区的范围也不同,如果指针指向未分配的地址就是异常,如果要访问的地址在分配范围内但页面不在就是缺页中断。
线程优点
-
创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多
-
线程占⽤的资源要⽐进程少很
-
能充分利⽤多处理器的可并⾏数量,在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务。计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现。I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
在计算密集型任务中,线程数量并非越多越好,过多的线程会导致操作系统调度问题,将并行计算转化为串行执行,降低效率。
在IO密集型任务中,可以采用多线程并发方式进行下载,例如下载10GB文件时可以创建10个线程,每个线程负责下载1GB数据的不同部分。这种并发下载方式可以充分利用等待时间,让部分线程在等待IO时其他线程可以继续工作。但实际下载速度不仅取决于线程数量,还受网络带宽限制。IO密集型任务的线程数量可以超过CPU核数,因为大部分时间线程都处于等待状态。多线程下载能够提高整体下载效率,但需要考虑网络带宽的实际限制,过高的线程数量在有限带宽下并不能带来明显提升。IO密集型任务的特点是存在大量等待时间,因此可以创建比CPU核数更多的线程来充分利用这些等待时间。
-
与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多
最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下⽂切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
另外⼀个隐藏的损耗是上下⽂的切换会扰乱处理器的缓存机制。简单的说,⼀旦去切换上下⽂,处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚拟内存空间的时候,处理的⻚表缓冲 (快表)会被全部刷新,这将导致内存的访问在⼀段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache
线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执⾏分⽀,线程出异常,就类似进程出异常,进⽽触发信号机制,终⽌进程,进程终⽌,该进程内的所有线程也就随即退出