Linux 线程深度解析:概念、机制与实践

Linux-线程理解

1.线程概念

  • 什么是进程?

    1. 进程 = 内核数据结构 + 自己的代码和数据。

    2. 进程是承担分配系统资源的基本实体。

这些资源 (内核数据结构) 包括:

  • task_struct结构体(可能会有多个)
  • 独立的虚拟地址空间

  • 文件描述符表

  • 独立的页表

  • 什么是线程?

    1. 线程是进程内部的一个执行分支(执行流)。

    2. 线程是CPU调度的基本单位。

  • 创建一个新的线程,在内核层面上本质就是创建了一个新的 task_struct,但这个新的 task_struct 与创建它的那个 task_struct 共享绝大部分的资源(虚拟地址空间、页表、文件描述表)。

在这里插入图片描述


一些结论和问题:

  • Linux并不严格区分进程和线程

  • Linux的线程使用进程来模拟的

  • Linux的线程就是轻量级进程

  • 当多个 task_struct 共享一个内存地址空间和其他资源时,在这个视角下被称为线程。线程本质上是对虚拟地址空间的共享。

  • 决定一个 task_struct 是用户眼中的进程还是线程,关键在于它是否与另一个 task_struct 共享资源,尤其是内存地址空间

    • 独立的 task_struct:它既是进程也是唯一的线程(这个线程也可以被称为主线程)。

    • 多个 task_struct 共享资源:其中每一个 task_struct,都是这个进程的一个线程。

  • 进程强调独占,部分共享(比如通信时),线程强调共享,部分独占。

为什么Linux不单独设计线程,而复用进程的代码呢?

Linux选择不单独设计线程,而是用进程来实现线程,这是一个直指Linux内核设计的哲学核心。

  • 一个数据结构搞定一切:如果为线程和进程分别设计两套不同的内核管理结构(比如一个 PCB 和一个 TCB),会导致内核代码变得复杂、冗余,容易出错。

  • 统一的调度器:调度器不需要关心它是进程还是线程,它只处理统一的 task_struct。每个 task_struct 都是平等的调度单元,这极大地简化了调度算法和内核代码(调度算法只需一套即可)。

  • 统一的机制: 比如信号处理、资源管理等所有机制,只需要针对 task_struct 这一种内核数据结构设计和实现即可。例如,向一个线程发送信号和向一个进程发送信号,在内核里是同一套逻辑。

这已经不是一种简单的复用,而是一种极具智慧且经过实践检验的、优雅的工程解决方案。

2.理解虚拟地址空间和页表

2.1 理解虚拟地址空间

简单来说,它们的主要目的是:提供内存抽象、隔离和保护,并实现更高效的内存管理。

假设并不存在它们会怎么样?(物理内存的直接映射)

假设没有虚拟地址空间,程序直接操作物理内存地址(比如0x00000000到0xFFFFFFFF),会带来灾难性的后果:

  • 安全问题(缺乏隔离)

    • 一个恶意或存在Bug的程序可以随意读写其他程序甚至操作系统内核的内存数据。比如,你的浏览器可以修改正在运行的Word文档的内容,或者直接让操作系统崩溃。

    • 结论:系统毫无安全性和稳定性可言。

  • 可靠性问题

    • 每个程序都需要知道哪块物理内存是可用的,这几乎不可能。一个程序很可能意外地覆盖掉另一个程序的关键数据。

    • 结论:无法同时运行多个程序。

  • 内存管理问题

    • 程序必须被加载到一块连续的、足够大的物理内存中才能运行。随着程序的运行和结束,物理内存中会产生大量难以利用的内存碎片。

    • 结论:内存利用率极低。

为了解决上述的问题,操作系统为我们提供了一层软件层(引入虚拟地址空间和页表)来解决:

虚拟地址空间:每个进程都认为自己独占了整个内存(比如在32位系统上是4GB的连续空间)。这个空间是线性的、从0开始的,并且被划分为固定的区域(代码区、数据区、堆、栈等)。程序的所有操作都基于这个虚拟地址。

页表:这是连接虚拟世界和物理世界的地图。它由CPU中的内存管理单元(MMU) 来自动查询(MMU是一个集成在CPU中的硬件)。

