线程概念

1. 线程与进程的区别

进程:拥有独立的地址空间和PCB(进程控制块),是操作系统资源分配的基本单位。

线程:没有独立的地址空间,而是共享其所属进程的地址空间,但每个线程都有自己的PCB,是CPU调度的基本单位。

从控制角度来说。控制回路有3条路,反馈,前馈,前向,输入以及输出。

整个控制回路系统就是一个进程,PCB管理这个系统的整体资源,而控制回路中的三条路径(反馈、前馈、前向)是三个线程,它们:

  • 共享同一个控制系统的资源(传感器数据、执行器、共享变量)

  • 并行执行各自的计算任务

  • 每个线程有自己的TCB来记录执行状态

所以PCB管理整个控制系统的输入输出数据,TCB管理每个控制路径的数据

另外一个例子:自动化工厂

  • 整个工厂 = 进程(有完整的生产系统)

  • 工厂档案(PCB) = 记录工厂的整体状态、设备清单、原料库存

  • 三条生产线 = 三个线程

    • 反馈控制线程(监控产品质量)

    • 前馈控制线程(预测原料需求)

    • 前向控制线程(执行生产指令)

  • 工人工作卡(TCB) = 记录每条生产线当前的工作状态

工作流程

  1. 三条生产线(线程)在同一个工厂(进程)内并行工作

  2. 它们共享工厂的传感器数据、控制参数、执行机构

  3. 调度器通过查看每条生产线的工作卡(TCB)来决定CPU时间分配

  4. 工厂档案(PCB)记录整个系统的资源使用情况

场景1:单进程模型

+-------------------------------+
|          Process A            |
|  +--------------------------+ |
|  |        Address Space     | |
|  |  +--------------------+  | |
|  |  |       Code         |  | |
|  |  +--------------------+  | |
|  |  |       Data         |  | |
|  |  +--------------------+  | |
|  |  |        Heap        |  | |
|  |  +--------------------+  | |
|  |  |       Stack        |  | |
|  |  +--------------------+  | |
|  +--------------------------+ |
|  +--------------------------+ |
|  |           PCB           | |
|  |   - PID: 1000          | |
|  |   - State: Running     | |
|  |   - ...                | |
|  +--------------------------+ |
+-------------------------------+

场景2:多线程模型(同一进程内)

+---------------------------------------------------+
|                 Process A                         |
|   +---------------------------------------------+ |
|   |              Shared Address Space           | |
|   |  +-------------------+ +-----------------+  | |
|   |  |      Code         | |      Code       |  | |
|   |  +-------------------+ +-----------------+  | |
|   |  |      Data         | |      Data       |  | |
|   |  +-------------------+ +-----------------+  | |
|   |  |       Heap        | |       Heap      |  | |
|   |  +-------------------+ +-----------------+  | |
|   |  | Thread 1's Stack  | | Thread 2's Stack|  | |
|   |  +-------------------+ +-----------------+  | |
|   +---------------------------------------------+ |
|   +-------------------+   +-------------------+   |
|   |      PCB 1        |   |      PCB 2        |   |
|   |  - PID: 1000      |   |  - PID: 1001      |   |
|   |  - TGID: 1000     |   |  - TGID: 1000     |   |
|   |  - State: Running |   |  - State: Ready   |   |
|   +-------------------+   +-------------------+   |
+---------------------------------------------------+

三级映射详细解析

映射流程

线程PCB → 页目录(PD) → 页表(PT) → 物理页面(PP) → 内存单元

各级结构说明

1. 页目录 (Page Directory)

  • 位置:位于进程的PCB中

  • 大小:4KB,包含1024个表项

  • 作用:每个表项指向一个页表

  • 特点:同一进程的所有线程共享同一个页目录

2. 页表 (Page Table)

  • 大小:4KB,包含1024个表项

  • 作用:每个表项指向一个物理页面

  • 映射范围:一个页表映射 1024 × 4KB = 4MB 地址空间

3. 物理页面 (Physical Page)

  • 大小:4KB

  • 作用:实际的物理内存块

  • 包含:1024个内存单元(每个单元1字节)

