线程概念
- 线程与进程的区别
进程:拥有独立的地址空间和PCB(进程控制块),是操作系统资源分配的基本单位。
线程:没有独立的地址空间,而是共享其所属进程的地址空间,但每个线程都有自己的PCB,是CPU调度的基本单位。
从控制角度时说。控制回路有3条路,反馈,前馈,前向,输入以及输出。
整个控制回路系统就是一个进程,PCB管理这个系统的整体资源(内存、IO设备、定时器等),
控制回路中的三条路径(反馈、前馈、前向)是三个线程,它们:
-
共享同一个控制系统的资源(传感器数据、执行器、共享变量)
-
但并行执行各自的计算任务
-
每个线程有自己的TCB来记录执行状态
PCB管理整个控制系统的"家当",TCB管理每个控制路径的"执行状态""
想象一个自动化工厂:
-
整个工厂 = 进程(有完整的生产系统)
-
工厂档案(PCB) = 记录工厂的整体状态、设备清单、原料库存
-
三条生产线 = 三个线程:
-
反馈控制线程(监控产品质量)
-
前馈控制线程(预测原料需求)
-
前向控制线程(执行生产指令)
-
-
工人工作卡(TCB) = 记录每条生产线当前的工作状态
工作流程:
-
三条生产线(线程)在同一个工厂(进程)内并行工作
-
它们共享工厂的传感器数据、控制参数、执行机构
-
调度器通过查看每条生产线的工作卡(TCB)来决定CPU时间分配
-
工厂档案(PCB)记录整个系统的资源使用情况
场景1:单进程模型
XML
+-------------------------------+
| Process A |
| +--------------------------+ |
| | Address Space | |
| | +--------------------+ | |
| | | Code | | |
| | +--------------------+ | |
| | | Data | | |
| | +--------------------+ | |
| | | Heap | | |
| | +--------------------+ | |
| | | Stack | | |
| | +--------------------+ | |
| +--------------------------+ |
| +--------------------------+ |
| | PCB | |
| | - PID: 1000 | |
| | - State: Running | |
| | - ... | |
| +--------------------------+ |
+-------------------------------+
场景2:多线程模型(同一进程内)
XML
+---------------------------------------------------+
| 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 | |
| +-------------------+ +-------------------+ |
+---------------------------------------------------+
三级映射详细解析
映射流程(修正版)
XML
线程PCB → 页目录(PD) → 页表(PT) → 物理页面(PP) → 内存单元
各级结构说明
1. 页目录 (Page Directory)
-
位置:位于进程的PCB中
-
大小:4KB,包含1024个表项
-
作用:每个表项指向一个页表
-
特点:同一进程的所有线程共享同一个页目录
2. 页表 (Page Table)
-
大小:4KB,包含1024个表项
-
作用:每个表项指向一个物理页面
-
映射范围:一个页表映射 1024 × 4KB = 4MB 地址空间
3. 物理页面 (Physical Page)
-
大小:4KB
-
作用:实际的物理内存块
-
包含:1024个内存单元(每个单元1字节)
地址转换过程
XML
虚拟地址 = [10位页目录索引] + [10位页表索引] + [12位页内偏移]
1. 通过页目录索引找到页表
2. 通过页表索引找到物理页面
3. 通过页内偏移找到具体内存单元
线程共享原理详解
关键机制
cpp
// 线程创建时共享地址空间的关键
clone(..., CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, ...);
CLONE_VM 标志:让新线程与父线程共享同一个页目录和地址空间映射。
线程共享示意图
XML
进程A
├── 页目录 (所有线程共享)
├── 线程1-PCB → 共享页目录
├── 线程2-PCB → 共享页目录
└── 线程3-PCB → 共享页目录
↓
相同的页表映射 → 相同的物理页面
与进程的对比
XML
进程A:独立页目录A → 页表集A → 物理页面集X
进程B:独立页目录B → 页表集B → 物理页面集Y
线程A1、A2、A3:共享页目录A → 共享页表集A → 共享物理页面集X
实际内存映射流程
完整路径
XML
线程执行指令
↓
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的内存也相当于使用一个表格进行管理。
从物理内存到虚拟内存
XML
原始状态:程序直接操作物理内存
↓
问题:内存碎片、安全风险、效率低下
↓
解决方案:引入MMU + 虚拟地址空间
↓
效果:每个进程有独立的虚拟世界,通过映射表访问真实物理内存
PCB的精细化管理的演进
第一阶段:粗粒度 - 仅进程
XML
一个程序 = 一个进程 = 一个PCB
↓
问题:一个程序内部无法并行,创建进程开销大
↓
解决方案:引入线程概念
第二阶段:细粒度 - 进程+线程
cpp
// Linux中的实现:task_struct 既可以是进程也可以是线程
struct task_struct {
pid_t pid; // 进程ID
pid_t tgid; // 线程组ID(进程ID)
struct mm_struct *mm; // 内存管理结构(线程间共享)
// ... 其他字段
};
PCB管理的"表格化"理解
您说的"针对PCB的内存也相当于使用一个表格进行管理"非常准确
XML
操作系统内核维护:
┌─────────────────┐
│ 进程/线程表 │ ← 这就是管理PCB的"表格"
│ - task_struct *│
│ - 状态 │
│ - 调度信息 │
│ - ... │
└─────────────────┘
↓ 指向各个PCB
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 线程1-PCB │ │ 线程2-PCB │ │ 进程B-PCB │
│ - pid │ │ - pid │ │ - pid │
│ - tgid │ │ - tgid │ │ - tgid │
│ - mm │ │ - mm │ │ - mm │
└─────────────┘ └─────────────┘ └─────────────┘
共享同一mm 共享同一mm 独立mm
完整的技术栈视图
资源管理的层次化
XML
最底层:物理资源
CPU核心、物理内存条、硬件设备
↓
第一层抽象:MMU虚拟化
└── 虚拟地址空间 + 页表映射
↓
第二层抽象:进程管理
└── PCB + 进程调度
↓
第三层抽象:线程管理
└── 轻量级PCB + 线程调度
↓
最高层:应用程序
└── 看到的是统一的编程接口
创建线程:pthread_create()
1. 函数原型
cpp
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)的重要区别!
- 这是和传统系统调用(如
必须注意:
-
回调函数签名必须严格匹配:
cppvoid* my_func(void* arg) { ... }不能是
void my_func()或int my_func(void*)! -
线程 ID 类型是
pthread_t,在 Linux 下通常是unsigned long,但不要假设 ,直接用%lu打印时要强转:cppprintf("Thread ID: %lu\n", (unsigned long)tid);
示例:
cpp
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
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. 函数原型
cpp
#include <pthread.h>
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打印:
cpp
printf("Thread ID = %lu\n", (unsigned long)pthread_self());
4. 示例代码
cpp
#include <stdio.h>
#include <pthread.h>
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 个线程,每个打印自己的序号
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
// 子线程函数
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:循环变量传地址导致线程参数错乱
cpp
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)!
正确做法:传值,不传地址!
✅ 正确代码:
cpp
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 -
看似没问题,但编译器会警告:
cppwarning: 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>):cpp#include <stdint.h> pthread_create(..., (void*)(intptr_t)i); int num = (int)(intptr_t)arg;
✅ 初学记住:用
(void*)(long)i传整数,用(int)(long)arg取回,即可安全又少警告。
扩展思考:如果必须传结构体怎么办?
cpp
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;
}