这样做的好处:

  • 进程之间具有独立性(进程A无法感知其他进程的存在,也无法访问对方的内存)

  • 解决内存碎片化问题(虚拟地址空间是连续的,但页表可以将其映射到物理内存中任何不连续的物理页框中)。

  • 无序变有序(进程的代码和数据在物理内存可能是乱序的,但是在进程地址空间中则是有序的)。

2.2 物理内存的理解

物理内存的理解:

  • 我们在Linux文件系统中学习知道,扇区是磁盘的基本单位 (通常一个扇区大小是512Byte),而操作系统一次IO通常是读取8个扇区,也就是4KB,磁盘中的4KB被称为块

  • 物理内存中与之对应的、大小通常也为4KB的单位,被称为页框或页帧或页

物理内存的管理:

假设一个可用的物理内存有 4GB 的空间。按照一个页框的大小 4KB 进行划分,4GB 的空间就是 4 GB / 4 KB = (4 * 1024 * (1024KB)) / 4 KB = 1,048,576 个页框,在内核中操作系统使用 struct page 结构描述系统中的每个物理页,出于节省内存的考虑,struct page 使用了大量的联合体。所以,struct page 的主要目的是描述和控制物理内存页(通常是4KB)的状态和元数据,而不是存储实际的数据。

你可以这样理解:内核为物理内存中的 每一个页框(通常为4KB) 都创建了一个对应的 struct page 对象。这些所有的 struct page 对象组成了一个巨大的数组,称为 page_map 数组。通过这个数组,内核可以知道系统中任何一块物理页框的状态和信息。(page_map[0]对应的就是物理内存的第一个4KB,以此类推…)

struct page_map[1048576];数组的下标就被称为页框号(PFN)。

  • 假设每个 struct page 的大小是 64 字节。

    • 1,048,576 * 64 Bytes = 67,108,864 Bytes

    • 67108864 / 1024 / 1024 = 64MB

结论:对于一个拥有 4GB 物理内存的系统,为了管理这些内存而产生的 struct page 结构体的总内存开销大约是 64 MB

struct page 结构体中通常并没有一个直接指向其代表的那4KB物理内存的指针字段,因为 struct page 对象和它代表的物理内存之间存在着固定且可计算的数学关系,保存一个指针将是巨大的冗余。

  • PFN(数组中的下标)=物理地址/4KBPFN(数组中的下标) = 物理地址 / 4KBPFN(数组中的下标)=物理地址/4KB

  • 物理地址=PFN(数组中的下标)∗4KB物理地址=PFN(数组中的下标)*4KB物理地址=PFN(数组中的下标)4KB

所以假设已知一个 struct page 的指针 ,根据数组的首元素地址,通过指针 - 指针的方式算出数组的下标,然后通过下标 * 4KB计算出物理地址即可

page根本不需要申请,操作系统启动初期,就已经一次性将page_map数组全部初始化完毕了。

struct page {
    /* 原⼦标志,有些情况下会异步更新 */        
    unsigned long flags;
    // flags 中的位表示页的各种状态,例如:
    //   PG_locked    - 页是否被锁定
    //   PG_dirty     - 页是否被写入过(脏)
    //   PG_uptodate  - 页的内容是否有效(与磁盘同步)
    //   PG_lru       - 页是否在LRU链表上(用于页面回收)
    //   PG_swapbacked - 页是否可被换出到交换分区
    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;
        };
        // ...
   };
    //...
    union {
        /* 内存管理⼦系统中映射的⻚表项计数,⽤于表⽰⻚是否已经映射,还⽤于限制逆向映射搜索*/
        atomic_t _mapcount; //表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。当计数值变为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它
        unsigned int page_type;
        unsigned int active; /* SLAB */
        int units; /* SLOB */
    };
    // ...
};

2.3 页表的理解

如果页表是单纯的映射比如虚拟地址和物理地址的映射,那么按地址4字节来算,一个虚拟地址映射就是8字节,4GB的虚拟地址映射就是32GB,所以根本不可能。

实际上:操作系统采用了一种 多级页表 的机制,巧妙的解决了这个空间爆炸的问题。其核心思想是,只为那些实际被使用的虚拟内存区域创建映射,而不是为整个4GB空间。

