Linux 理解线程

目录

一、概念铺垫 

1、描述内存的结构体

2、使用malloc分配内存的过程 

3、内存划分

4、管理内存

5、页帧、页框

6、缺页中断

7、虚拟地址到物理内存

8、重新理解进程

二、线程

1、概念

2、从生活角度理解

3、从进程角度理解

4、线程共享进程资源

5、从CPU的角度理解

6、实现线程->pthread 线程库

7、线程的优点

8、线程的缺点

9、线程异常

10、线程用途

11、线程切换的成本比进程低

12、进程线程对比


一、概念铺垫 

1、描述内存的结构体

vm_area_struct 是 Linux 内核中的核心数据结构之一,它是用来描述进程虚拟地址空间中的一个连续区域的重要组成部分。在 Linux 的内存管理系统中,每个进程都有其独立的虚拟地址空间,这个空间被划分为多个不同的虚拟内存区域,每个区域的信息由一个 vm_area_struct 结构体实例来记录。

vm_area_struct 结构体的主要作用包括:

  1. 地址范围:记录了虚拟内存区域的起始地址和结束地址,定义了该区域在虚拟地址空间中的位置和大小。

  2. 内存映射类型:描述了区域的内容和用途,可以是匿名内存、文件映射、共享内存、堆栈、可执行代码段等不同类型的内存区域。

  3. 权限控制:包含了对该区域的访问权限信息,如读、写、执行权限,这些信息在发生内存访问时用于检查是否违反了内存保护规则。

  4. 映射关联:对于基于文件的内存映射,结构体可能包含与文件相关的数据结构指针,以便当进程访问内存时能够定位到正确的磁盘文件内容或共享内存对象。

  5. 内存管理元数据:存储了关于物理内存如何对应到虚拟内存的相关信息,包括内存映射方式、映射策略(如私有或共享)、以及用于页面替换和内存回收的数据结构链接。

  6. 列表链接vm_area_struct 结构体会通过双向链表的形式链接在一起,形成整个进程的虚拟内存区域链表,方便内核遍历和管理各个区域。

  7. 缺页处理:当进程尝试访问尚未映射到物理内存的虚拟地址时,内核会根据 vm_area_struct 中的信息来进行缺页异常处理,决定是否分配新的物理内存、加载文件内容或者触发其他相应操作。

进程使用vm_area_struct结构体的方式是通过内核的内存管理系统间接实现的。每个运行在Linux系统上的进程都有自己的虚拟内存空间,这个空间被分割成不同的内存区域,每个区域由一个vm_area_struct结构体来描述。这些结构体存储在进程的内存描述符(mm_struct)中,用于管理和维护进程的虚拟内存布局。

2、使用malloc分配内存的过程 

 

