进程与线程共享哪些资源,切换开销差在哪

面试里问到「进程和线程的区别」,绝大多数人能背出「进程是资源分配单位,线程是调度单位」。但只要追问一句「那具体共享哪些资源、不共享哪些」,答不上来的人立刻现形。再往下问「线程切换到底比进程切换省了什么」,能说清楚页表、TLB、寄存器、内核栈这几层的就更少了。这篇把这两个问题拆到可验证的粒度:哪些资源是进程级私有、哪些是线程级私有、哪些是进程内共享,以及一次上

面试里问到「进程和线程的区别」,绝大多数人能背出「进程是资源分配单位,线程是调度单位」。但只要追问一句「那具体共享哪些资源、不共享哪些」,答不上来的人立刻现形。再往下问「线程切换到底比进程切换省了什么」,能说清楚页表、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)。面试时提到这个命令,比空谈概念有说服力得多。

三、上下文切换的实质:到底保存了什么

理解了资源归属,切换开销就好推了。一次上下文切换在内核里大致做三件事:

  1. 保存当前任务的状态:通用寄存器、PC、SP、FLAGS 等压入内核栈并写入 task_struct->thread(x86-64 上是 struct thread_struct,含 rsp、fsbase、gsbase 等字段)。
  2. 切换内核栈与地址空间:把 current 指向新任务;如果新任务是不同进程的,还要 switch_mm() 换页表基址寄存器。x86-64 上的关键动作是写 CR3。
  3. 恢复新任务状态:从它的内核栈和 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 秒的答法:

  1. 先给归属:进程持有地址空间(mm_struct)、fd 表(files_struct)、fs 信息、信号处理表;线程共享这些,但各自拥有寄存器上下文、用户栈、内核栈、TLS、信号屏蔽字、errno、调度属性。
  2. 再讲切换:切换本质是保存/恢复寄存器 + 切内核栈 + (可能)切地址空间。线程与进程的差别仅在最后一项。
  3. 最后讲代价:切地址空间要写 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,且往往很难定位。