首先:虚拟内存的划分本质上就是一个 start 和 end 整数的上移和下移。

  • 虚拟地址拆分:32位地址,10位页目录索引 + 10位页表索引 + 12位页内偏移。

  • 索引过程:用第一部分找页目录项(PDE),用第二部分找页表项(PTE)。

  • 数量关系:1024个页目录项,每个指向一个页表;每个页表有1024个项,所以总共可以管理 1024 * 1024 = 2^20 个页。这正好对应4GB地址空间。

  • 页内偏移:找到物理页框的基地址后,加上12位的偏移量,就能定位到该4KB页内的任何一个字节。

思考一种极端情况:假设一个进程使用了全部4GB虚拟地址空间,并且每一页都有效,因此页目录和页表都占满。

页目录总占的大小:1024 * 4 bytes = 4096 bytes = 4KB

每个页表总大小:1024 * 4 bytes = 4096 bytes = 4KB

所有页表的总大小:1024个页表 * 每个页表的总大小4KB = 4MB

所以最极端的情况下,一个进程的页表也只有4MB多,而我们刚开始以为的页表如果是单纯的虚拟和物理的映射,一个进程的页表就是32GB,所以采用多级页表的方式节约了大量的内存,其次是绝大多数进程只会用到很少一部分页表,1024个页目录中,只有几个被使用,其它大部分的页目录根本不需要分配页表。

  • 页表项/页(PTE):里面存放的是 物理页框的基地址(物理内存的地址),再加上一些标志位(存在位、可写位、用户/内核位等)

    • 它直接指向物理内存,是给 CPU的MMU硬件 使用的。MMU通过PTE直接拿到物理地址,不需要任何软件介入。

    • 每个进程的页目录的物理地址被 CR3 寄存器指向(其实也是CPU的上下文的一部分)。

    • 所以虚拟地址转物理地址,是通过硬件 MMU 完成的,全程硬件自动、高速完成的。

    • 问题:标志位存放到哪里?4字节不是存放地址了吗?

      • 这个32位的值并不仅仅是一个纯粹的地址,而是被划分成了两部分:

        1. 高20位:用来存储物理页框号(就是page_map[1048576]的下标)

          • 2的20次方 = 1024 * 1024 = 1048576
        2. 低12位:用来存储标志位和控制信息。

  • struct page:是内核软件用于 管理 这个物理页框的数据结构。它描述了这块物理内存的状态(是否空闲?被谁引用?脏了吗?等)。

    • 它是给 操作系统内核软件 使用的。当内核需要分配、释放内存,或者处理缺页异常时,它需要通过 struct page 来了解物理页框的元信息。

在这里插入图片描述


总结:单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双刃剑,在减少存储空间的同时降低了查询效率。

有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。MMU 引入了快表 TLB(其实,就是缓存,Translation Lookaside Buffer)。

当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存。但 TLB 容量⽐较⼩,难免发⽣ Cache Miss ,这时候 MMU 就在⻚表中找到对应物理地址后,MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录⼀下刷新缓存。

2.4 用户申请内存的大概流程

这个流程的核心在于 拖延战术:直到万不得已,才真正分配物理内存。

1️⃣ 用户态 - malloc 调用

  1. 程序调用 malloc(4096):申请4KB内存。

  2. malloc底层它会调用 brk()sbrk() 系统调用,请求操作系统扩大堆的结束地址(即上移 vm_end 指针)。

  3. 结果:到此为止,malloc 成功返回了一个虚拟地址(比如 0x80123000)给程序。关键:此时没有任何物理内存被分配! 操作系统只是修改了进程的 vm_area_struct,承诺这块虚拟地址你可以用了。

2️⃣ 用户态 - 首次访问

  1. 程序拿到 malloc 返回的指针,比如 char *p = malloc(4096);

  2. 程序第一次对这块内存进行读写,例如 p[0] = 'A';

  3. CPU 执行这条存储指令,将虚拟地址 0x80123000 发送给 MMU。

3️⃣ 硬件 - 触发缺页异常

  1. MMU 查询页表:MMU 用虚拟地址 0x80123000 去查找当前进程的多级页表。

  2. 发现页表项无效:在相应的页表项(PTE)中,存在位为0。这表明该虚拟地址还没有映射到任何物理页框

  3. 触发缺页异常:MMU 产生一个缺页异常,CPU 中断当前执行流,切换到内核态。

4️⃣ 内核态 - 异常处理(核心环节)

