一、补充内容
1.1 Linux内核中的堆区管理
vm_area_struct
(VMA)是Linux内核中的一个数据结构,表示进程虚拟内存空间中的一个堆区内存区域。它用于跟踪关于堆区内存区域的各种属性和信息,例如起始和结束地址、权限、标志以及关联的文件或设备。
以下是Linux内核源代码中vm_area_struct
结构的定义:
struct vm_area_struct {
struct mm_struct *vm_mm; /* 关联的mm_struct */
unsigned long vm_start; /* 起始地址 */
unsigned long vm_end; /* 结束地址 */
unsigned long vm_flags; /* 该VMA的标志,例如可读写、可执行等*/
struct rb_node vm_rb; /* 红黑树节点 */
struct list_head vm_list; /* mm中的VMA双向链表 */
struct vm_area_struct* vm_next, * vm_prev; /*双向链表中的前驱、后继节点*/
pgprot_t vm_page_prot; /* 页面保护标志 */
struct vm_operations_struct *vm_ops; /* VMA操作,用于处理堆区的操作*/
unsigned long vm_pgoff; /* 文件/设备内的偏移量 */
struct file *vm_file; /* 关联的文件 */
void *vm_private_data; /* VMA的私有数据 */
};
在进程的虚拟内存空间中,堆区通常是一个连续的内存区域,但可能会被分成多个vm_area_struct
(VMA)结构来管理。
当进程使用malloc()
等函数分配内存时,内核会根据需要创建一个或多个vm_area_struct
结构来管理这些分配的内存区域。每个vm_area_struct
结构对应于堆区中的一个内存段,它包含了该内存段的起始地址、结束地址、标志等信息。
这种分割堆区的方式可以提高内存管理的灵活性和效率。例如,当进程释放部分内存时,可以通过修改VMA结构中的vm_start
和vm_end
字段来缩小内存段的范围,以反映释放后的内存空间,而不需要重新映射整个堆区。如果释放的内存块与内存段的边界对齐,可能会合并相邻的VMA结构,以减少内存碎片。
通过vm_area_struct
结构,内核可以有效地管理进程的堆区,包括分配和释放内存、保护内存区域、处理内存映射等操作。
补充:当创建一个新的
vm_area_struct
结构时,内核会将其插入到mm_rb
红黑树中,以便可以通过起始地址快速查找和访问对应的VMA。同时,内核还会将其插入到mmap
双向链表的末尾,以保持VMA的创建顺序。
1.2 虚拟地址到物理地址的转换
1.2.1 ELF文件格式
- ELF是一种通用的可执行程序格式,广泛用于Linux和许多其他UNIX-like操作系统。
- 可执行程序本身就是按照虚拟地址(逻辑地址)的方式进行编译的。最终形成的ELF文件将可执行文件分为多个段(segment),包括代码段、数据段、符号表等。每个段都有自己的属性和对应的内存区域。
- 运行程序时将代码和数据加载到内存,实际加载的就是ELF文件。
1.2.2 页、页框和页表
页(Page)、页框(Page Frame)和页表(Page Table)是与内存管理相关的基本概念。
- 页(Page):页是内存管理中的一个基本单位,通常是一个固定大小的连续内存块。常见的页大小是4KB、8KB或者更大。物理内存被划分为一系列的页,每个页都有一个唯一的物理地址。
- 页框(Page Frame):页框是物理内存中的一个基本单位,与页的大小相同。页框是物理内存的划分单位,用于存储页的内容。操作系统将物理内存划分为一系列的页框,每个页框都有一个唯一的物理地址(页框的起始地址)。
- 页帧:可执行程序存储空间(磁盘空间)的划分单位,与页的大小相同。其实就是文件系统中一个数据块的大小(block),操作系统和磁盘进行IO操作的基本单位是4KB。 源代码在编译的时候就会以特定的格式(elf)被编译成以4KB为单位的二进程可执行程序。
- 页表(Page Table):页表是一种数据结构,每个进程都有自己的页表,用于管理虚拟地址和物理内存之间的映射关系。页表并不是直接记录虚拟地址和物理地址的一一映射关系,而是通过多级页表记录虚拟地址和物理页的映射关系(最终需要通过虚拟地址中的页内偏移定位到物理地址)。
提示:
- 在Linux内核中,
struct page
是用来描述物理页框的数据结构。每个物理页框都对应一个struct page
对象,用于管理和跟踪该页框的状态和属性。包括页框是否被占用、页框的引用计数、页框所属的地址空间等。struct page是物理内存的元数据结构!struct page
中的struct list_head lru
字段用于将页框链接到LRU(Least Recently Used)链表中,以实现页面置换算法。通过lru
字段,可以将页框链接到活跃链表或非活跃链表中。
1.2.3 MMU内存管理单元
MMU是内存管理单元(Memory Management Unit)的缩写,是计算机系统中的一个硬件组件。MMU负责虚拟地址到物理地址的转换,以及内存访问权限的控制。
MMU通常集成在CPU或者独立的芯片中,它是操作系统进行内存管理的重要组成部分。MMU的主要功能包括:
-
地址转换:MMU根据虚拟地址的高位部分(页表索引)查找页表,将虚拟地址转换为物理地址。这个过程通常包括页表的查找和页内偏移的计算。
-
内存保护:MMU根据页表中的权限位,对访问的虚拟地址进行权限检查。如果访问权限不符合要求,MMU会产生一个异常,中断程序的执行。
-
缺页处理:当程序访问的虚拟页不在物理内存中时,MMU会产生一个缺页异常。操作系统会根据异常处理程序将缺失的页从磁盘读取到物理内存中,并更新页表的映射关系。
-
页面置换:当物理内存不足时,MMU会根据页面置换算法(如LRU)将部分页从物理内存置换到磁盘上,以释放内存空间。
MMU的存在使得操作系统可以将虚拟地址空间映射到物理内存,提供了更大的地址空间和更高的灵活性。同时,MMU也起到了保护内存的作用,防止程序越界访问或者非法访问内存。
1.2.4 虚拟地址到物理地址的转换
虚拟地址到物理内存的映射是由操作系统的内存管理单元(MMU)完成的。MMU负责将虚拟地址转换为物理地址。
虚拟地址到物理地址的映射过程如下:
- 当程序访问虚拟地址时,CPU将虚拟地址发送给MMU。
- MMU根据虚拟地址的高位部分(页表索引)查找页表(page table)。
- 页表中存储了虚拟页号到物理页框号的映射关系。MMU根据虚拟页号找到对应的物理页框号。
- MMU将物理页框号与虚拟地址的低位部分(页内偏移)组合,得到物理地址。
- MMU将物理地址发送给内存控制器,从物理内存中读取或写入数据。
需要注意的是,虚拟地址空间可以大于物理内存空间,这种情况下会使用页面置换算法(如LRU)将部分虚拟页置换到磁盘上,以释放物理内存空间。当程序访问被置换到磁盘上的虚拟页时,会触发缺页异常,操作系统会将该虚拟页从磁盘读取到物理内存中,并更新页表的映射关系。
提示:虚拟地址的低位部分(页内偏移)刚好是12位,2^12 = 4KB,页内偏移码刚好能够覆盖整个页框。
通过以上概念的补充介绍,我们能感受到从上层开发到编译原理再到操作系统都是强相关的,都是经过精心设计,相互配合的。
二、Linux线程概念
2.1 从用户的角度看
-
进程是指计算机中正在运行的程序的实例。每个进程都有自己的地址空间、内存块、文件描述符等资源(内核数据结构+内存块),同时还包括所有的线程。进程是操作系统进行资源分配的基本单位。进程之间相互独立,通过进程间通信(IPC)机制来进行数据交换和协作。
-
线程是进程中的一个执行流(或执行单元),是进程中的实际工作单位。一个进程至少要有一个线程,也可以包含多个线程,它们共享进程的资源,如内存、文件等。线程是操作系统调度(CPU执行)的基本单位。线程之间可以并发执行,提高了程序的并发性和响应性。线程之间通过共享内存来进行通信和同步。
进程和线程的区别主要有以下几点:
- 资源开销:进程之间的切换开销较大,需要保存和恢复整个进程的上下文信息;而线程之间的切换开销较小,只需要保存和恢复线程的上下文信息。
- 独立性:进程是独立的执行实体,拥有独立的地址空间和资源;而线程是进程的子集,共享进程的资源。
- 通信和同步:进程之间通信和同步需要使用进程间通信(IPC)机制,如管道、消息队列、共享内存等;而线程之间通信和同步可以直接通过共享内存来实现,更加方便和高效。
- 创建和管理:在用户空间,进程和线程可以通过不同的API(如fork()和pthread_create())来创建和管理。
提示:我们之前一直编写的是单线程进程,现在我们要研究的是多线程进程!
2.2 从Linux内核的角度看
在Linux中,线程是进程的一部分,也被称为轻量级进程(LWP,Lightweight Process)。Linux使用了一种称为"多线程共享同一进程地址空间"的模型,即多个线程共享同一个进程的资源,如内存、文件描述符等。
在Linux系统中,进程和线程的结构是相同的。在内核中,进程和线程都是通过task_struct
结构体来表示。
task_struct
结构体包含了进程或线程的各种属性和状态信息,如进程ID(PID)、父进程ID(PPID)、进程状态、进程优先级、进程的地址空间、文件描述符表、进程的线程组、进程的信号处理结构等。它还包含了一些指针,用于连接进程或线程的相关数据结构,如进程的子进程链表、进程的线程链表等。
在Linux中,线程是进程的一部分,多个线程共享同一个进程的资源,包括地址空间、文件描述符等。同时,从内核的角度来看,进程和线程的结构是相同的,都是通过task_struct
结构体来表示。所以Linux系统中的线程也被称为轻量级进程(LWP)。
在Linux系统中,线程和进程之间的区别相对较小,所以Linux并不直接给我们提供线程相关的系统调用,而是统一提供轻量级进程的接口。但是为了降低用户的使用学习难度,Linux在用户层封装了一套多线程方案,以库的形式提供给用户进行使用。
在Linux中,线程的创建和管理可以使用pthread库(又叫POSIX线程库、原生线程库)。
提示:在Windows系统中,进程和线程是两个不同的概念,并且在结构上也有一些区别。每个进程都有一个独立的进程控制块(Process Control Block,PCB),每个线程也有一个独立的线程控制块(Thread Control Block,TCB)。
2.3 轻量级进程
Linux线程又被称为轻量级进程,原因是:
-
创建时轻量化
- 线程创建时,只需要创建task_struct结构即可。不需要创建地址空间、页表、文件描述符表等内核数据结构,也不需要加载内存块。进程资源的创建和申请是在进程创建时进行的,多线程共享进程的资源。
-
调度时轻量化
-
线程切换的成本较低是因为线程共享同一个进程的资源,包括地址空间、页表等。相比于进程切换,线程切换不需要切换地址空间和页表等资源的上下文(寄存器),因此开销较小。
-
线程切换的成本较低的另一个重要原因:在程序运行期间,CPU会根据局部性原理,将内存中的代码和数据预读到CPU高速缓存(L1~L3 catch)。如果是进程切换,CPU高速缓存就会立即失效,需要重新缓存热点数据。而如果是线程切换,则高速缓存的命中率更高,不需要重新缓存数据。
-
-
删除时轻量化
- 线程在删除时,也只需要删除其task_struct结构,不需要释放进程的资源。进程资源的释放和回收是在进程退出时进行的。
2.4 测试程序
以下是一个简单的示例代码,演示了如何使用pthread_create()
函数创建线程(线程控制在下一章节具体讲):
void *ThreadRun(void *name)
{
//打印新线程的PID
printf("%s:pid:%d\n", (char *)name, getpid());
while (1)
sleep(1);
}
int main()
{
//打印主线程的PID
printf("%s:pid:%d\n", "main thread", getpid());
pthread_t tid[5];
char name[50];
for (int i = 0; i < 5; ++i)
{
snprintf(name, sizeof(name), "%s-%d", "thread", i);
//循环创建新线程
pthread_create(tid + i, nullptr, ThreadRun, (void *)name);
sleep(1);
}
while (1)
sleep(1);
return 0;
}
- 主线程和新线程的PID相同,证明线程是进程的一部分,是进程的一个执行流(执行单元)。
- 进程监视窗口(
ps axj
)只出现一个mythread进程,证明这6个线程属于同一个进程。 - 轻量级进程监视窗口(
ps -aL
)出现了一共6个线程,1个主线程(PID和LWP相同),5个新线程(PID和LWP不同)。 - 向进程发送9号信号,所有的线程都终止了。因为进程是正在运行的程序的实例,是OS进行资源分配的基本单位。所有线程共享进程的资源。所以进程退出,线程必须退出。
三、线程共享进程的资源
各线程共享进程的地址空间,包括:
- 代码区数据(定义一个函数,在各线程中都可以调用)
- 静态区数据(定义一个全局变量,在各线程中都可以访问)
- 堆区数据(堆空间的指针可以在各线程间传递,也可以选择私有堆空间)
- 共享区数据(动态库和共享内存通信)
- 命令行参数和环境变量
各线程还共享以下进程的资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
同时,线程也拥有独属于自己的一部分数据:
- 线程ID(线程属性结构)
- 独立栈结构(线程属性结构):线程独立执行的重要依据
- errno错误码(线程局部变量,线程属性结构)
- 线程上下文(一组寄存器,PCB数据):线程独立调度的重要依据
- 信号屏蔽字(PCB数据)
- 调度优先级(PCB数据)
提示:
- 两项重要的私有数据:线程上下文数据(线程调度)和栈结构数据(调用函数,开辟栈帧空间,存储临时数据),他们体现了线程的动态属性。
- 关于线程属性结构,在下一章“线程控制”的“线程ID”部分进行讲解。
四、线程的优缺点
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多核处理器的可并行数量,进程创建的线程数量一般不要超过CPU的核数。
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
-
性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
-
健壮性降低:编写多线程程序需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
-
缺乏访问控制:在多线程编程中,存在访问控制的挑战。由于多个线程可以同时访问共享的数据和资源,因此需要采取适当的措施来确保线程之间的安全访问。
-
编程难度提高:编写与调试一个多线程程序比单线程程序困难得多
五、线程的用途
- 合理的使用多线程,能提高计算密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(例如边下边播功能,就是多线程运行的一种表现)