1. 从进程到线程:为什么要拆出“线程”这个概念
在操作系统的知识体系里,进程和线程是两个绕不开的核心概念。很多人刚接触时容易混淆,觉得“进程不就是运行中的程序吗,为什么还要再搞一个线程出来?”这个疑问很自然,但等你真正理解了线程的定位,就会明白:不是操作系统故意要增加概念,而是某些现实需求用“进程”这个单位根本没法满足。
先回忆一下进程的核心特征:独立的内存空间、独立的资源分配单位。两个进程之间的数据是完全隔离的,A进程想访问B进程的内存数据,需要走进程间通信(IPC)机制,比如管道、消息队列、共享内存,又慢又别扭。这个设计对“隔离性”是好的——一个进程崩溃不会拖垮另一个进程,操作系统运行起来才稳定。但反过来想:如果我希望多个任务并行执行,而且这些任务之间本来就需要频繁交换数据、配合完成工作呢?用多个进程去实现,通信开销大到不划算。
举个例子,一个GUI程序既要响应用户的鼠标点击,又要后台下载文件,还得实时刷新界面进度条。如果只用一个顺序执行的进程,那用户在下载期间点击按钮是没反应的,体验极差。用多个进程吧,界面进程和下载进程之间那点进度数据还得搞IPC传来传去,写代码的人心态直接崩掉。
线程的解决方案是:一个进程内部可以同时存在多个执行流,这些执行流共享进程的地址空间、打开的文件、全局变量等资源,但每个执行流有自己的栈和寄存器上下文,可以独立被调度执行。线程之间的数据共享天然就是“共享内存”,因为大家本来就在同一个地址空间里干活,不需要任何特殊通信手段。进度条线程读一下下载线程刚写入的全局变量,完事。
这个设计解决了三个痛点:
- 并行执行:一个程序内部存在多条执行路径,可以同时推进不同任务。
- 资源共享:同进程线程间默认共享全局数据,省去IPC的额外开销。
- 轻量切换:线程切换比进程切换轻,因为不需要切换地址空间(同一进程内的线程)、不需要刷新TLB等缓存,上下文切换成本要小一个量级。
所以通俗地讲,进程是“资源和隔离的单位”,线程是“调度和执行的单位”。操作系统眼中要不断切换执行的是一条条线程,进程更像是一个“容器”,把这些共享资源的线程装在一起。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程的核心概念:TCB、状态与生命周期
1.1 线程控制块TCB:每个线程的专属档案
就像进程有PCB(进程控制块)一样,每个线程也有一份自己的档案,叫线程控制块——TCB。TCB里记录的东西比PCB少得多,核心就是让操作系统能独立调度这条线程、并在切换后恢复现场所需的信息:
- 线程ID(TID),用于唯一标识。
- 程序计数器(PC),记录当前执行到哪条指令。
- 寄存器组,包括通用寄存器、栈指针SP等。
- 线程栈指针,指向线程自己的栈空间。
- 线程状态(就绪、运行、阻塞等)。
- 所属进程的指针,表示这条线程归谁管理。
要注意,TCB里没有内存映射表、没有打开文件表这种资源信息,因为这些是进程级的共享资源,线程用进程的就行。这也是“一个进程的线程之间切换快”的根本原因——资源都还在,只需要保存和恢复执行上下文。
1.2 线程的状态迁移
线程的状态和进程的状态模型基本一致:就绪态、运行态、阻塞态。这里不画图,用文字描述清楚:
- 就绪态:线程已经具备运行条件,在就绪队列里排队等CPU。线程被创建后初始状态一般是就绪,等调度器选中它。
- 运行态:调度器把CPU分配给它了,正在执行指令。
- 阻塞态:线程执行中遇到某事件(比如等待I/O完成、等锁、sleep),此时让出CPU,进入阻塞状态。等事件完成后重新回到就绪队列。
状态转换的触发条件需要记牢:就绪→运行靠的是调度器分配CPU;运行→就绪是时间片用完被抢占;运行→阻塞是线程自己发起I/O或加锁操作;阻塞→就绪是等待的事件完成。
这里有个初学者常忽略的细节:线程是内核调度还是用户态调度,状态管理的位置完全不同。如果是纯用户态线程(协程就是典型),内核根本感知不到线程的存在,调度完全由用户态运行时以协作式或抢占式方式管理;如果是内核级线程,状态由内核统一维护。后面第3节细说两者的区别。
1.3 线程结构和生命周期:创建、运行、终止
线程的生命周期比进程简单,无非“创建—运行—终止”三步,但每一步的细节都能展开很多工程内容。
创建线程时,需要确定三件事:线程入口函数、传给线程的参数、线程属性(栈大小、调度策略、是否为分离态等)。以POSIX线程(pthread)为例,最基本的创建代码是:
c复制#include <pthread.h>
#include <stdio.h>
void* worker(void* arg) {
long id = (long)arg;
printf("线程 %ld 开始执行\n", id);
return NULL;
}
int main() {
pthread_t tids[4];
for (long i = 0; i < 4; i++) {
if (pthread_create(&tids[i], NULL, worker, (void*)i) != 0) {
perror("pthread_create");
return 1;
}
}
for (int i = 0; i < 4; i++) {
pthread_join(tids[i], NULL);
}
return 0;
}
注意pthread_create的第四个参数是void*,想要传整数一般会把整数值强转成指针传进去,但千万别传局部变量的地址,否则多个线程会共享同一个可变地址,数据全乱。
编译时要加-lpthread链接线程库:
bash复制gcc thread_demo.c -o thread_demo -lpthread
./thread_demo
运行后会看到4条线程并发打印,打印顺序不固定,这正是并发执行的特征。
线程终止有几种途径:线程函数return返回;调用pthread_exit主动退出;被其他线程pthread_cancel取消。关于线程的资源回收,重点在于:线程退出后它占用的栈空间和TCB不会自动释放(除非是分离态),必须由另一个线程调用pthread_join来“收割”。如果线程创建后你既不想阻塞地等它,又不想它退出后泄漏资源,可以pthread_detach把它置为分离态,让系统在线程退出时自动回收资源。这个细节在长时间运行的服务端程序中非常重要,忘写join或者忘写detach,跑了几天后内存涨上天,排查半天才发现是线程资源没回收。
3. 线程实现模型:用户级、内核级与混合模型
1.1 三种模型的核心区别
线程的底层实现方式决定了它的调度性能、使用的场景和编程模型。这里必须把三种模型讲透,因为很多实际问题(比如“为什么我创建的线程不并行”“为什么线程一多性能反而下降”)的根源都出在实现模型上。
用户级线程(User-Level Thread,ULT):线程的创建、调度、同步全部在用户态完成,内核只知道有一个进程,完全不知道进程里面还有一条条线程。线程切换不涉及内核态陷入,非常快(纳秒级),切换成本基本就是用户态栈切换和少量寄存器操作。但缺点也很明显:如果线程A发起了一个阻塞式系统调用(比如read读文件),整个进程都会被内核阻塞,因为这个进程在内核眼里只有一个执行流,A阻塞了进程就停了,其他线程也跑不了。这就导致用户级线程无法利用多核CPU实现真正的并行——内核只能把进程调度到一个核上,进程内的多条线程在这个核上交替执行。
内核级线程(Kernel-Level Thread,KLT):每条线程由内核管理和调度,用户态通过系统接口(如clone、NPTL)创建。好处是真正可以被调度到多核上并行执行,某条线程阻塞不会拖累整个进程的其他线