以下是进程与vm_area_struct结构体交互的一些典型场景:

  1. 进程启动:当进程被创建时,内核会根据ELF可执行文件格式或其他加载机制建立初始的内存映射,包括代码段、数据段、BSS段等,并为每个段创建相应的vm_area_struct结构体。

  2. 动态内存分配

    在进程中,每当需要动态分配内存时,程序员会通过调用诸如malloc()calloc(), 或 realloc() 等内存管理函数,在进程的堆内存区域中申请特定大小的内存空间。C标准库(如glibc)负责处理这些请求,并根据请求的内存大小采取适宜的分配策略。

    对于较小规模的内存需求(常规情况下是指低于128KB的请求),标准库倾向于采用brk()系统调用来扩充堆空间。具体而言,库会向内核发送brk()调用,指示堆空间的末端应当向上扩展,以容纳新分配的内存块。

    1. 进程的内存结构,在内核中,是用mm_struct来表示的

      struct mm_struct {
       ...
       unsigned long (*get_unmapped_area) (struct file *filp,
       unsigned long addr, unsigned long len,
       unsigned long pgoff, unsigned long flags);
       ...
       unsigned long mmap_base; /* base of mmap area */
       unsigned long task_size; /* size of task vm space */
       ...
       unsigned long start_code, end_code, start_data, end_data;
       unsigned long start_brk, brk, start_stack;
       unsigned long arg_start, arg_end, env_start, env_end;
       ...
      }

      在上述mm_struct结构中:

      1. [start_code,end_code)表示代码段的地址空间范围。
      2. [start_data,end_start)表示数据段的地址空间范围。
      3. [start_brk,brk)分别表示heap段的起始空间和当前的heap指针。
      4. [start_stack,end_stack)表示stack段的地址空间范围。
      5. mmap_base表示memory mapping段的起始地址。
    2. brk()系统调用:malloc()决定使用brk()时,它会调用系统调用接口,传递一个新的堆顶地址,即试图将堆的末端指针(brk)向高地址移动一段距离,以便容纳新分配的内存。

      内核在接收到brk()调用后,会更新进程的内存映射结构。具体而言,内核会找到进程堆区域对应的vm_area_struct结构体,并调整其中的vm_startvm_end字段,使它们标识的地址区间扩大,从而增大进程的堆大小。

    3. vm_area_struct结构体的作用

      在Linux内核中,每一个虚拟内存区域都由一个vm_area_struct结构体来描述,其中vm_start成员变量代表该区域的起始虚拟地址,而vm_end则代表结束地址。通过调整堆对应的vm_area_struct结构体,内核确保了进程虚拟地址空间的合法性和连续性。

    4. 页表映射

      尽管brk()调用后立刻完成了虚拟地址空间的分配,此时分配的仅是虚拟内存,并未分配实际的物理内存。当进程首次尝试访问新分配的内存区域时,由于尚未建立虚拟地址到物理地址的映射关系,将会触发页错误(page fault)。

      内核的页错误处理机制能够捕获这次异常,依据vm_area_struct中的信息定位到相应的虚拟内存区域,并在此基础上为该区域分配所需的物理内存(若有必要,还会从物理内存池中分配页帧)。

      接下来,内核会在页表中为这个新的虚拟地址创建一个新的页表项(Page Table Entry,PTE),并在这个页表项中填入所分配的物理页帧的地址。一旦页表项建立完成,虚拟地址到物理地址的映射就建立了起来,进程便可以正常访问这片原本引发页错误的内存区域。

      这样,“按需”分配物理内存的方式极大地提高了系统的内存利用率,因为只有在真正需要用到内存的时候才分配物理资源,而不是一开始就为进程的所有虚拟地址空间预分配物理内存。同时,这也使得进程可以使用比实际物理内存更大的地址空间,即实现了虚拟内存技术。

  3. 内存映射: 当进程需要将文件、设备或其他资源映射至其虚拟地址空间时,可通过调用mmap()系统调用来实现。在这个过程中,内核会创建一个新的vm_area_struct结构体,以表示这一新的虚拟内存区域,并将其精确地插入到进程的地址空间结构中。不同于brk()调用所影响的堆区域,mmap()不仅可以映射匿名内存,还可以映射文件或其他特定的物理内存区域。

  4. 内存释放: 在进程不再需要某一内存区域(无论是堆内存或是通过mmap()映射的区域)时,它会调用相应的内存释放函数,如free()用于堆内存,munmap()用于映射区域。内核在接到这些释放请求后,会查找与之对应的vm_area_struct结构体,对其进行调整或直接从进程地址空间结构中移除。同时,内核会撤销对该区域的物理内存映射关系,回收物理内存资源,确保内存的有效利用和管理。

  5. 访问内存

    1. 在进程运行期间,每当CPU需要访问内存时,它会将虚拟地址提交给MMU(内存管理单元)进行转换。

    2. MMU根据当前进程的页表(其内容基于vm_area_struct结构体中的信息)将虚拟地址转化为物理地址。

    3. 页表中包含了内存区域的权限设定(如读、写、执行权限)以及其他相关属性,从而保障了进程只能在允许的范围内安全地访问内存,同时也实现了虚拟内存到物理内存的透明映射和管理。

在现代操作系统中,每个进程都有自己的独立虚拟地址空间,这个空间与物理内存并不是一一对应的,而是通过内存管理单元(Memory Management Unit, MMU)进行地址转换。当进程在运行期间需要访问内存时,CPU会生成一个虚拟地址,这个地址需要经过MMU转换才能访问到实际的物理内存。

具体过程如下:

  1. 虚拟地址到物理地址的转换: 当CPU发出一个访问内存的指令时,它提供的地址是虚拟地址,而非物理地址。MMU会根据当前进程的页表来将这个虚拟地址转换为物理地址。页表是一个数据结构,它将虚拟地址空间划分为固定大小的页,并为每个页提供一个映射到物理内存页的条目。这些页表条目(称为页表项)存储在硬件中,由操作系统管理和更新。

  2. 页表与vm_area_struct结构体: 在Linux等类UNIX操作系统中,内核维护了一种叫做vm_area_struct的数据结构,它记录了进程虚拟地址空间的各个内存区域(也叫内存映射区域)的详细信息,包括起始地址、结束地址、访问权限(读、写、执行)以及映射到的物理内存区域等。操作系统在创建和调整进程地址空间时,会根据这些信息来更新页表,建立起虚拟地址到物理地址的映射关系。

  3. 内存权限验证与保护: 在MMU进行地址转换的过程中,不仅会查找对应的物理地址,还会检查页表项中的权限位。如果进程试图执行一个违反当前页表项权限的操作(比如对只读页面进行写操作),MMU会触发一个异常,操作系统内核接收到这个异常后,会判断是否允许该操作,如果不允许,则会采取相应的错误处理措施,如终止进程或更改页表项权限。