地址转换过程

虚拟地址 = [10位页目录索引] + [10位页表索引] + [12位页内偏移]
1. 通过页目录索引找到页表
2. 通过页表索引找到物理页面
3. 通过页内偏移找到具体内存单元

线程共享原理详解

关键机制

// 线程创建时共享地址空间的关键
clone(..., CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, ...);

CLONE_VM 标志:让新线程与父线程共享同一个页目录和地址空间映射。

线程共享示意图

进程A
├── 页目录 (所有线程共享)
├── 线程1-PCB → 共享页目录
├── 线程2-PCB → 共享页目录
└── 线程3-PCB → 共享页目录
    ↓
相同的页表映射 → 相同的物理页面

与进程的对比

进程A:独立页目录A → 页表集A → 物理页面集X
进程B:独立页目录B → 页表集B → 物理页面集Y
线程A1、A2、A3:共享页目录A → 共享页表集A → 共享物理页面集X

实际内存映射流程

完整路径

线程执行指令
    ↓
CPU遇到虚拟地址
    ↓
查线程PCB中的CR3寄存器 → 找到页目录物理地址
    ↓
通过虚拟地址前10位索引页目录 → 找到页表
    ↓
通过虚拟地址中间10位索引页表 → 找到物理页面
    ↓
物理页面基址 + 页内偏移(12位) = 物理地址
    ↓
访问内存单元

关键点说明

  1. CR3寄存器:每个线程的PCB中都保存着CR3值,指向页目录的物理地址

  2. 共享原理:同一进程的所有线程,其CR3指向同一个页目录

  3. TLB加速:频繁的地址转换通过TLB(快表)缓存,提高性能


为什么线程比进程快?

1. 创建开销小

  • 线程:只需创建PCB和栈,共享现有页目录

  • 进程:需要创建完整的页目录、页表结构

2. 切换开销小

  • 线程切换:主要保存寄存器状态,地址空间不变

  • 进程切换:需要切换整个页目录(刷新CR3和TLB)

3. 通信成本低

  • 线程:直接通过共享内存通信

  • 进程:需要IPC机制,涉及内核拷贝

个人理解

我这么理解。cpu和内核以及内存都是计算机资源,我们要充分利用。 但是现实当中内存难以分配以及计算效率不高,甚至会引起信息传输不安全。所以发展出了mmu以及pcb,通过创建一个虚拟内存空间,将虚拟的内存空间与现实空间进行映射,创建映射表,这样充分利用资源,同时还将信息进行隔离保证安全。 除上述之外,这个操作还不够细腻。一个进程分配一个pcb,但是pcb的内存也不小,为了充分利用资源也要对pcb的资源进一步规划以及分配,针对pcb的内存也相当于使用一个表格进行管理。

从物理内存到虚拟内存

原始状态:程序直接操作物理内存
    ↓
问题:内存碎片、安全风险、效率低下
    ↓
解决方案:引入MMU + 虚拟地址空间
    ↓
效果:每个进程有独立的虚拟世界,通过映射表访问真实物理内存

PCB的精细化管理的演进

第一阶段:粗粒度 - 仅进程

一个程序 = 一个进程 = 一个PCB
    ↓
问题:一个程序内部无法并行,创建进程开销大
    ↓
解决方案:引入线程概念

第二阶段:细粒度 - 进程+线程

// Linux中的实现:task_struct 既可以是进程也可以是线程
struct task_struct {
    pid_t pid;          // 进程ID
    pid_t tgid;         // 线程组ID(进程ID)
    struct mm_struct *mm; // 内存管理结构(线程间共享)
    // ... 其他字段
};

PCB管理的"表格化"理解

您说的"针对PCB的内存也相当于使用一个表格进行管理"非常准确

操作系统内核维护:
┌─────────────────┐
│  进程/线程表    │ ← 这就是管理PCB的"表格"
│  - task_struct *│
│  - 状态         │
│  - 调度信息     │
│  - ...         │
└─────────────────┘
    ↓ 指向各个PCB
┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│  线程1-PCB  │  │  线程2-PCB  │  │  进程B-PCB  │
│  - pid      │  │  - pid      │  │  - pid      │
│  - tgid     │  │  - tgid     │  │  - tgid     │
│  - mm       │  │  - mm       │  │  - mm       │
└─────────────┘  └─────────────┘  └─────────────┘
    共享同一mm       共享同一mm        独立mm

完整的技术栈视图

资源管理的层次化

最底层:物理资源
    CPU核心、物理内存条、硬件设备
    ↓
第一层抽象:MMU虚拟化
    └── 虚拟地址空间 + 页表映射
    ↓
第二层抽象:进程管理
    └── PCB + 进程调度
    ↓
第三层抽象:线程管理
    └── 轻量级PCB + 线程调度
    ↓
最高层:应用程序
    └── 看到的是统一的编程接口

创建线程:pthread_create()

1. 函数原型

int pthread_create(
    pthread_t *thread,               // 传出参数:保存新线程ID
    const pthread_attr_t *attr,      // 线程属性(通常 NULL)
    void *(*start_routine)(void *),  // 线程要执行的函数(入口)
    void *arg                        // 传给该函数的参数
);

2. 参数详解

参数说明
thread传出参数,函数成功后会把新线程的 ID 写入这里。类型是 pthread_t*
attr线程属性(如栈大小、是否分离等)。初学者传 NULL 表示使用默认属性
start_routine回调函数,新线程启动后执行这个函数。必须是 void* func(void*) 形式。
arg传给回调函数的参数,类型是 void*,可以传任意类型的指针(如 int*struct* 等)。

3. 返回值

  • 成功:返回 0
  • 失败直接返回错误码(如 EAGAINEINVAL),不是通过 errno
    • 这是和传统系统调用(如 openfork)的重要区别!
必须注意:
  • 回调函数签名必须严格匹配

    void* my_func(void* arg) { ... }

    不能是 void my_func()int my_func(void*)

  • 线程 ID 类型是 pthread_t,在 Linux 下通常是 unsigned long,但不要假设,直接用 %lu 打印时要强转:

    printf("Thread ID: %lu\n", (unsigned long)tid);

示例:

#include 
#include 
#include 
void* my_thread_func(void* arg) {
    printf("Child thread running! ID = %lu\n", (unsigned long)pthread_self());
    sleep(1);
    return NULL;
}
int main() {
    pthread_t tid;
    printf("Main thread ID = %lu\n", (unsigned long)pthread_self());
    int ret = pthread_create(&tid, NULL, my_thread_func, NULL);
    if (ret != 0) {
        fprintf(stderr, "Failed to create thread: %d\n", ret);
        return 1;
    }
    sleep(2); // 等待子线程执行完(临时方案,后续用 pthread_join)
    return 0;
}

获取线程 ID:pthread_self()

1. 函数原型

#include 
pthread_t pthread_self(void);

2. 特点

  • 总是成功:不会失败,也不返回错误码。
  • 返回当前线程的 ID:就像 getpid() 返回当前进程 ID 一样。
  • 线程 ID 是进程内部的标识:不同进程中的线程 ID 可能相同,但它们互不影响。

3. 线程 ID vs LWP(轻量级进程号)