这是最复杂的一步,内核的缺页异常处理程序开始工作:

  1. 诊断原因:内核检查出错的虚拟地址 0x80123000

  2. 查找区域:在内核为进程维护的 vm_area_struct 红黑树中,查找该地址属于哪个区域。

  3. 合法性检查

    • 找到了吗? 如果没找到(例如访问了未分配的区域),则发送 SIGSEGV 信号(段错误),杀死进程。

    • 权限正确吗? 如果区域是只读的,但进程试图写入,同样发送 SIGSEGV

  4. 分配物理页框:检查通过,内核需要分配一个物理页框。

    • 内核调用伙伴算法,从空闲物理内存链表中申请一个4KB的物理页框

    • 伙伴系统找到空闲页框,返回其物理地址(比如 0x12345000)。

    • 同时,伙伴系统会更新对应的 struct page 元数据,将其状态从空闲改为已使用,并增加引用计数等。

    相当于就是伙伴算法维护了一些空闲链表(目的是为了不用遍历整个链表,空闲链表就是每个节点都是空闲的,直接取下一个就可以O(1)),而链表的节点就是一个page,然后找到空闲的page,根据page计算出物理地址返回,并且将该page中的元信息也填充(计算就是通过 page - page_map数组地址 算出下标就可以了)。*

  5. 更新页表

    • 内核修改当前进程的页表:找到(或创建)虚拟地址 0x80123000 对应的那个页表项(PTE)

    • 将物理地址 0x12345000 和权限标志位(可写、用户态、存在)填入该PTE

  6. 可选:清零页面:出于安全考虑,内核通常会将新分配的物理页框清零,防止泄漏之前进程的旧数据。

5️⃣ 恢复执行

  1. 缺页异常处理完毕,内核返回到用户态,并重新执行那条触发异常的指令 p[0] = 'A';

  2. 这次,MMU再次查询页表,发现PTE的存在位为1,且物理地址为 0x12345000

  3. MMU成功地将虚拟地址 0x80123000 翻译成物理地址 0x12345000,并将字符 'A' 写入该物理内存地址。

  4. 至此,内存申请和使用的整个流程才真正完成。

结合信号部分理解。

2.5 线程的优点

  • 创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多。

  • 与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多。

    • 最主要的区别是线程的切换虚拟内存空间依然是相同的但是进程切换是不同的,虽然他们的上下文都需要切换,但是线程切换CR3寄存器不需要切换。

    • 另外⼀个隐藏的损耗是上下⽂的切换会 扰乱处理器的缓存机制,⼀旦去切换上下⽂,处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚拟内存空间的时候,处理的⻚表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在⼀段时间内相当的低效。但是在线程的切换中,不会出现这个问题。

2.6 线程的私有数据

  • 线程ID。

  • 一组寄存器,存放线程的上下文数据。

  • 栈。

2.7 补充

  • 进程的时间片会被线程等分。

  • 任何一个线程奔溃,都会导致整个进程奔溃。

3. 线程的操作

3.1 什么是 pthread 库

Linux中没有线程的概念,Linux中是使用进程模拟线程的,被称为轻量级进程。

所以,pthread 库并不属于内核,而是 glibc 的一部分,是一个第三方库属于用户态的库。

使用 g++ 编译时,需要携带 -lpthread 选项。

3.2 pthread_create

创建一个新的线程。

函数原型:

int pthread_create(pthread_t *thread,
                   const pthread_attr_t *attr,
                   void *(*start_routine) (void *),
                   void *arg);

参数:

  • thread: 输出型参数,用于返回新线程的 ID。

  • attr: 线程属性,通常传入 NULL 使用默认属性(可设置栈大小、分离状态等)。

  • start_routine: 线程函数的指针。该函数必须形如 void* function_name(void* arg)

  • arg: 传递给线程函数的参数。

返回值: 成功返回 0,失败返回错误码(且 *thread 是未定义的)。

3.3 pthread_join

等待线程终止。

函数原型:

int pthread_join(pthread_t thread, void **retval);

参数:

  • thread: 要等待的线程 ID。

  • retval: 输出型参数,用于接收线程函数 start_routine 的返回值。如果不需要,可以传 NULL

返回值: 成功返回 0,失败返回错误码。

不等待线程终止,会造成内存泄漏问题。

3.4 pthread_self

获取当前线程的id。

pthread_t pthread_self(void);

3.5 线程的终止