通过这种方式,操作系统实现了虚拟内存管理,不仅能够让每个进程拥有独立并且远大于实际物理内存大小的地址空间,还能通过页表来控制进程对内存的访问权限,保障了系统的安全性和稳定性。同时,虚拟内存技术通过按需分页加载和交换机制,使得有限的物理内存资源得到了高效的利用。

3、内存划分

exe:就是一个文件 在Windows操作系统中,一个以.exe为扩展名的文件代表的是一个可执行文件。它包含了运行一个程序所需要的机器指令、数据、资源(如图标、字符串等)以及其他必要的元信息。当用户双击这样的文件时,操作系统会加载这个文件到内存并执行其中的指令。

我们的可执行程序本来就是按照地址空间方式进行编译的 编译器在编译源代码生成可执行文件时,会根据目标架构(如x86、x64等)的地址空间模型进行编译。这意味着编译后的代码和数据会被组织成一个虚拟地址空间的布局,以便在运行时被操作系统映射到实际的物理内存中。每个进程都有其独立的地址空间,这样可以确保进程之间的内存访问互不干扰,提高系统稳定性与安全性。

可执行程序按区域划分,以4KB为单位;物理内存也被划分为4KB的块 这是在现代操作系统内存管理层面的概念:

  • 程序内存区域划分:在编译链接阶段,可执行程序通常会被划分为不同的区域,比如代码段、数据段(包括初始化数据区和未初始化数据区)、堆、栈等。而在运行时,操作系统的内存管理单元(MMU)会进一步按照页(在大多数系统中通常是4KB大小)为单位来分配和管理这些区域。

  • 物理内存分页:现代操作系统普遍采用虚拟内存技术,其中一个重要特点是将物理内存划分为固定大小的页(同样通常是4KB)。这样做的好处在于简化内存管理和提高内存利用率。当进程请求内存时,操作系统会在虚拟地址空间中为其分配一页或若干页,并通过页表机制动态地将这些虚拟页面映射到可用的物理内存页上。如果物理内存不足,操作系统还会利用硬盘上的交换文件实现虚拟内存,即所谓的“换出”和“换入”过程。

4、管理内存

在32位操作系统环境下,尽管地址空间相对有限,操作系统依然需要有效地管理远超100万个4KB大小的物理内存页,这是内存管理的关键职责之一。为了达到这一目标,操作系统不仅设计了精巧的内存管理机制,而且创建了名为 struct page 的数据结构来详尽记录和组织每个物理内存页的详细信息

struct page 结构体充当了操作系统内核与物理内存交互的核心中介,其设计根据不同的操作系统可能存在一定的差异,但基本都会包含反映内存页关键特性和状态的字段。下面是一个更为丰富和详细的 struct page 示例:

// 定义了一个完善的 struct page 数据结构,用于准确、便利地记录和管理单个物理内存页
struct page {
    uint32_t flags; // 标志字段,记录该页的多重状态信息,如是否已分配、是否位于缓存中、是否包含待同步至磁盘的数据等
    uintptr_t physical_address; // 存储该物理内存页的实际物理起始地址

    union {
        void *virtual_mapping; // 若该页已映射至虚拟地址空间,则记录对应的虚拟地址映射关系
        struct frame *physical_frame; // 指针指向物理内存帧,准确记录物理页的实体位置
        struct list_node lru_list_entry; // 将该页插入LRU(最近最少使用)链表,作为页面替换策略的一部分
        struct page *next_free_page; // 若该页闲置,指向下一块空闲内存页,优化内存分配效率
        // ... 其他与内存管理紧密相连的属性字段
    };

    // ... 更多用于支持内存管理策略实施、性能分析及其他相关信息的字段
};

// 考虑到操作系统可能需要管理超过百万个4KB大小的物理内存页
struct page managed_memory_pages[1000000]; // 创建一个巨大的数组,用于储存所有物理内存页的详细描述

在这个简化版的 struct page 中:

  • flags 字段通常用来标志内存页的各种状态,例如是否为空闲、是否正在被使用、是否包含脏数据(需要写回磁盘)等。
  • 内存页的实际物理地址或者映射到虚拟地址空间的地址可以通过联合体(union)中的成员变量来存储。
  • 结构体中可能还包含用于内存管理算法的数据结构,如 LRU 链表节点,用于在内存紧张时选择哪些页应该被换出至磁盘。

在32位操作系统下,操作系统内存管理子系统依然依靠此类数组结构以及其它辅助数据结构,实时更新和精确把控每个内存页的状态迁移,确保无论何时何地都能高效地分配、回收、映射内存,以及在必要时进行智能的页面替换决策。

因此,在32位系统中,即便面临“100w+4KB”的大规模内存管理挑战,操作系统也会通过运用 `struct page` 结构体实例组成的大规模数组,确保系统可以根据各进程的内存需求,灵活且高效地调度和管理这庞大而又珍贵的物理内存资源。

