进程、线程与协程¶
学习目标¶
学完本章后,学习者应该能够:
- 区分进程、线程和协程。
- 理解进程地址空间和上下文切换的基本含义。
- 理解 Go goroutine 与操作系统线程的关系。
- 能解释僵尸进程、孤儿进程、IPC 和同步问题。
进程¶
进程是操作系统分配资源的基本单位。一个正在运行的 Go 服务就是一个进程。
进程通常拥有:
- 独立地址空间。
- 打开的文件描述符。
- 环境变量。
- 当前工作目录。
- 权限信息。
- 一个或多个线程。
进程隔离让不同程序之间不容易互相破坏,但进程间通信也因此需要特定机制。
线程¶
线程是 CPU 调度的基本单位。一个进程中可以有多个线程,它们共享进程地址空间和文件描述符。
线程共享资源带来便利,也带来风险:
- 多线程能并发执行。
- 共享内存通信速度快。
- 需要锁、原子操作或其他同步手段。
- 写错容易出现数据竞争和死锁。
协程与 goroutine¶
协程是用户态调度的轻量执行单元。Go 的 goroutine 由 Go runtime 管理,不是一创建就对应一个操作系统线程。
简化理解:
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 数量、控制数据库连接池、使用队列削峰、增加超时和取消。
实战任务¶
写一个批量任务执行器:
- 输入一组任务函数。
- 支持设置最大并发数。
- 任意任务出错时返回错误。
- 支持
context.Context取消。 - 说明为什么不能无限制创建 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 数量过多、锁竞争严重、频繁阻塞唤醒都会增加调度开销,导致吞吐下降和延迟抖动。