3.5.1 三种方式
  1. 从线程函数 return。这种⽅法对主线程不适⽤,从 main 函数 return 相当于调⽤exit。

  2. 线程可以调⽤ pthread_ exit 终⽌⾃⼰。

  3. ⼀个线程可以调⽤ pthread_ cancel 终⽌同⼀进程中的另⼀个线程。

3.5.2 pthread_exit

线程调用此函数来主动终止自己。

函数原型:

void pthread_exit(void *retval);

参数:

  • retval: 线程的返回值,可以被 pthread_join 获取。
3.5.3 pthread_cancel

向一个线程发送取消请求。

当线程被取消时,pthread_joinretval 参数会接收到一个特殊值:PTHREAD_CANCELED,它是一个宏定义,通常是(void*)-1

函数原型:

int pthread_cancel(pthread_t thread);

参数:

  • thread: 要取消的线程 ID。

返回值: 成功返回 0,失败返回错误码。

3.6 pthread_detach

用于将一个线程标记为 分离状态

  • 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进⾏ pthread_join 操作,否则⽆法释放资源,从⽽造成系统泄漏。

  • 如果不关⼼线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,⾃动释放线程资源。

当一个线程处于分离状态时:

  1. 它不能被其他线程通过 pthread_join 等待,且无法获取该线程的返回值,pthread_join 会返回错误码

  2. 当它终止时,系统会自动回收其资源。

函数原型:

int pthread_detach(pthread_t thread);

参数:

  • thread:要设置为分离状态的线程 ID。

返回值: 成功返回 0,失败返回错误码。