5、页帧、页框

在计算机操作系统中,尤其是涉及到内存管理时,"页帧(Page Frame)" 和 "页框(Page Frame)" 是同一个概念,它们通常指的是物理内存中连续的、固定大小的存储区域,这个大小一般与系统中设置的内存页大小一致,

  • 例如在很多系统中是 4KB。在讨论内存管理时,我们通常把虚拟内存空间分割成多个页,对应的物理内存则被分割成多个页帧。

页帧是用来实现虚拟内存管理的基本单位,操作系统通过页表(Page Table)将虚拟地址空间中的页映射到物理内存的页帧上。当进程需要访问内存时,CPU首先查询页表,获取到对应虚拟地址所映射的物理页帧地址,然后进行数据读取或写入。

  • 举例来说,如果系统有1GB的物理内存,且页大小为4KB,则会有大约2^20(即1,048,576)个页帧。操作系统负责追踪和管理这些页帧的状态(如空闲、已分配、包含页面错误等)以及它们如何被虚拟内存中的各个页所映射。

总结起来,页帧/页框是物理内存管理的基本单元,是实现虚拟内存、内存分配、换页、页面替换等内存管理功能的基础。

6、缺页中断

缺页中断是现代操作系统内存管理中一种重要的机制,它发生在程序试图访问的内存页面(页)尚未加载到物理内存(RAM)中的情况。为了更好地理解这个概念,我们可以结合操作系统地址空间、页表、内存和磁盘来详细说明。

操作系统的地址空间: 在虚拟内存系统中,每个进程都有自己的独立地址空间,这是一个连续的逻辑视图,尽管物理内存可能并不连续。操作系统将进程的地址空间划分为固定大小的单元——页,通常为4KB或8KB等。

页表: 为了将虚拟地址转换为物理地址,操作系统维护了一个叫做页表的数据结构。页表包含了虚拟页到物理页的映射关系。当处理器尝试访问虚拟地址时,首先查询页表,确定对应的物理内存地址。

内存与磁盘交互: 当处理器检测到一个虚拟地址对应的页不在物理内存中(即发生缺页),就会触发缺页中断。这是由硬件实现的一种特殊的中断类型,通知操作系统发生了这种特殊情况。

缺页中断过程

  1. 保护CPU现场:在发生缺页中断时,CPU首先保存当前进程的上下文信息,包括程序计数器(PC)、寄存器状态等,以便稍后恢复执行。

  2. 分析中断原因:操作系统内核接收中断信号,检查产生中断的原因确实是由于访问了不存在于物理内存的虚拟页。

  3. 调入所需页:操作系统根据页表中的信息,得知该虚拟页对应存储在外存(如硬盘或SSD)的交换文件中的位置。于是,操作系统会选择一个空闲的物理页(若没有空闲页,则依据某种页面替换算法替换一个已存在但暂时不那么重要的页面),从磁盘读取所需的页内容到这个物理页中。

  4. 更新页表:将新的虚拟到物理页的映射关系写入页表,确保下一次对同一虚拟地址的访问可以直接找到正确的物理地址。

  5. 恢复执行:完成上述步骤后,操作系统恢复进程的执行环境,更新程序计数器使其重新指向触发缺页中断的那条指令,使得进程能够继续执行。

通过这种方式,缺页中断机制实现了按需分页,从而允许进程的虚拟地址空间大于实际物理内存大小,并且只在真正需要时才将数据从磁盘调入内存,大大提高了内存资源的利用率和系统的整体性能。同时,这也支持了内存保护机制,因为只有具有有效页表项的页面才能被正确访问,否则会导致缺页异常。

7、虚拟地址到物理内存

在32位Linux x86架构的操作系统中,虚拟地址空间通常被划分为多个字段,以适应分页机制。这里的“32位地址空间被分成10 10 12 三个区域”的描述是一种简化版的解释,用于说明地址如何被拆解成不同部分以查找物理地址的过程。

 

  1. 最高12位(页目录索引):这部分地址空间用于定位页目录表中的项。在典型的Linux x86中,页目录表是由1024个条目组成(2^10个),每个条目指向一个一级页表。这12位用于选择页目录表中的哪一个条目,一旦选定,就可以获取到指向一级页表的地址。

  2. 中间的10位(页表索引):在二级页表系统中,这部分地址用于索引一级页表。对于32位地址空间,如果采用两级页表,那么这10位可以用来指向一个一级页表中的某个表项,每个表项存放的是下一个级别的页表(即二级页表)在内存中的基地址。

  3. 最低12位(页内偏移量):这部分地址空间用于指定在一页(通常是4KB)内的具体位置。因为每一页是2^12(即4096)字节,所以理论上只需要12位就能唯一标识页内的任何一个字节的位置。但实际上,出于对齐或者效率的考虑,有时可能会仅用10位,留出额外的2位,这样页的大小仍然是4KB,但页内地址的粒度更大,例如每两个字节作为一个单位进行寻址。

