跳转至

进程、线程与协程

学习目标

学完本章后,学习者应该能够:

  1. 区分进程、线程和协程。
  2. 理解进程地址空间和上下文切换的基本含义。
  3. 理解 Go goroutine 与操作系统线程的关系。
  4. 能解释僵尸进程、孤儿进程、IPC 和同步问题。

进程

进程是操作系统分配资源的基本单位。一个正在运行的 Go 服务就是一个进程。

进程通常拥有:

  • 独立地址空间。
  • 打开的文件描述符。
  • 环境变量。
  • 当前工作目录。
  • 权限信息。
  • 一个或多个线程。

进程隔离让不同程序之间不容易互相破坏,但进程间通信也因此需要特定机制。

线程

线程是 CPU 调度的基本单位。一个进程中可以有多个线程,它们共享进程地址空间和文件描述符。

线程共享资源带来便利,也带来风险:

  • 多线程能并发执行。
  • 共享内存通信速度快。
  • 需要锁、原子操作或其他同步手段。
  • 写错容易出现数据竞争和死锁。

协程与 goroutine

协程是用户态调度的轻量执行单元。Go 的 goroutine 由 Go runtime 管理,不是一创建就对应一个操作系统线程。

简化理解:

很多 goroutine -> Go runtime 调度 -> 少量 OS thread -> CPU 执行

goroutine 很轻量,但不是没有成本。无限创建 goroutine 仍然会消耗内存、调度成本和外部资源。

上下文切换

上下文切换是 CPU 从一个执行单元切到另一个执行单元时保存和恢复现场的过程。

上下文切换过多会带来问题:

  • CPU 时间花在切换上,而不是业务代码上。
  • 缓存命中率下降。
  • 延迟抖动变大。

Go 服务中,goroutine 阻塞、锁竞争、系统调用、网络 IO 都可能影响调度行为。

进程间通信 IPC

不同进程地址空间隔离,需要 IPC 通信。

常见方式:

  • 管道。
  • Unix Domain Socket。
  • TCP socket。
  • 共享内存。
  • 消息队列。
  • 信号。

后端服务里最常见的是 socket:服务间调用、数据库连接、Redis 连接都可以理解为跨进程通信。

Go 后端实际应用例子

例子一:限制 goroutine 并发数

不要因为 goroutine 便宜就无限创建。处理批量任务时,可以用 channel 做并发限制:

func RunJobs(ctx context.Context, jobs []func(context.Context) error, limit int) error {
    sem := make(chan struct{}, limit)
    var wg sync.WaitGroup
    errCh := make(chan error, len(jobs))

    for _, job := range jobs {
        select {
        case sem <- struct{}{}:
        case <-ctx.Done():
            return ctx.Err()
        }

        wg.Add(1)
        go func(job func(context.Context) error) {
            defer wg.Done()
            defer func() { <-sem }()
            if err := job(ctx); err != nil {
                errCh <- err
            }
        }(job)
    }

    wg.Wait()
    close(errCh)

    for err := range errCh {
        return err
    }
    return nil
}

这个模式常用于批量调用外部 API、批量处理文件、批量发送消息。

例子二:优雅退出依赖信号

服务收到 SIGTERM 后应该停止接收新请求,并给已有请求一段完成时间:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

go func() {
    <-ctx.Done()
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    _ = srv.Shutdown(shutdownCtx)
}()

这背后涉及进程信号和操作系统进程管理,也是容器和 Kubernetes 优雅退出的基础。

常见误区

  • 误区一:goroutine 没有成本。

goroutine 有栈、调度和资源持有成本。阻塞的 goroutine 过多会导致内存上涨和延迟变差。

  • 误区二:线程越多越快。

线程太多会增加上下文切换和锁竞争。吞吐量通常需要结合 CPU 核数、IO 等待和任务类型来调。

  • 误区三:只要用了锁就线程安全。

锁要保护完整的不变量。锁粒度错误、锁顺序不一致、忘记保护读路径都可能出问题。

线上问题案例

某个 Go 服务批量处理用户事件时,每条事件都启动一个 goroutine,并且每个 goroutine 都访问数据库。流量上涨后,goroutine 数量飙升,数据库连接池被打满,请求大量超时。

根因不是“Go 并发能力不够”,而是缺少背压和并发控制。修复方式包括限制 goroutine 数量、控制数据库连接池、使用队列削峰、增加超时和取消。

实战任务

写一个批量任务执行器:

  1. 输入一组任务函数。
  2. 支持设置最大并发数。
  3. 任意任务出错时返回错误。
  4. 支持 context.Context 取消。
  5. 说明为什么不能无限制创建 goroutine。
参考答案

可以用带缓冲 channel 作为信号量控制并发数。启动 goroutine 前写入 sem,任务结束时释放。每个任务都应该接收同一个 context.Context,这样上游取消后任务能尽快退出。

无限创建 goroutine 的风险包括内存上涨、调度成本变高、外部依赖被打爆、连接池耗尽,以及错误时无法快速收敛。高级工程师写并发代码时要同时考虑吞吐、背压、取消和资源上限。

面试题

1. 进程和线程有什么区别?

参考答案

进程是资源分配的基本单位,拥有独立地址空间、文件描述符、环境变量等资源。线程是 CPU 调度的基本单位,同一进程内的线程共享地址空间和大部分资源。

进程隔离性更强,但通信成本更高;线程共享内存通信方便,但需要同步,否则会出现数据竞争。

2. goroutine 和线程是什么关系?

参考答案

goroutine 是 Go runtime 管理的轻量执行单元,不等同于操作系统线程。Go runtime 会把大量 goroutine 调度到较少的 OS thread 上执行。

这种模型让 Go 能以较低成本支持大量并发任务,但 goroutine 仍然会占用内存和调度资源,不能无限创建。

3. 什么是上下文切换?为什么它会影响性能?

参考答案

上下文切换是 CPU 从一个执行单元切换到另一个执行单元时,保存当前执行现场并恢复另一个执行现场的过程。

切换本身需要成本,还可能破坏 CPU 缓存局部性。线程或 goroutine 数量过多、锁竞争严重、频繁阻塞唤醒都会增加调度开销,导致吞吐下降和延迟抖动。