项目线程 ID (pthread_self())LWP(ps -Lf 中看到的)
作用进程内部标识线程内核调度单位(CPU 时间片依据)
类型pthread_t(在 Linux 中通常是 unsigned long整数 PID(其实是内核线程的 PID)
是否相同?❌ 不同!不要混淆!

打印建议:在 Linux 下,pthread_t 本质是 unsigned long,所以用 %lu 打印:

printf("Thread ID = %lu\n", (unsigned long)pthread_self());

4. 示例代码

#include 
#include 
int main() {
    printf("Main thread ID = %lu\n", (unsigned long)pthread_self());
    return 0;
}

线程中的错误处理:为什么不能用 perror

1. 问题背景:errno 在多线程中不安全!

  • 在传统单线程程序中,系统调用失败时会设置全局变量 errno,然后你可以用 perror() 打印错误。
  • 但在多线程环境中,多个线程共享同一个 errno(虽然现代 glibc 已将其改为线程局部存储,但为了代码可移植性和明确性,仍建议避免依赖 errno)。
  • 更重要的是:pthread_create 等线程函数不会设置 errno

关键事实
pthread_create() 失败时直接返回错误码(如 EAGAIN, EINVAL),不是通过 errnoerrno是输出错误原因,但是pthread_create是返回错误码。

怎么解决报错处理?

不碰 errno,只看返回值;用 strerror_r 解析信息;根据错误码(如 EAGAINEINVAL)针对性处理,确保线程安全和问题可定位。

小测试:

循环创建 5 个线程,每个打印自己的序号

#include 
#include 
#include 
#include 
#include 
// 子线程函数
void* tfn(void* arg) {
    // 将 void* 转回整数(注意 long 中转)
    int idx = (int)(long)arg;
    // 打印:序号从1开始,所以 idx+1
    printf("I'm %dth thread: pid=%d, tid=%lu\n",
           idx + 1,
           getpid(),
           (unsigned long)pthread_self());
    // 模拟不同执行时间(让输出更有序)
    sleep(idx + 1);
    return NULL; // 必须返回!
}
int main() {
    const int N = 5;
    pthread_t tids[N];
    // 创建 N 个线程
    for (int i = 0; i < N; i++) {
        int ret = pthread_create(&tids[i], NULL, tfn, (void*)(long)i);
        if (ret != 0) {
            fprintf(stderr, "Create thread %d failed: %s\n", i, strerror(ret));
            exit(EXIT_FAILURE);
        }
    }
    // 主线程等待(临时方案:usleep)
    usleep(100000); // 100ms,确保子线程有时间输出
    printf("main thread: pid=%d, tid=%lu\n",
           getpid(),
           (unsigned long)pthread_self());
    return 0;
}

erro:循环变量传地址导致线程参数错乱

void* tfn(void* arg) {
    int *p = (int*)arg;
    printf("Thread %d\n", *p); // 解引用指针
    return NULL;
}
int main() {
    pthread_t tids[5];
    for (int i = 0; i < 5; i++) {
        pthread_create(&tids[i], NULL, tfn, &i); // ⚠️ 传的是 &i!
    }
    sleep(1);
    return 0;
}

 输出可能为:

Thread 3
Thread 3
Thread 5
Thread 5
Thread 5

根本原因:所有线程共享同一个地址 &i

内存图解析:

主线程栈帧:
+------------------+
| int i = ?        | ← 地址固定,比如 0x7fff1234
+------------------+
循环过程:
i=0 → 创建线程0,传 &i(0x7fff1234)
i=1 → 创建线程1,传 &i(还是 0x7fff1234!)
i=2 → 创建线程2,传 &i(仍是 0x7fff1234)
...
  • 所有线程的 arg 都指向同一个地址 &i
  • 主线程在 for 循环中不断执行 i++
  • 子线程启动后,什么时候执行 *p 是不确定的(由调度器决定)。
  • 当子线程终于执行到 printf 时,i 可能已经变成 3、5 甚至循环结束了(i=5)!

正确做法:传值,不传地址!

✅ 正确代码:

void* tfn(void* arg) {
    int num = (int)(long)arg; // 从 void* 还原整数
    printf("Thread %d\n", num);
    return NULL;
}
int main() {
    pthread_t tids[5];
    for (int i = 0; i < 5; i++) {
        pthread_create(&tids[i], NULL, tfn, (void*)(long)(i + 1)); // 传值!
    }
    sleep(1);
    return 0;
}

✅ 输出:

Thread 1
Thread 2
Thread 3
Thread 4
Thread 5

为什么这样安全?

  • 每个线程收到的是 i 的一个独立副本(数值被编码进指针值中)。
  • 主线程后续修改 i完全不影响子线程拿到的值
  • 没有共享内存,没有竞争条件,线程安全!

关于 (void*)(long)i 的深度解释

问题:为什么不能直接 (void*)i

在 64 位系统:

  • int i = 3; → 4 字节:0x00000003
  • (void*)i → 8 字节指针:0x0000000000000003
  • 看似没问题,但编译器会警告:
    warning: cast to pointer from integer of different size [-Wint-to-pointer-cast]

为什么用 long 中转?

  • 在 Linux 64 位系统,long 是 8 字节,和指针同宽。
  • (void*)(long)i 先把 int 扩展为 8 字节 long,再转指针,消除宽度不匹配警告
  • 更标准的做法是用 intptr_t(定义在 <stdint.h>):
    #include 
    pthread_create(..., (void*)(intptr_t)i);
    int num = (int)(intptr_t)arg;

✅ 初学记住:(void*)(long)i 传整数,用 (int)(long)arg 取回,即可安全又少警告。

扩展思考:如果必须传结构体怎么办?

typedef struct { int id; char name[20]; } Task;
// 正确做法:每个线程分配独立内存
for (int i = 0; i < 5; i++) {
    Task *task = malloc(sizeof(Task));
    task->id = i + 1;
    strcpy(task->name, "worker");
    pthread_create(&tid, NULL, tfn, task); // 传堆地址
}
// 子线程中:
void* tfn(void* arg) {
    Task *t = (Task*)arg;
    printf("Task %d\n", t->id);
    free(t); // 谁分配,谁释放!
    return NULL;
}

线程退出

1. return(从线程函数返回)

void* thread_func(void* arg) {
    int id = *(int*)arg;
    if (id == 2) {
        return NULL;  //  仅结束当前线程函数
    }
    printf("Thread %d running\n", id);
    return NULL;
}
  • 作用:正常从函数返回。
  • 影响:只结束当前线程,其他线程照常运行。
  • 本质:等价于调用 pthread_exit(return_value)

✅ 安全、推荐方式之一。

2. pthread_exit(void *retval)

if (id == 2) {
    pthread_exit((void*)88);  //  显式退出当前线程,并传回值88
}
  • 作用:主动终止当前线程
  • 影响:只退出自己,不影响其他线程。
  • 可传值:通过 retval 把结果传给 pthread_join

⚠️ 注意:

  • retval不能是局部变量地址!因为线程一退出,栈就销毁了。
  • 正确做法:传 NULL、全局变量地址、或 malloc 分配的堆地址。

✅ 推荐用于需要显式退出或传递状态的场景。

3. exit(int status)

if (id == 2) {
    exit(0);  //  危险!整个进程立刻终止!
}
  • 作用:终止整个进程(包括所有线程!)。
  • 影响:所有线程瞬间死亡,不管它们在干什么。
  • 后果:可能造成资源泄漏、文件未保存、数据丢失!

❌ 在多线程程序中,子线程绝对不要用 exit()

特别注意:主线程如果用 return 0exit(0),也会导致整个进程退出,子线程可能还没跑完!

小测试:

#include 
#include 
#include 
void* thread_func(void* arg) {
    int id = *(int*)arg;
    printf("Hello from thread %d!\n", id);
    sleep(1); // 模拟工作
    printf("Thread %d done.\n", id);
    return NULL;
}
int main() {
    pthread_t t1, t2;
    int id1 = 1, id2 = 2;
    pthread_create(&t1, NULL, thread_func, &id1);
    pthread_create(&t2, NULL, thread_func, &id2);
    // 等待两个线程结束
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("All threads finished. Main exiting.\n");
    return 0;
}

Q:线程结束后,怎么把一个“结果”(比如状态、计算值)安全地传给主线程

设你让一个线程去计算 5 + 3,算完后想告诉主线程:“结果是 8”。

// 子线程:
int result = 5 + 3;
return &result;  // 把地址传回去?

这不行!为什么?

内存分区简图:

高地址
┌──────────────┐
│   栈 (stack)  │ ← 每个线程私有!函数调用时自动分配,返回时自动释放
├──────────────┤
│   堆 (heap)   │ ← 所有线程共享!用 malloc 分配,手动 free
├──────────────┤
│  全局/静态区   │ ← 所有线程共享!程序启动时分配,结束时释放
└──────────────┘
低地址

关键点:

  • 局部变量(如 int x = 100;)存在“栈”上
  • 线程一退出,它的“栈”就整个被销毁了!
  • 所以:返回局部变量的地址 = 返回一个已经无效的地址!

那怎么安全传值?

✅ 方法1️⃣:不传地址,直接“伪装”整数为指针(最常用!)

C语言中,void* 是“通用指针”,但在64位系统上,指针是8字节,long 也是8字节
所以我们可以“把整数塞进指针里”,虽然不标准,但广泛使用且安全

线程函数:
void* worker(void* arg) {
    int sum = 5 + 3;
    return (void*)(long)sum;  // 把整数强转成 void*
}
主线程接收:
void* result;
pthread_join(tid, &result);
long value = (long)result;  // 把 void* 转回 long
printf("计算结果是: %ld\n", value);  // 输出 8

优点:简单、高效、无需分配内存!
适用场景:只需要传一个整数、状态码(如 0=成功,1=失败)

方法2️⃣:用全局变量(适合传复杂数据)

// 全局区(所有线程都能看到)
int global_result = 0;
void* worker(void* arg) {
    global_result = 5 + 3;
    return &global_result;  // 返回全局变量地址,安全!
}
// 主线程:
int* p = (int*)result;
printf("结果: %d\n", *p);  // 输出 8

✅ 安全,因为全局变量程序结束才销毁
⚠️ 但多个线程同时写 global_result 会有冲突(需要加锁,后面学)。

方法3️⃣:用堆内存(最灵活,但要手动管理)

void* worker(void* arg) {
    int* p = malloc(sizeof(int));  // 在堆上分配
    *p = 5 + 3;
    return p;  // 返回堆地址,安全!
}
// 主线程:
int* p = (int*)result;
printf("结果: %d\n", *p);
free(p);  // ⚠️ 必须手动释放!否则内存泄漏

✅ 适合返回结构体、数组等复杂数据。

pthread_join

为什么需要 pthread_join

回忆:

  • 线程结束后,它的资源(如栈、线程控制块)不会自动释放,会变成“僵尸线程”(类似僵尸进程)。
  • 如果你不回收它,系统资源会慢慢耗尽。
  • 更重要的是:你想知道线程干完活没?结果是什么?

✅ 所以 pthread_join 有两个作用:

  1. 等待线程结束(同步)
  2. 获取它的退出值(通信)

函数原型:

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

参数解释:

参数类型作用
threadpthread_t要等待并回收的线程ID
retvalvoid**(二级指针)用来接收退出值的地址

✅ 场景:子线程返回数字 42,主线程打印它

#include 
#include 
void* worker(void* arg) {
    return (void*)42;  // 返回整数42(伪装成指针)
}
int main() {
    pthread_t tid;
    void* result;       // 1. 定义一个 void* 变量来存结果
    pthread_create(&tid, NULL, worker, NULL);
    // 2. 调用 pthread_join 等待线程结束,并把结果存到 &result
    pthread_join(tid, &result);  // 注意:传的是 &result(二级指针)
    // 3. 把 void* 转回 long(因为42被塞进指针了)
    printf("子线程返回: %ld\n", (long)result);  // 输出 42
    return 0;
}

▶️ 编译运行:

gcc join_demo.c -o join_demo -lpthread
./join_demo
# 输出:子线程返回: 42

什么 retval 是 void**(二级指针)?

这是很多人困惑的地方。我们拆解:

目标:让 pthread_join 能修改你定义的变量

  • 你想让 pthread_join 把退出值写到你的变量 result 里。
  • 在 C 语言中,要修改一个变量,必须传它的地址
  • result 的类型是 void*,所以它的地址就是 void**
void* result;          // 类型:void*
pthread_join(..., &result);  // &result 类型:void**

✅ 就像 scanf("%d", &x) 一样,要传地址才能写入!

对比进程回收:

int status;
wait(&status);        // wait(int*) ← 因为子进程返回 int
void* result;
pthread_join(..., &result); // pthread_join(void**) ← 因为线程返回 void*

✅ 设计完全对称!