综合起来,寻址流程如下:

  • 当CPU生成一个虚拟地址时,首先取出最高12位作为页目录索引,去查找页目录表,得到指向一级页表的物理地址。
  • 然后取出中间的10位作为一级页表索引,定位到一级页表中的某个条目,从中获取二级页表的物理地址。
  • 最后,取出最低10位(或原本的12位页内偏移量),结合二级页表的索引找到对应的二级页表项,该项包含物理页帧的起始地址。
  • 将物理页帧的起始地址与页内偏移量相加,得到最终要访问的物理内存地址。

8、重新理解进程

用户视角和内核视角看待进程的方式有着显著的区别,这是因为它们关注的角度和涉及的功能层次不同。

用户视角:进程=内核数据结构+进程代码和数据

  • 从用户层面观察进程时,可以将其理解为一种结合了内核维护的底层数据结构以及进程自身所对应的执行代码和相关数据的逻辑单元。
  • 用户视角着重于进程的逻辑表现,即进程执行了哪些操作、处理了哪些数据、与其他进程如何交互等。开发者无需关心进程如何与硬件资源互动或如何调度,他们编写的应用程序通过系统调用与内核交互,请求服务,如打开文件、创建线程、分配内存等。

内核视角:进程=承担分配系统资源的基本实体

  • 从内核管理的角度审视进程,则突显其作为操作系统中分配和控制各类系统资源的核心元素的地位。而在操作系统内核的眼中,进程不仅仅是一个执行代码的载体,更是操作系统管理和调度资源的关键实体。
  • 内核视角下的进程不仅包括了用户视角看到的程序代码和数据结构,还包括了一套由内核维护的、与进程生命周期和行为密切相关的数据结构。这些数据结构记录了进程的状态(如运行、等待、阻塞等)、优先级、内存映射表(页表)、打开的文件描述符列表、信号处理设置、CPU上下文信息(如寄存器状态)以及其他系统资源的使用权等。
  • 内核通过进程数据结构管理和控制进程的行为,负责调度进程在CPU上执行、分配和回收内存、同步和通信、处理I/O请求等核心操作。在内核看来,进程是操作系统分配和控制硬件资源(如CPU时间、内存空间、外设等)的基本单元,它通过调度算法决定哪个进程应该在何时何地获取资源使用权,以实现多任务并发执行和系统资源的有效利用。

总结来说,用户视角关注进程的行为和逻辑,而内核视角更关注进程的管理和资源调度层面,两者共同构成了操作系统中进程完整且立体的概念模型。

二、线程

1、概念

线程,作为一种细粒度的执行单元,在计算机科学领域中被定义为“一个进程内部的独立控制流”。换句话说,线程代表着在单一程序进程中并行执行的不同执行路径。换言之,当你在一个程序中观察到的各个并发执行的执行序列,我们称之为线程。

2、从生活角度理解

在生活中,我们可以将线程比喻为厨房中厨师们分工合作准备一道大餐的过程。

假设你在家里举办一场晚宴,需要准备多道菜品。在这个情境中,整个烹饪过程可以被视为一个“进程”,也就是一个大任务。而每个具体的菜品准备任务就是一个“线程”。

  • 想象一下,你有两个厨师在厨房里工作,一个负责烤鸡(线程A),另一个负责炒菜(线程B)。
  • 这两个线程共享厨房这个“进程”的资源,如炉灶、刀具、冰箱里的食材等(相当于进程的内存空间和其他系统资源)。
  • 虽然他们同时工作,但他们各自有独立的工作进度和任务(线程有自己的执行流和上下文),比如厨师A专注于烤鸡的温度和时间,而厨师B则专注于炒菜的火候和配料。
  • 在准备过程中,厨师A可能需要等待烤箱预热,这时他可以选择去协助厨师B洗菜,这就是线程间的协作和上下文切换。
  • 同时,为了避免两个人同时操作同一台炉灶造成混乱,他们会通过某种约定(比如口头沟通或厨房规定,对应编程中的同步机制)来协调对公共资源(炉灶)的访问。

通过这样的多线程模式,厨房的工作效率得到了极大提升,不同的菜品可以同时准备,使得整个晚宴得以更快速地完成。同样,计算机程序通过创建和管理多个线程,能够实现并发执行多个任务,提高程序运行效率和系统资源利用率。

3、从进程角度理解

