面试里问到「进程和线程的区别」,绝大多数人能背出「进程是资源分配单位,线程是调度单位」。但只要追问一句「那具体共享哪些资源、不共享哪些」,答不上来的人立刻现形。再往下问「线程切换到底比进程切换省了什么」,能说清楚页表、TLB、寄存器、内核栈这几层的就更少了。这篇把这两个问题拆到可验证的粒度:哪些资源是进程级私有、哪些是线程级私有、哪些是进程内共享,以及一次上下文切换在内核里究竟动了哪些数据结构、省下的开销来自哪里。
一、先建立正确的资源分层模型
要回答「共享什么」,必须先明确「谁持有谁」。Linux 的实现里,这件事由两个核心结构体承担:task_struct(调度实体,俗称 PCB)和 mm_struct(地址空间)。线程在 Linux 里就是共享同一个 mm_struct 的 task_struct,内核并不为线程单独造一个「线程结构体」,这正是一直说「Linux 不区分进程和线程」的由来。
用 clone() 的 flag 来描述最直观,因为 pthread 库底层就是调 clone():
/* 创建一个线程:共享地址空间、文件描述符表、信号处理函数、文件系统信息 */
clone(thread_fn, stack_top,
CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM,
arg);
/* fork 一个进程:以上 flag 全不传,内核复制一份 mm_struct、files_struct 等 */
pid_t pid = fork();
可以看到「共享」不是一句空话,而是由一组 flag 精确控制的。把它们翻译成资源清单如下表,这张表建议直接背下来:
| 资源 | 进程内线程是否共享 | 内核承载结构 | 说明 |
|---|---|---|---|
| 虚拟地址空间(代码段、数据段、堆) | 共享 | mm_struct |
CLONE_VM,这是最本质的一条 |
| 全局变量、静态变量、堆上对象 | 共享 | 同上 | 因此必须自己做同步 |
| 打开的文件描述符表 | 共享 | files_struct |
CLONE_FILES;一个线程 close() 会影响其他线程 |
| 当前工作目录、umask、根目录 | 共享 | fs_struct |
CLONE_FS;chdir() 对同进程所有线程生效 |
| 信号处理函数表(handler) | 共享 | sighand_struct |
CLONE_SIGHAND;sigaction() 改的是全进程的 |
| 信号屏蔽字(blocked mask) | 不共享 | task_struct->blocked |
每线程独立,可各自 pthread_sigmask() |
| 待处理信号(pending) | 进程级一份 + 线程级一份 | signal_struct / task_struct |
进程级信号由任一未屏蔽线程处理 |
| 寄存器上下文、栈指针 | 不共享 | task_struct->thread |
切换的核心内容 |
| 用户栈 | 不共享 | 由 pthread 在堆/映射区分配 | 默认 8MB,ulimit -s 相关 |
| 线程私有存储 TLS | 不共享 | task_struct->thread.fsbase |
x86-64 上通过 %fs 段基址寻址 |
| errno | 不共享 | 本质是 TLS 变量 | 每线程一份,别以为全局唯一 |
| 调度优先级 / nice / 亲和性 | 不共享 | task_struct |
pthread_setschedparam() 只影响单线程 |
| 内核栈 | 不共享 | thread_info 所在页 |
x86-64 默认 16KB(THREAD_SIZE_ORDER=2) |
| PID / TGID | PID 独立,TGID 相同 | task_struct->pid/tgid |
gettid() 取 PID,getpid() 取 TGID |
一个容易踩的坑:很多人以为「文件描述符是线程私有的」。实际上 fork() 出来的子进程共享同一个打开文件描述(file description)的偏移量,而 clone(CLONE_FILES) 出来的线程干脆连 fd 表本身都是同一份。
二、用代码把「共享 / 不共享」验证一遍
光看表格记不牢,写个程序打印出来最实在。注意下面这段在 gcc -pthread 下可直接编译运行,输出里的地址和值就是证据。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <errno.h>
#include <sys/syscall.h>
int g_counter = 0; /* 全局变量:线程间共享 */
__thread int t_var = 0; /* TLS:线程间不共享 */
void *worker(void *arg) {
long id = (long)arg;
g_counter++; /* 没有加锁,故意演示竞态 */
t_var = (int)id * 100;
printf("[thread %ld] getpid=%d gettid=%ld\n", id, getpid(), syscall(SYS_gettid));
printf("[thread %ld] &g_counter=%p g_counter=%d\n", id, (void*)&g_counter, g_counter);
printf("[thread %ld] &t_var=%p t_var=%d\n", id, (void*)&t_var, t_var);
/* 把 errno 置脏,观察是否影响其他线程 */
errno = 1000 + (int)id;
usleep(1000);
printf("[thread %ld] errno after sleep=%d\n", id, errno);
return NULL;
}
int main(void) {
pthread_t t[3];
printf("[main] getpid=%d gettid=%ld\n", getpid(), syscall(SYS_gettid));
for (long i = 0; i < 3; i++)
pthread_create(&t[i], NULL, worker, (void*)(i + 1));
for (int i = 0; i < 3; i++)
pthread_join(t[i], NULL);
return 0;
}
典型输出(地址每次不同,关系不变):
[main] getpid=18422 gettid=18422
[thread 1] getpid=18422 gettid=18423
[thread 1] &g_counter=0x55f0c2a1b020 g_counter=1
[thread 1] &t_var=0x7f3a1c7f46f8 t_var=100
[thread 2] getpid=18422 gettid=18424
[thread 2] &g_counter=0x55f0c2a1b020 g_counter=2
[thread 2] &t_var=0x7f3a1bfd36f8 t_var=200
[thread 3] getpid=18422 gettid=18425
[thread 3] &g_counter=0x55f0c2a1b020 g_counter=3
[thread 3] &t_var=0x7f3a1b7b26f8 t_var=300
[thread 1] errno after sleep=1001
[thread 3] errno after sleep=1003
[thread 2] errno after sleep=1002
读出来的结论:三个线程 getpid() 返回同一个值、gettid() 各不相同,这就是 TGID 与 PID 的区别;&g_counter 三行完全相同,证明共享数据段;&t_var 三行不同,证明 TLS 每线程一份;errno 各自保留了自己写进去的值,证明它不是全局的。
补充一句:用
ps -eLf | grep 程序名或ps -T -p <pid>能看到同一 TGID 下的多个 SPID(即线程 ID)。面试时提到这个命令,比空谈概念有说服力得多。
三、上下文切换的实质:到底保存了什么
理解了资源归属,切换开销就好推了。一次上下文切换在内核里大致做三件事:
- 保存当前任务的状态:通用寄存器、PC、SP、FLAGS 等压入内核栈并写入
task_struct->thread(x86-64 上是struct thread_struct,含rsp、fsbase、gsbase等字段)。 - 切换内核栈与地址空间:把
current指向新任务;如果新任务是不同进程的,还要switch_mm()换页表基址寄存器。x86-64 上的关键动作是写CR3。 - 恢复新任务状态:从它的内核栈和
thread_struct里弹出寄存器,iretq/sysretq返回用户态。
线程切换与进程切换的差异,全部集中在第 2 步:
- 共享
mm_struct时switch_mm()被跳过。Linux 里这是通过惰性 TLB 优化实现的:只有当prev->mm != next->mm时才真正换地址空间,否则连CR3都不写。 - 不换 CR3 就不需要刷 TLB。这是开销差距的大头。现代 CPU 的 TLB 只有几十到一两千项,进程切换后旧条目大部分失效(PCID 机制能缓解,但不是消除),后续访存要重新走多级页表,一次 miss 可能付出几十到上百个时钟周期。
- 不换地址空间,缓存局部性更好。两个线程访问同一份代码和数据,L1/L2 cache 命中率高;两个进程往往是完全不同的热数据,cache 频繁失效。
- 内核侧仍然要切换内核栈和寄存器,这部分开销线程和进程是一样的,没有区别。
所以准确的表述是:线程切换省掉的是地址空间切换相关的成本(页表基址寄存器写入、TLB 失效、cache 冷启动),而不是「保存寄存器」这件事本身。很多面试者说成「线程切换不用保存上下文」,这是错误表述——寄存器照样要存要恢复。
switch_mm() 里有个细节可以加分:它会检查 cpumask_test_cpu(cpu, mm_cpumask(prev)),据此决定是否需要刷 TLB。这也是为什么有些资料说「同进程线程切换几乎不碰页表」,但严格讲,内核栈切换、寄存器保存这些仍然发生。
四、量一遍:用工具看真实开销
空谈「开销小」说服力有限,实测数据更可靠。看单次切换耗时用 perf:
# 统计 5 秒内的上下文切换次数
perf stat -e context-switches,cpu-migrations,page-faults -a sleep 5
# 看某个进程的切换情况
perf stat -e context-switches -p <pid> sleep 1
用 vmstat 看系统级指标更直观,其中 cs 列就是每秒上下文切换次数,in 是中断次数:
vmstat 1 5
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa st
# 2 0 0 812304 91240 4213888 0 0 0 12 480 1240 3 1 96 0 0
想要更精确的单次成本,可以自己写微基准:两个线程通过条件变量或 pipe 互相 ping-pong,测 N 次往返时间再除以次数。经验上(现代 x86-64 服务器,数量级参考,不同机器差很多):
- 同进程线程间切换 + 管道往返,通常在 1~3 微秒量级;
- 跨进程同条件往返,通常要 2~6 微秒量级,多出来的部分主要来自地址空间切换与 cache/TLB 影响。
之所以强调「数量级参考」,是因为具体数字受 CPU 型号、是否开启 PCID、内核版本、是否有 CONFIG_PREEMPT、是否被 cgroup 限流等因素强烈影响。面试时给数量级 + 说明影响因素,比背一个精确的「1.2 微秒」可信得多。
另一个常被追问的点:线程数不是越多越好。
nproc看到的是逻辑核数,占用 CPU 的线程超过这个数就会增加切换次数而不增加吞吐。线程池大小的经验公式(CPU 密集型取核数 + 1,IO 密集型取核数 × (1 + 平均等待时间/平均计算时间))也是从这个角度来的。
五、面试现场的答题骨架与高频追问
把上面的内容压缩成一段能说 90 秒的答法:
- 先给归属:进程持有地址空间(
mm_struct)、fd 表(files_struct)、fs 信息、信号处理表;线程共享这些,但各自拥有寄存器上下文、用户栈、内核栈、TLS、信号屏蔽字、errno、调度属性。 - 再讲切换:切换本质是保存/恢复寄存器 + 切内核栈 + (可能)切地址空间。线程与进程的差别仅在最后一项。
- 最后讲代价:切地址空间要写 CR3、导致 TLB 与 cache 失效,这部分是线程切换省下来的。寄存器保存那部分谁都要付。
常见的追问和正确答案:
- 「线程有自己的 PID 吗?」——有,
gettid()返回的就是线程的 PID;getpid()返回的是 TGID,同进程所有线程相同。 - 「一个线程崩溃会怎样?」——进程通常整体退出,因为地址空间是共享的,除零、野指针等信号默认终止整个线程组。
- 「一个线程
close(fd)会影响别人吗?」——会,fd 表共享。这是线上事故的高发点。 - 「
fork()之后子进程继承线程吗?」——不继承,子进程只有一个执行流(调用 fork 的那个线程的副本),其他线程不会复活。
小结
把两个问题收敛成一句话:线程共享进程的地址空间、fd 表、fs 信息、信号处理表,独占栈、寄存器上下文、TLS、信号屏蔽字和调度属性;切换开销的差距不来自寄存器保存,而来自是否切换地址空间以及随之而来的 TLB / cache 失效。记住 clone() 的那组 flag,就记住了整张共享表;记住 switch_mm() 里的 prev->mm != next->mm 判断,就记住了开销差异的来源。
FAQ
Q1:为什么说 Linux 内核不区分进程和线程?
因为两者都由 task_struct 表示,唯一的实质差别是是否共享 mm_struct 等结构,而这个差别由 clone() 的 flag 决定。内核调度器眼里只有 task,没有「进程/线程」的类型标签。
Q2:线程切换真的完全不刷 TLB 吗?
不是「绝对不刷」,而是「不需要因为地址空间变化而刷」。如果因为其他原因(如内核修改了某页映射、发生 munmap),照样会触发 TLB shootdown,同进程的其他线程也会收到 IPI 受影响。所以准确说法是「省掉了地址空间切换导致的 TLB 失效」。
Q3:编译时报 undefined reference to `pthread_create' 怎么解决?
链接时加 -pthread,注意要放在源文件之后:gcc main.c -o main -pthread。只加 -lpthread 在某些平台上不够,-pthread 会同时设置编译和链接阶段的宏定义,是推荐写法。
Q4:程序跑起来报 pthread_create: Resource temporarily unavailable 是什么原因?
典型是线程数达到上限。检查三处:ulimit -u(用户最大进程/线程数)、/proc/sys/kernel/threads-max(系统级上限)、/proc/sys/vm/max_map_count(每个线程栈是一次 mmap,默认 65530,线程多时会先撞这个)。此外内存不足导致栈分配失败也会返回 EAGAIN。
Q5:怎么查看一个进程下有多少线程、各自占多少 CPU?
ps -T -p <pid> 列出所有 SPID;top -H -p <pid> 按线程看 CPU;ls /proc/<pid>/task | wc -l 直接数线程数。定位到高 CPU 线程后,把它转成十六进制去 gdb 里 thread apply all bt 对照,是排查线上 CPU 飙高的标准流程。
Q6:线程栈默认多大,能改吗?
Linux 上 pthread 默认栈大小通常 8MB(与 ulimit -s 相关),可以用 pthread_attr_setstacksize() 调整,最小不能小于 PTHREAD_STACK_MIN(x86-64 上一般是 16KB 量级)。栈设太小会在深递归或大局部数组时栈溢出,表现为 SIGSEGV,且往往很难定位。