在 Linux 的 glibc 实现中,pthread_join 对于已分离的线程有一个特殊行为:

  • 如果线程在调用 pthread_join 之前就已经被分离,那么 pthread_join 会立即返回错误(EINVAL

  • 但如果线程在调用 pthread_join 之后才被分离(就像你的代码),pthread_join正常等待线程结束并返回 0(成功)

3.7 案例

#include <iostream>
#include <unistd.h>
#include <string>
#include <pthread.h>

void* routine(void* args) {
    // 线程分离
    pthread_detach(pthread_self());

    std::string name = static_cast<const char*>(args);
    int cnt = 3;
    while(cnt--) {
        sleep(1);
        std::cout << "I am a new thread , thread name: " << name << " , thread id: " << pthread_self() << std::endl;
    }

    // 线程终止
    pthread_exit(nullptr);
}

int main() {

    // 创建线程
    pthread_t tid;
    pthread_create(&tid , nullptr , routine , (void*)"thread - 1");

    int cnt = 3;
    while(cnt--) {
        std::cout << "I am a main thread ,  thread id:" << pthread_self() << std::endl;
        sleep(1);
    }

    // 由于线程分离,pthread_join 函数返回错误码,并且不能获取返回值
    int res = pthread_join(tid , nullptr);
    if(res != 0) {
        std::cout << "pthread_join error res: " << res << std::endl; 
    }

    std::cout << "main thread end" << std::endl;

    return 0;
}

3.8 查看线程的命令

ps -aL

4. 线程原理的理解

4.1 理解 TCB

当我们的程序引入了 pthread 库时,其实本质就是将 pthread 动态库映射到我们程序的地址空间中,通过动态链接的查找规则找到该函数调用。而在 pthread 库中存在描述当前线程的结构体 struct pthread

描述线程的TCB

/* Thread descriptor data structure. */
struct pthread {
    /* Thread ID */
    pid_t tid;
    /* Process ID - thread group ID in kernel speak. */
    pid_t pid;   

    /* The result of the thread function. */
    void *result;

    // ⽤⼾指定的⽅法和参数
    void *(*start_routine) (void *);
    void *arg;

    // 线程⾃⼰的栈和⼤⼩
    void *stackblock;
    size_t stackblock_size;

    // ...
}

理解线程ID,线程传参和返回值,线程分离:

每当使用 pthread_create 创建线程时,pthread 库会在用户空间为我们分配一个线程控制块。这个 TCB 是一个数据结构,它包含了管理线程所需的所有元数据。

  • pthread_t 类型(我们通常称之为 tid)本质上就是这个 TCB 在用户空间内存地址的抽象表示。而我们在 pthread_join时,返回值其实是被保存到 TCB 描述结构体中,等待成功后,将其通过 void** 赋值给用户,并释放 TCB 相关的资源。这也就是为什么不进行 pthread_join会产生内存泄漏问题,因为内存中的 TCB 没有释放。而每个接口需要传递的 tid,本质就是描述该线程 TCB 的起始地址

  • 线程分离的核心是改变线程结束时的资源回收机制。在 pthread 的实现中,当一个线程结束时,它的 TCB 和栈空间并不会立即被释放。这些资源需要被正确地回收,而分离状态决定了由谁来负责回收,每个线程的 TCB 中都有一个标志位记录它是 JOINABLE 还是 DETACHED

    • joinable:默认状态,线程结束时,它的 TCB 和退出状态值被保留在系统中

    • detached:线程结束时,系统自动立即回收其 TCB 和栈资源,无法对该线程调用 pthread_join,无法获取线程返回值。

在这里插入图片描述


4.2 理解线程栈

线程栈的主要作用和函数调用栈完全一样,它是线程独享的运行时内存空间,用于存储:

  • 局部变量: 线程函数内部定义的非静态变量。

  • 函数调用链路信息:

    • 返回地址(调用完函数后回到哪里)。
  • 参数传递: 当函数参数过多时,超出寄存器容量的部分会通过栈来传递。

那么私有栈是如何实现的呢?

当创建一个新线程时,系统会在进程的虚拟地址空间中划出一块特定的区域(比如在堆附近)分配给这个线程作为栈使用。对于这个线程来说,它拥有这块区域的读写权限。而对于进程内的其他线程,它们理论上知道这块内存的存在(因为大家都在同一个地址空间里),但约定俗成的编程规范是绝不会去访问其他线程的栈。

那本来栈里面的局部变量和信息不是本来就是独立的?那为什么还要单独申请空间存呢?

每个线程的函数调用链和局部变量在逻辑上本来就是独立的。 但问题在于,计算机的硬件(CPU)是如何跟踪和管理这些独立状态的?一个进程的上下文中只有一个栈指针 (SP),如果每个线程切换的时候共用一个栈指针会造成覆盖问题,所以就需要每个线程有一块私有的栈空间。

假设我们有两个线程,Thread A 和 Thread B,它们共享进程的唯一一个栈

  1. Thread A 开始执行:

    • 调用 function_A1(),将返回地址、参数等压栈。

    • function_A1() 内部定义了一个局部变量 int x = 10;,也压栈。

    • 此时栈的内容大致是:[ ... | return_addr_A | x=10 | ... ]

  2. 操作系统进行线程调度:

    • 时间片用完,操作系统暂停 Thread A。

    • 关键一步:操作系统保存 Thread A 的上下文,其中最重要的是栈指针(SP) 的值,它指向了栈顶(x=10 附近)。

  3. Thread B 开始执行:

    • 操作系统恢复 Thread B 的上下文,其中也包括栈指针(SP)

    • 但问题是,Thread A 和 Thread B 共享同一个栈!所以 Thread B 恢复的栈指针,直接指到了 Thread A 的地盘。

    • Thread B 调用 function_B1(),将它的返回地址、参数压栈。这个操作直接覆盖了 Thread A 栈帧的一部分数据!

通过为每个线程分配私有栈空间,并在上下文切换时同步切换SP寄存器的值:

  • 线程A运行时,SP指向A的私有栈。

  • 切换到线程B时:保存A的SP → 加载B的SP → 现在SP指向B的私有栈。

  • 每个线程都在自己的私有栈上操作,互不干扰。

与 2.6 线程私有数据中的CPU上下文相对应,所以线程是被单独调度的。

主线程的栈结构就是进程地址空间的栈,而其他线程的栈结构其实是malloc出来的空间或者使用mmap (在进程的地址空间中映射一段新的内存区域) 开辟的一段空间

4.3 线程的局部存储

线程的局部存储是一种机制,允许每个线程拥有一个全局变量的独立副本。也就是说,在每个线程中都有自己独有的值,互不干扰。这非常适用于需要保持线程上下文状态的场景。

__thread 关键字,这是 Linux/GCC 环境下最简单、最高效的方法。它通过编译器直接支持,只支持内置类型

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 使用 __thread 关键字声明一个整型变量
__thread int tls_var = 0;

void* thread_func(void* arg) {
    int thread_id = *(int*)arg;
    tls_var = thread_id * 10; // 每个线程设置自己的 tls_var

    sleep(1); // 模拟一些工作,确保线程调度能看到效果

    printf("Thread %d: tls_var = %d (address: %p)\n", thread_id, tls_var, (void*)&tls_var);
    return NULL;
}

int main() {
    pthread_t t1, t2;
    int id1 = 1, id2 = 2;

    printf("Main thread: tls_var = %d (address: %p)\n", tls_var, (void*)&tls_var);

    pthread_create(&t1, NULL, thread_func, &id1);
    pthread_create(&t2, NULL, thread_func, &id2);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("Main thread: tls_var = %d (address: %p)\n", tls_var, (void*)&tls_var);
    return 0;
}

上述打印新线程和主线程打印得 &tls_val 地址是不同的。

5. 封装 pthread 库

#pragma once

#include <iostream>
#include <vector>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <functional>
#include <cstdlib>

// 封装pthread
class Thread{
    using func_t = std::function<void()>;

    // 因为成员函数具有隐含的this指针, 不满足 void*(*start_routine)(void*),需要声明为静态函数
    // 但是 _routine 函数需要使用类内成员, 所以需要通过 args 把 this 指针传进来
    static void* _routine(void* args) {
        Thread* self = static_cast<Thread*>(args);
        self->_is_running = true;
        self->_method();
        self->_is_running = false;
        return nullptr;
    }

    public:
        Thread(const func_t& method) 
            :_tid(0)
            ,_method(method)
            ,_is_running(false)
            ,_is_detach(false)
        {
            _tname = "thread - " + std::to_string(id++);
        }

        // 禁止拷贝
        Thread(const Thread&) = delete;
        Thread& operator=(const Thread&) = delete;

        bool start(bool dt = false) {
            if(_is_running)
                return false;   // 线程已经运行

            int n = pthread_create(&_tid , nullptr , _routine , (void*)this);
            if(n != 0) {
                std::cerr << "pthread_create error" <<std::endl;
                return false;
            }

            // 如果设置分离, 在启动后立即分离
            if(dt) {
                detach();
            }   
            std::cout << "pthread_create success , thread name: " << _tname << std::endl;
            return true;
        }

        void join() {
            // 若分离 / 未启动则不需要join
            if(_is_detach || !_is_running) {
                std::cout << "线程已经分离或线程未启动, 不能进行join" << std::endl;
                return;
            }

            int n = pthread_join(_tid , nullptr);
            if(n != 0) {
                std::cerr << "pthread_join error: " << n << std::endl;
            } else {
                std::cout << "pthread_join success , thread name: " << _tname << std::endl;
                _is_running = false;
            }
        }

        pthread_t gettid() { return _tid; }

        bool detach() { 
            // 若分离 / 未启动则不需要detach
            if(_is_detach || !_is_running) {
                std::cout << "线程已经分离或线程未启动, 不能进行detach" << std::endl;
                return false;
            }

            int n = pthread_detach(_tid);
            if(n != 0) {
                std::cerr << "pthread_detach error" << std::endl;
                return false;
            } else {
                std::cout << "pthread_detach success , thread name: " << _tname << std::endl;
                _is_detach = true;
                return true;
            }
        }

        // 谨慎使用 cancel,可能造成资源泄漏
        bool cancel() {
            if(_is_running) {
                int n = pthread_cancel(_tid);
                if(n != 0) {
                    std::cerr << "pthread_cancel error" << std::endl;
                    return false;
                } else {
                    std::cout << "pthread_cancel success" << std::endl;
                    _is_running = false;
                    return true;
                }
            }

            std::cout << "线程未启动" << std::endl;
            return false;
        }

        std::string& gettname() { return _tname; }

        ~Thread() {
            if (_is_running && !_is_detach) {
                // 析构时如果线程还在运行且未分离,尝试join
                pthread_join(_tid, nullptr);
            }
        }
    private:
        pthread_t _tid;         // 线程id
        std::string _tname;     // 线程名字
        func_t _method;         // 线程执行的方法
        bool _is_running;       // 线程是否在运行
        bool _is_detach;        // 线程是否分离
        // void* _result;          
    public:
        static int id;
};

是否在运行
        bool _is_detach;        // 线程是否分离
        // void* _result;          
    public:
        static int id;
};

int Thread::id = 1;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值