进程是操作系统进行资源分配的基本单位,它拥有独立的虚拟内存空间、打开的文件描述符、信号处理等资源。而线程是在同一进程中创建的更轻量级的执行单元,它们共享进程的大部分资源,如地址空间、文件描述符和其他相关资源,但每个线程有自己的程序计数器、栈和一组寄存器(保存线程自身的执行上下文)。

 

  • 线程作为操作系统调度的最小单位,它在进程内部看待资源的方式主要是通过共享进程的大部分资源,并且每个线程拥有独立的一组寄存器和栈空间。 
  • 任何进程在其生命周期内至少包含一个线程,这是其执行的最基本构成要素。线程本质上在进程的地址空间内协同运行,这意味着它与进程内的其他线程共享同样的内存区域、文件描述符、信号处理机制以及其他系统资源。

4、线程共享进程资源

        在Linux操作系统中,线程作为一种更为轻量级的执行单元,其设计理念在于最大程度地减少资源消耗并提高并发性能。从CPU的角度审视,每个线程均配备有一套独有的上下文信息,尽管这套信息较之传统的进程控制块(PCB)更为紧凑,因为它无需复制大部分进程级别的资源数据。得益于Linux内核对进程虚拟地址空间的抽象和管理,大部分进程资源,如代码段、数据段、堆以及栈空间等,都能被透明地共享给该进程中的各个线程。 

线程作为操作系统调度的微粒化单元,在进程内部对资源的使用采取共享与独立相结合的方式:

共享资源方面,线程之间几乎无差别地享有进程的全部公共资源,包括但不限于:

  • 共享地址空间:同一进程的所有线程均可访问相同的虚拟内存空间,涵盖代码段、数据段、堆内存区域以及载入的共享库内容。
  • 共享文件描述符:任何线程都能够操作进程已经打开的文件和网络连接等资源。
  • 同步访问全局变量与静态变量:同一进程内的所有线程均有权读写全局变量和静态变量,这就要求在多线程编程时务必妥善处理同步问题,以免引发数据竞争。
  • 共享系统资源:像是信号量、互斥锁等用于进程间或线程间同步的资源,可被线程群体共同访问和协调使用。

独立资源方面,每个线程保有其独特的核心状态信息:

  • 独立寄存器集:每个线程具备一套自身的寄存器集合,包括程序计数器(PC)、栈指针(SP)以及通用寄存器,这些寄存器记录了线程执行状态和局部变量的状态。
  • 独立栈空间:每个线程还配备了专属的栈内存区域,用于临时储存函数调用时的局部变量和函数返回地址。不同线程之间的栈是相互隔离的,确保了线程间的执行独立性和稳定性。

在多线程环境中,由于线程共享同一地址空间,因此在进行线程切换时,只需要保存和恢复寄存器状态,大大加速了切换速度。另外,独立的栈空间设计则确保了即使在并发执行函数调用时也不会出现交叉干扰的问题。为了确保数据一致性以及资源的安全共享,操作系统通过各种同步机制,如互斥锁、条件变量等,有效地协调多个线程对共享资源的访问和操作,从而提升了系统的整体并发能力和执行效率。

5、从CPU的角度理解

CPU是计算机系统的核心部件,负责执行指令、处理数据,它的运行遵循“顺序执行”的基本原理,但为了提高效率,引入了多任务并行处理的能力。而在操作系统层面,线程作为CPU调度和执行的基本单位,实现了多任务并发执行的效果。

具体来说:

  • 当操作系统调度一个线程时,CPU会保存当前线程的执行上下文(如程序计数器、寄存器状态等),然后加载被调度线程的执行上下文,开始执行该线程对应的指令序列。
  • 在多核CPU环境下,每个核心可以独立地运行一个线程,也就是说,一个进程内的多个线程可以真正意义上的并发执行,极大地提升了CPU资源的利用率和系统的整体性能。
  • 即使在单核CPU环境下,操作系统也会通过时间片轮转等方式,让多个线程看似“同时”运行(实际上是在快速切换),从而营造出并发执行的效果,有效利用CPU时间,避免单一线程执行时CPU空闲的情况。

6、实现线程->pthread 线程库

  • 在Linux操作系统中,虽然早期版本并没有内核级线程(Kernel-level threads,KLTs)的支持,但后来为了实现高效的并发和资源共享,引入了轻量级进程(Lightweight Processes,LWPs),这种轻量级进程在某种程度上模拟了线程的功能,从而在用户层面提供了多线程的能力。
  • 轻量级进程在Linux内核中同样拥有一个task_struct结构体,即进程控制块,但它相比于传统进程在上下文切换等方面更快捷,因为它们共享相同的地址空间和资源。在Linux中,通过clone()系统调用可以创建轻量级进程,它允许子进程继承父进程的部分或全部资源,从而实现线程的效果。
  • Linux并没有提供直接操作线程的内核API,但是通过用户空间的线程库,例如POSIX线程库(Portable Operating System Interface for Unix Threads,简称pthreads),实现了用户级线程(User-level threads,ULTs)的管理。pthreads库提供了一系列线程创建、同步、销毁等接口,使得用户程序可以方便地进行多线程编程。
  • 尽管用户级线程的管理最初是在用户空间完成的,但由于Linux内核对轻量级进程的良好支持,pthreads库能够在后台调用内核系统调用(如clone()),创建和管理轻量级进程,从而达到近似于内核级线程的性能和效果。这种方式使得Linux在不直接提供内核级线程API的情况下,仍然能够高效地支持多线程应用的需求。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
