线程概念
1. 线程与进程的区别
进程:拥有独立的地址空间和PCB(进程控制块),是操作系统资源分配的基本单位。
线程:没有独立的地址空间,而是共享其所属进程的地址空间,但每个线程都有自己的PCB,是CPU调度的基本单位。
从控制角度来说。控制回路有3条路,反馈,前馈,前向,输入以及输出。
整个控制回路系统就是一个进程,PCB管理这个系统的整体资源,而控制回路中的三条路径(反馈、前馈、前向)是三个线程,它们:
共享同一个控制系统的资源(传感器数据、执行器、共享变量)
但并行执行各自的计算任务
每个线程有自己的TCB来记录执行状态
所以PCB管理整个控制系统的输入输出数据,TCB管理每个控制路径的数据
另外一个例子:自动化工厂
整个工厂 = 进程(有完整的生产系统)
工厂档案(PCB) = 记录工厂的整体状态、设备清单、原料库存
三条生产线 = 三个线程:
反馈控制线程(监控产品质量)
前馈控制线程(预测原料需求)
前向控制线程(执行生产指令)
工人工作卡(TCB) = 记录每条生产线当前的工作状态
工作流程:
三条生产线(线程)在同一个工厂(进程)内并行工作
它们共享工厂的传感器数据、控制参数、执行机构
调度器通过查看每条生产线的工作卡(TCB)来决定CPU时间分配
工厂档案(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位) = 物理地址
↓
访问内存单元
关键点说明
CR3寄存器:每个线程的PCB中都保存着CR3值,指向页目录的物理地址
共享原理:同一进程的所有线程,其CR3指向同一个页目录
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 - 失败:直接返回错误码(如
EAGAIN,EINVAL),不是通过errno!- 这是和传统系统调用(如
open,fork)的重要区别!
- 这是和传统系统调用(如
必须注意:
回调函数签名必须严格匹配:
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),不是通过 errno!errno是输出错误原因,但是pthread_create是返回错误码。
怎么解决报错处理?
不碰 errno,只看返回值;用 strerror_r 解析信息;根据错误码(如 EAGAIN、EINVAL)针对性处理,确保线程安全和问题可定位。
小测试:
循环创建 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>):#includepthread_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 0或exit(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 有两个作用:
- 等待线程结束(同步)
- 获取它的退出值(通信)
函数原型:
#include
int pthread_join(pthread_t thread, void **retval);
参数解释:
| 参数 | 类型 | 作用 |
|---|---|---|
thread | pthread_t | 要等待并回收的线程ID |
retval | void**(二级指针) | 用来接收退出值的地址 |
✅ 场景:子线程返回数字 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*
✅ 设计完全对称!
浙公网安备 33010602011771号