进程、线程和协程都能承载一段正在执行的代码,但它们属于不同层次:进程定义资源边界,线程是 Linux 内核调度的任务,goroutine 则是 Go runtime 实现和管理的用户态协程。
理解三者的关键不在于背定义,而在于看“谁拥有状态,谁共享资源,谁保存执行现场”。本文以 Linux v5.10 的 task_struct 和 Go 1.20 的 g 为线索,只讨论能帮助建立概念的源码字段。
Linux 的基本对象是任务
Linux 内核没有分别为“进程”和“线程”定义两套核心结构体。无论创建的是一个新进程,还是同一进程中的新线程,内核都会为它创建一个独立的 task_struct。源码常把这样的对象称为 task。
进程和线程的差别,主要体现在多个 task_struct 之间是否共享地址空间、文件描述符表、信号处理等资源,以及它们是否属于同一个线程组。
struct task_struct {
volatile long state;
void *stack;
struct sched_entity se;
pid_t pid;
pid_t tgid;
struct task_struct *group_leader;
struct mm_struct *mm;
struct files_struct *files;
struct fs_struct *fs;
struct signal_struct *signal;
struct sighand_struct *sighand;
struct thread_struct thread;
// ...
};
这里不是完整定义,而是从 task_struct 中摘出最能说明进程与线程关系的字段。可以把它们分成三组来读。
pid、tgid 与 group_leader:任务属于哪个进程
Linux 为每个 task 分配独立的 pid。对线程来说,这个值就是常说的线程 ID(TID)。tgid 是线程组 ID,同一线程组中的所有线程拥有相同的 tgid,用户空间通常把它看作进程 ID(PID)。
group_leader 指向线程组组长。主线程既是一个可调度任务,也是线程组组长,因此它满足:
main thread: pid == tgid, group_leader == self
other thread: pid != tgid, group_leader == main thread
假设一个进程包含两个线程,它们的身份字段可能是:
| 字段 | 主线程 T1 | 子线程 T2 | 含义 |
|---|---|---|---|
pid | 4200 | 4201 | 每个 task 的独立 TID |
tgid | 4200 | 4200 | 两者属于同一个线程组 |
group_leader | task@t1 | task@t1 | 都指向线程组组长 T1 |
因此,Linux 中的“进程”可以理解为一个以 tgid 标识的线程组,而不是另一个包裹线程的调度对象。内核真正放到 CPU 上调度的是组内每个独立的 task_struct。
mm、files、fs:进程资源是否共享
task_struct 通过指针关联资源对象:
mm指向mm_struct,描述用户态虚拟地址空间,包括代码、堆、内存映射等。files指向files_struct,即文件描述符表。fs指向fs_struct,保存当前工作目录、根目录和 umask 等文件系统上下文。signal与sighand分别关联线程组共享的信号状态和信号处理器表。
创建任务的核心路径位于 copy_process。以地址空间为例,copy_mm 会根据 CLONE_VM 决定共享还是复制:
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk) {
struct mm_struct *oldmm = current->mm;
struct mm_struct *mm;
if (clone_flags & CLONE_VM) {
mmget(oldmm);
mm = oldmm;
} else {
mm = dup_mm(tsk, current->mm);
}
tsk->mm = mm;
// ...
}
设置 CLONE_VM 时,新旧 task 的 mm 指向同一个对象,写入内存可以被对方直接看到;未设置时,内核创建新的 mm_struct,由写时复制等机制形成独立地址空间。
其他资源采用相同思路:CLONE_FILES 决定是否复用 files_struct,CLONE_FS 决定是否复用 fs_struct,CLONE_SIGHAND 决定是否复用 sighand_struct,CLONE_THREAD 则决定新 task 是否沿用当前线程组的 tgid 和 group_leader。创建 POSIX 线程时会设置这些共享标志;创建普通子进程时则主要得到各自的资源结构。需要注意,子进程拥有独立的文件描述符表,并不意味着底层已打开文件也完全独立:复制出的描述符仍可能引用相同的 open file description。
这解释了进程和线程最重要的区别:
| 观察角度 | 新进程 | 同进程新线程 |
|---|---|---|
task_struct | 新建 | 新建 |
pid | 不同 | 不同 |
tgid | 不同 | 相同 |
mm | 独立,初始内容通常采用写时复制 | 指向同一个对象 |
files | 独立的描述符表 | 通常指向同一个描述符表 |
| 内核调度身份 | 独立 | 独立 |
state、se、stack 与 thread:线程如何被调度
每个线程都需要自己的调度状态和内核执行现场,这也是它必须拥有独立 task_struct 的原因。
state表示 task 的可运行性,例如TASK_RUNNING、TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE。TASK_RUNNING同时涵盖“正在 CPU 上执行”和“已在运行队列等待”。se是普通任务使用的 CFS(Completely Fair Scheduler)调度实体,其中vruntime、sum_exec_runtime等字段帮助内核选择下一个运行任务并统计执行时间。stack指向该 task 的内核栈。线程进入系统调用、处理中断相关路径或在内核中阻塞时,需要在自己的内核栈上保存调用现场。thread是体系结构相关的线程状态,例如切换任务时需要保存和恢复的寄存器信息。具体字段因 CPU 架构而异。
同一进程中的线程虽然共享 mm 和 files,但各自拥有 state、调度实体、内核栈和寄存器现场。因此,一个线程阻塞并不等于整个进程都停止运行,其他可运行线程仍可被内核调度到 CPU 上。
goroutine 是 runtime 管理的协程
goroutine 是 Go 的协程实现,不对应新的 task_struct,Linux 内核看不到它。对内核来说,Go 程序仍然只是一个包含若干系统线程的进程;goroutine 的创建、阻塞和恢复由 Go runtime 在用户态管理。
Go 1.20 在 runtime2.go 中用 g 保存一个 goroutine 的状态:
type g struct {
stack stack
stackguard0 uintptr
m *m
sched gobuf
atomicstatus atomic.Uint32
goid uint64
waitsince int64
waitreason waitReason
preempt bool
lockedm muintptr
gopc uintptr
ancestors *[]ancestorInfo
// ...
}
这些字段同样可以按身份、状态和执行现场来理解。
stack、stackguard0 与 sched:goroutine 的执行现场
stack 保存 goroutine 栈的地址范围 [lo, hi)。它是用户态栈,不是 task_struct.stack 指向的线程内核栈。每个 goroutine 拥有自己的 Go 栈,所以一个系统线程可以先后执行不同 goroutine,而它们的函数调用链互不混淆。
stackguard0 是函数序言检查栈空间时使用的边界值。栈空间不足时,runtime 可以扩容并搬迁 goroutine 栈;该字段也能被设置为特殊值以触发抢占。这正是 goroutine 栈能够从较小容量开始并按需增长的基础之一。
sched 的类型是 gobuf,保存恢复 goroutine 所需的执行上下文:
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
lr uintptr
bp uintptr
}
其中 sp 是栈指针,pc 是程序计数器。goroutine 让出执行权或阻塞时,runtime 保存现场;再次运行时,再从这些信息恢复。它与内核切换线程时保存寄存器现场的目的相似,但管理者和数据结构不同。
atomicstatus、waitreason 与 preempt:goroutine 当前在做什么
atomicstatus 保存 runtime 内部状态,例如 _Grunnable、_Grunning、_Gwaiting 和 _Gsyscall:
_Grunnable:已经可以运行,但当前没有执行。_Grunning:正在某个系统线程上执行用户代码。_Gwaiting:因 channel、锁、定时器或网络 I/O 等原因等待。_Gsyscall:正在执行系统调用,执行权暂时交给内核。
waitreason 进一步记录 _Gwaiting 的原因,waitsince 记录大致从何时开始等待。这些字段让 runtime 和诊断工具能够区分“还没获得执行机会”与“正在等待某个事件”。preempt 则表示 runtime 希望抢占该 goroutine,使它在安全点停止并让出执行权。
这组字段与 Linux task_struct.state 有相似之处,但不能一一对应:前者描述用户态 goroutine,后者描述内核可调度线程。一个系统线程处于运行状态时,当前 goroutine 仍可能在 Go runtime 内部经历状态切换。
goid、m、lockedm 与创建来源
goid 是 runtime 内部的 goroutine ID,便于运行时、栈转储和追踪区分不同 goroutine。Go 没有提供把它作为业务标识使用的公开 API,不应依赖非公开方式读取它。
m 指向当前执行该 goroutine 的 runtime 线程对象。它只表示当前执行关系,不表示 goroutine 天生绑定某个 Linux 线程;goroutine 暂停后可以由另一个线程继续执行。lockedm 则记录显式的线程绑定,例如调用 runtime.LockOSThread 后建立的约束。
gopc 记录创建该 goroutine 的 go 语句对应的程序计数器。启用 tracebackancestors 调试选项时,ancestors 还会保存一段祖先 goroutine 的创建栈和 goid。它们不影响资源隔离,却能帮助 traceback 回答“这个 goroutine 从哪里创建、创建链是什么”。
从 go 语句看 goroutine 的创建
应用代码只需写:
go handle(conn)
编译器会把它接入 runtime 的 newproc 路径。newproc1 会取得或分配一个 g,初始化栈和 sched,记录 gopc 和可选的祖先信息,把状态从 _Gdead 改为 _Grunnable,再从 runtime 的 ID 缓存中分配 goid。
此时创建的是一个用户态 g,没有调用 Linux 去创建与之对应的线程,也没有生成新的 PID 或 TID。runtime 之后让某个已有或新建的系统线程执行它;具体调度器实现并不改变这个基本边界。
把三者放在一起
| 对比项 | Linux 进程 | Linux 线程 | Go goroutine |
|---|---|---|---|
| 核心表示 | 一组共享关系明确的 task_struct | task_struct | runtime g |
| 管理者 | Linux 内核 | Linux 内核 | Go runtime |
| 标识 | tgid | pid(TID) | goid(仅 runtime 内部使用) |
| 资源关系 | 定义地址空间、文件等资源边界 | 共享进程资源 | 共享所在 Go 进程的地址空间 |
| 独立执行现场 | 由组内线程分别保存 | 内核栈、寄存器、调度实体 | Go 栈、sched、runtime 状态 |
| 内核直接调度 | 否,实际调度组内线程 | 是 | 否 |
最终可以用三句话概括:
- Linux 为每个线程维护独立的
task_struct,内核直接调度线程。 - 同一进程的线程通过共享
mm、files等对象形成共同的资源边界,并通过相同tgid组成线程组。 - Go runtime 用
g保存 goroutine 的栈、状态和恢复现场,再让它运行于进程中的系统线程之上。