#include <string>
#include <mutex> // 添加头文件以使用互斥锁

using namespace std;

// 创建一个互斥锁对象用于同步对cout的访问
std::mutex mtx;

int x = 100;

void show(const string &name)
{
    // 锁定互斥锁,确保一次只有一个线程可以输出
    std::lock_guard<std::mutex> lock(mtx);

    cout << name << ",pid:" << getpid() << " " << x << endl << endl;
}

void *threadRun(void *args)
{
    const string name = (char *)args;
    while (1)
    {
        show(name);
        sleep(1);
    };
    return nullptr;
}

int main()
{
    pthread_t tid[5];
    char name[64];
    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 (true)
    {
        cout << "main thread, pid: " << getpid() << endl;
        sleep(3);
    }

    return 0;
}

[hbr@VM-16-9-centos thread]$ ./mytest 
thread-0,pid:26230 100

thread-4,pid:26230 100

thread-1,pid:26230 100

thread-2,pid:26230 100

thread-3,pid:26230 100

main thread, pid: 26230
thread-0,pid:26230 100

thread-4,pid:26230 100

thread-1,pid:26230 100
  • 进程与线程共享资源

    • 执行程序./mytest创建了一个进程,其PID为26230。
    • 进程中创建了5个线程,分别名为thread-0thread-4,它们都在打印各自的线程名和进程ID(均为26230),表明它们共享同一个父进程的资源,特别是进程ID。
  • 线程并发执行

    • 各个线程在循环中并发地执行show函数,打印线程名、进程ID和全局变量x的值。
    • 输出结果显示线程按照创建顺序轮流输出,这是因为sleep(1)的作用,使得各线程交替执行,展示了线程在同一进程空间内的并发执行特性。
  • 互斥锁同步

    • 通过添加互斥锁mtx并在show函数中使用std::lock_guard,这意味着在任何时候,只有一个线程能够访问和修改(这里是输出)共享资源(这里是标准输出),体现了线程间同步的重要性。
  • 主线程与工作线程共存

    • 主线程(main函数所在的线程)也在循环中执行输出,打印main thread, pid:及其进程ID,说明主线程与工作线程(由pthread_create创建的线程)在同一进程中并行执行各自的任务。

7、线程的优点

线程技术带来的优势主要包括以下几个方面:

  1. 较低的创建成本:相较于创建一个全新的进程,新建一个线程所需要的系统开销明显较小。这是因为线程共享了进程的地址空间和其他核心资源,避免了重复创建这些资源所需的时间和空间消耗。

  2. 高效的上下文切换:线程之间的切换相比进程切换更为迅速。由于线程在相同的地址空间中运行,操作系统在切换线程时仅需更新少量的寄存器状态,而无需进行地址空间的切换,极大地减少了上下文切换的时间成本。

  3. 节约资源:线程在同一个进程中共享进程的大部分资源,如代码段、数据段和打开的文件等,从而显著减少了对系统资源的需求。

  4. 并行计算能力:线程特别适合在多处理器或多核系统上发挥效能。通过将计算密集型任务拆分成多个线程,可以充分利用硬件的并行处理能力,从而加快计算速度。

  5. 异步I/O处理:在处理大量I/O操作的应用场景中,线程可以极大地提升性能。当一个线程等待慢速I/O操作(如磁盘或网络读写)时,其他线程仍可继续执行计算任务或发起新的I/O请求,实现I/O操作的重叠执行,从而减少整个系统的等待时间,增强并发处理能力

8、线程的缺点

线程所面临的挑战及潜在缺点包括:

  1. 性能损耗:在多线程环境下,尤其是对于计算密集型任务,如果线程数量超过了系统中可用的处理器核心数,会导致上下文切换频繁,增加同步和调度的开销。这样一来,即使线程本身没有受到外部事件阻塞,也可能因为CPU时间片的分配和切换而导致实际计算效率下降,性能不如预期。

  2. 健壮性减弱:相较于单线程应用,多线程编程要求开发者更加细致周全地思考并发控制问题。线程间的交互可能导致竞态条件、死锁等问题,尤其是当多个线程访问和修改共享数据时,如果没有采取适当的同步措施,程序就可能出现不可预测的行为,大大降低了系统的稳定性和可靠性。

  3. 访问控制难题:操作系统层面,进程通常是资源访问控制的基本单位。在一个进程中创建的线程共享大部分资源,这意味着在一个线程中调用的某些操作系统函数会影响到同一进程内的其他线程。例如,关闭某个文件描述符或者更改进程权限等操作,可能对整个进程产生意料之外的影响,从而增加了管理和控制的复杂性。

  4. 编程和调试复杂性提升:多线程编程相对于单线程编程在技术难度上有显著增加。线程间的交织执行不易跟踪,传统的顺序执行逻辑难以直观理解。此外,线程间的并发错误(如数据竞争、死锁等)往往难以重现和定位,这导致调试过程变得十分复杂,对开发人员的专业知识和经验提出了更高的要求。

