Skip to content
Go back

从源码理解 Linux 进程、线程与 Go 协程 goroutine

Updated:
Edit page

进程、线程和协程都能承载一段正在执行的代码,但它们属于不同层次:进程定义资源边界,线程是 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含义
pid42004201每个 task 的独立 TID
tgid42004200两者属于同一个线程组
group_leadertask@t1task@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 之后让某个已有或新建的系统线程执行它;具体调度器实现并不改变这个基本边界。

进程、线程与 goroutine 的执行层级

把三者放在一起

对比项Linux 进程Linux 线程Go goroutine
核心表示一组共享关系明确的 task_structtask_structruntime g
管理者Linux 内核Linux 内核Go runtime
标识tgidpid(TID)goid(仅 runtime 内部使用)
资源关系定义地址空间、文件等资源边界共享进程资源共享所在 Go 进程的地址空间
独立执行现场由组内线程分别保存内核栈、寄存器、调度实体Go 栈、sched、runtime 状态
内核直接调度否,实际调度组内线程是否

最终可以用三句话概括:

  1. Linux 为每个线程维护独立的 task_struct,内核直接调度线程。
  2. 同一进程的线程通过共享 mm、files 等对象形成共同的资源边界,并通过相同 tgid 组成线程组。
  3. Go runtime 用 g 保存 goroutine 的栈、状态和恢复现场,再让它运行于进程中的系统线程之上。

参考资料


Edit page