01 · 一个线程,一个 task_struct
在应用层,进程提供运行程序的资源环境,线程是其中的一条执行路径。一个进程可以包含多个线程。
在 Linux 内核中,每个线程都是一个任务(task),都有自己的 task_struct,主线程也不例外。
一个有三个线程的进程,就有三个 task_struct。这些任务分别参与调度,通过线程组关系组织在一起,并共享地址空间等资源。
task_struct 通过指针关联资源:mm 指向地址空间,files 指向文件描述符表,fs 指向目录上下文。任务对象独立,不代表它引用的资源也独立。
02 · 一张图看清区别与联系

图:普通 fork 与同进程 POSIX 线程的概念对照,省略非核心字段和内部步骤。
先看图中普通 fork 后的两套资源视图,再看多个线程如何指向同一批资源:
| 对比项 |
普通 fork 父子进程 |
同进程线程 |
| task_struct |
各自独立 |
每个线程也各自独立 |
| 地址空间 |
不同 mm_struct |
共享同一个 mm_struct |
| 文件描述符表 |
表独立;继承的 fd 可指向同一个打开文件对象 |
表本身共享 |
| 目录上下文 |
各自维护 |
通常共享 |
| 寄存器、内核栈 |
各自独立 |
各自独立 |
| 任务身份 |
属于不同线程组 |
TID 不同,TGID 相同 |
混淆的地方:
普通 fork 的地址空间独立,但私有可写页可以先共享物理页,需要写入时再通过 COW 分离。
每个线程各自使用用户栈区域,但这些栈仍在共享地址空间内,并不是其他线程无法访问的隔离区。
03 · PID、TID、TGID 怎么区分?
同一进程的线程属于同一个线程组。各线程的内核任务 ID 不同,但线程组 ID 相同:
| 任务 |
内核 pid / TID |
tgid / 进程 ID |
| 主线程(组长) |
3000 |
3000 |
| 工作线程 A |
3001 |
3000 |
| 工作线程 B |
3002 |
3000 |
getpid() 返回共同的进程 ID,gettid() 返回当前线程的任务 ID。pthread_self() 返回线程库的 pthread_t,不能等同于 TID。以上 ID 按同一 PID 命名空间理解。
同组任务的 group_leader 指向同一个组长;组长也参与调度,并不负责给其他线程分配 CPU。
04 · 创建机制相通,共享参数不同
以 Linux 6.1 为例,fork 与线程创建的系统调用路径汇入 kernel_clone():copy_process() 准备新任务,wake_up_new_task() 让它进入运行队列。
区别在于创建参数。glibc 的 pthread_create() 准备用户栈和 TLS,并通过 clone 系列调用设置资源共享和线程组关系:
| 标志 |
作用 |
CLONE_VM |
共享地址空间 |
CLONE_FILES / CLONE_FS |
共享 fd 表 / 目录上下文 |
CLONE_SIGHAND |
共享信号处理动作表 |
CLONE_THREAD |
加入同一线程组 |
这些标志有组合约束,单独共享地址空间并不足以定义 POSIX 线程。另外,信号处理动作共享,信号掩码仍是每线程独立的;TLS 也为各线程提供各自的变量实例。
05 · 这些区别,对写程序有什么影响?
共享变量要同步。线程能直接访问同一块内存,并发读写需要适当的锁或原子操作;进程之间则可通过管道、Socket、共享内存等机制协作。
共享 fd 表要协调。一个线程关闭 fd,会影响其他线程后续使用该描述符;普通 fork 子进程关闭自己的 fd,不会直接删除父进程表里的对应项。
各自调度,不代表故障隔离。某线程等待 I/O 时,其他可运行线程仍可执行;但导致进程终止的致命信号可能结束整个线程组。
写在最后
记住三点就够了:
一个线程,一个 task_struct。
普通 fork 创建新的线程组与地址空间视图。
同进程线程各有执行现场,共享资源环境。
判断某个状态是否共享,关键是看:几个 task_struct,是否指向同一个资源对象?
思考题:
既然线程共享同一份虚拟地址空间,能不能在一个线程里直接修改另外一个线程的用户栈?
Linux 6.1 / glibc NPTL · 以普通 fork 和 POSIX 线程为例