9、线程异常

        当一个线程在执行过程中遇到诸如除以零或野指针等异常情况时,这将会导致该线程自身的崩溃。由于线程是进程中的执行流,一旦某个线程发生此类严重错误,其影响会迅速扩散至整个进程。就如同一颗石子投入平静湖面所激起的涟漪,线程的异常将触发操作系统的信号机制,从而可能导致进程整体的异常终止。

        进一步解释,即当进程内有一个线程因异常而崩溃时,操作系统通常会选择结束整个进程,这是因为各个线程共享同一进程的资源和地址空间。因此,当进程因为其中一个线程的异常而终止运行时,该进程内的所有其他线程也将同时被迫退出,无法继续执行

10、线程用途

  • 合理的使用多线程,能提高CPU密集型程序的执行效率
  • 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

11、线程切换的成本比进程低

线程切换的成本较低,主要是因为它不需要切换地址空间,因而避免了与之相关的页表更新和缓存失效,同时也减少了需要保存和恢复的上下文信息。

地址空间

  • 进程:每个进程都有自己独立的地址空间,包括代码、数据、堆和栈区域等。在进程切换时,必须更新CPU的页表,即切换到新的进程地址空间,这涉及到TLB(Translation Lookaside Buffer,转换旁路缓冲器)和各级缓存(如L1、L2、L3)的刷新,因为不同的进程拥有不同的物理内存映射。这样的切换不仅耗时,还可能导致大量有效缓存数据失效,需要重新从内存加载数据到缓存,影响性能。
  • 线程:同一进程内的线程共享相同的地址空间,因此在线程切换时无需更改页表或TLB,这意味着地址转换硬件保持不变,因此减少了相关的开销。

上下文切换

  • 进程:除了地址空间之外,进程切换还需要保存和恢复更多的上下文信息,如通用寄存器、程序计数器(PC)、堆栈指针等用户态信息,同时还有可能涉及特权级切换(用户态到内核态再到用户态)以及可能的内存管理单元(MMU)状态变化。
  • 线程:线程切换虽然也需要保存和恢复寄存器等上下文信息,但由于它们共享大部分资源,因此需要保存和恢复的信息相对较少。线程间的切换只需交换少量的CPU寄存器状态,而无需涉及地址空间的切换。

缓存一致性与局部性原理

  • 进程:由于地址空间不同,进程切换后,之前的缓存数据对于新进程来说通常是无效的,新进程运行时需要重新填充缓存,这会导致缓存命中率降低,增加内存访问延迟。
  • 线程:由于线程共享内存,当一个线程切换到另一个线程时,如果两个线程正在访问相近的数据区域,缓存中的一些数据依然可能是有效的,遵循了缓存的局部性原理,有助于提高缓存利用率和整体性能。

12、进程线程对比

        进程是系统资源管理和保护的边界,而线程则代表了进程中并发执行的任务流,线程间的切换相比于进程间的切换更加轻量级,从而提高了系统在处理并发任务时的效率和响应速度。

进程: 进程是操作系统进行资源分配和保护的基本单位。

  • 每个进程在其生命周期内,操作系统会为之分配独立的虚拟地址空间、打开文件描述符集、环境变量等一系列资源。
  • 进程拥有自己的程序代码、数据段、堆区和栈空间,且各个进程之间互不影响,各自的资源是隔离的。当进程执行时,操作系统会在进程间进行调度,确保CPU时间片的公平分配。

线程: 线程则是操作系统进行任务调度和执行的最小基本单位,它存在于进程的上下文中。同一进程内的多个线程共享进程的所有资源,如代码段、数据段、打开的文件等。同时,每个线程除了共享资源外,还维持自身私有的执行状态,这部分私有数据包括但不限于:

  • 线程ID:用于唯一标识线程的身份。
  • 寄存器集合:线程拥有自己的程序计数器(PC)和其他相关寄存器,记录其执行点和执行状态。
  • 栈:每个线程拥有独立的栈空间,用于存放临时变量、函数调用返回地址等信息。
  • errno:错误号码,记录最近发生的错误条件。
  • 信号屏蔽字:每个线程有自己的信号屏蔽字,决定了哪些信号会被阻塞。
  • 调度优先级:线程可以有不同的优先级,操作系统根据这些优先级进行调度决策。
本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码

)">
< <上一篇
下一篇>>