一次性带你彻底学透进程线程协程的区别
刚接触并发编程的同学,大概率都被进程、线程、协程这三个概念绕晕过:
-
“为什么有了进程还要线程?”
-
“协程又是用来解决什么问题的?”
-
“Go 里的 goroutine 是线程还是协程?”
其实这三者的诞生,本质是为了在“性能”和“资源开销”之间不断做优化。
今天我们从本质定义到核心差异,一次性把它们讲透。
一、本质
从“执行载体”看三者定义。
要区分三者,首先得明确它们在操作系统和程序中的“角色定位”——简单说,它们都是“执行任务的载体”,但所属层级和依赖的调度者完全不同。
(一)进程
进程:操作系统资源分配的最小单位。
进程是程序运行的“独立实例”,比如你打开的浏览器、记事本、Go 程序,每个都是一个进程。
操作系统给进程分配独立的内存空间、CPU 时间片等资源,进程之间相互隔离——一个进程崩溃,不会影响其他进程(比如浏览器崩溃不会让记事本关闭)。
核心特点:
- 资源独立:有自己的堆、栈、文件描述符等,资源开销最大;
- 调度层级:内核级调度(由操作系统内核管理),切换时需要从用户态陷入内核态,耗时较长(毫秒级);
- 隔离性强:进程间通信(IPC)需要通过管道、消息队列等专门机制,不能直接访问彼此内存。
操作系统资源分配的基本单位,具有独立的内存空间,一般一个应用代表一个进程
(二)线程
线程:操作系统调度的最小单位。
线程是进程的“子执行单元”——一个进程可以包含多个线程,这些线程共享进程的内存空间、文件描述符等资源,但有自己独立的栈空间。
比如浏览器的一个标签页崩溃,整个浏览器不会关闭,就是因为每个标签页对应一个线程。
核心特点:
- 资源共享:共享进程的堆内存和资源,仅栈空间独立,资源开销比进程小;
- 调度层级:仍为内核级调度(操作系统内核管理),切换时也需陷入内核态,但开销比进程小(微秒级);
- 隔离性弱:线程间通信直接通过共享内存实现,但需注意线程安全(如加锁避免竞争)。
进程内的执行单元,共享进程内存,一个进程内可以同时并发执行多个线程,线程是由 CPU 内核进行控制:创建、执行、销毁由 CPU 调度执行
(三)协程
协程:用户态的轻量级“执行单元”。
协程(Coroutine)是“用户态线程”,完全由程序代码控制调度,不需要操作系统内核参与。它依赖线程存在,多个协程可以在同一个线程内并发执行,切换时无需陷入内核态,仅在用户态完成。
Go 语言的 goroutine 就是协程的典型实现。
核心特点:
- 极致轻量:初始栈大小仅几 KB(如 goroutine 初始 2 KB),支持动态扩容,创建和切换开销极小(纳秒级);
- 调度层级:用户级调度(由程序 runtime 控制),无需内核干预,切换效率极高;
- 依赖线程:多个协程绑定到一个线程上执行,若某协程阻塞,可能会阻塞整个线程(需结合调度机制优化,如 Go 的 GMP 模型)。
准确的来说,协程应该是 Goroutine,但是 go 基于 Goroutine 的理念创建了属于自己的协程 Goroutine,用户态轻量级线程,由运行时调度,由语言自己进行调度控制,创建和销毁都由语言自身实现
二、差异对比
光看定义不够直观,我们从“调度、开销、隔离性”等 8 个核心维度,直接对比三者的差异,这也是面试高频考点。
用一张表看懂关键维度:
| 对比维度 | 进程(Process) | 线程(Thread) | 协程(Coroutine) |
|---|---|---|---|
| 调度者 | 操作系统内核 | 操作系统内核 | 程序 runtime(用户态) |
| 调度开销 | 大(毫秒级,陷入内核态) | 中(微秒级,陷入内核态) | 极小(纳秒级,用户态切换) |
| 资源分配 | 独立内存、文件描述符等 | 共享进程资源,独立栈 | 共享线程资源,独立栈 |
| 创建数量上限 | 少(数百个,受内存限制) | 中(数千个,受线程栈限制) | 极多(百万级,如 goroutine) |
| 隔离性 | 强(进程崩溃不影响其他) | 弱(线程崩溃可能导致进程崩溃) | 极弱(协程崩溃可能导致线程崩溃) |
| 通信方式 | IPC(管道、消息队列等) | 共享内存、信号量等 | 共享变量、通道(如 Go 的 channel) |
| 并发能力 | 低(切换开销大) | 中(切换开销适中) | 高(切换开销极小,支持高并发) |
| 典型实现 | 浏览器进程、Java 进程 | Java Thread、C++ std::thread | Go goroutine、Python asyncio |
大白话讲解:
一个进程可以执行多个线程
一个线程可以同时执行多个协程
线程是面向内核态(操作系统)的,而协程是用户态(语言开发者)的,
在创建开销、切换成本、内存隔离、调度方式、通信方式等方面,协程的性能远高于线程
协程的初始化大小只有 2-4 k
三、Go 实战
理论讲完,我们用 Go 语言写三段代码,分别实现进程、线程(模拟)、协程的并发执行,直观感受它们的差异。
Go 对协程(goroutine)支持原生,但对进程和传统线程的实现需要借助标准库。
(一)进程实现
通过 os/exec 启动外部进程。
Go 中创建进程可通过 os/exec 包启动外部程序,每个启动的程序都是独立进程。下面代码启动 3 个独立的 echo 进程,演示进程的独立性:
package main
import (
"fmt"
"os/exec"
"time"
)
func main() {
// 定义 3 个进程任务:执行 echo 命令输出信息
tasks := []string{"进程 1 执行完成", "进程 2 执行完成", "进程 3 执行完成"}
for _, task := range tasks {
// 启动独立进程:执行 echo 命令
cmd := exec.Command("echo", task)
// 执行进程并获取输出
output, err := cmd.Output()
if err != nil {
fmt.Printf("进程执行失败:%v\n", err)
continue
}
// 打印进程输出
fmt.Printf("进程输出:%s", output)
// 模拟进程执行耗时
time.Sleep(100 * time.Millisecond)
}
}
// 输出结果(进程独立执行,顺序输出):
// 进程输出:进程 1 执行完成
// 进程输出:进程 2 执行完成
// 进程输出:进程 3 执行完成关键说明:每个 exec.Command 启动的都是独立进程,有自己的资源空间,进程间通过输出结果通信,符合进程“独立隔离”的特性。
(二)线程模拟
通过 sync 包实现传统线程逻辑。
Go 没有直接暴露“传统线程”的 API,但其原生的 goroutine 本质是协程,不过我们可以通过 sync.WaitGroup 模拟线程的并发执行(实际是协程,但能体现线程“共享资源”的特点)。下面代码启动 3 个“线程”,共享一个计数器变量:
package main
import (
"fmt"
"sync"
"time"
)
var (
counter int // 共享计数器(线程间共享)
wg sync.WaitGroup // 等待所有线程完成
mutex sync.Mutex // 互斥锁(保证线程安全)
)
// 线程执行的任务:累加计数器
func threadTask(id int) {
defer wg.Done() // 任务完成后通知 WaitGroup
fmt.Printf("线程 %d 开始执行\n", id)
// 加锁保证线程安全,避免计数器竞争
mutex.Lock()
counter++
fmt.Printf("线程 %d 累加后,计数器值:%d\n", id, counter)
mutex.Unlock()
time.Sleep(100 * time.Millisecond)
fmt.Printf("线程 %d 执行完成\n", id)
}
func main() {
wg.Add(3) // 注册 3 个线程任务
// 启动 3 个“线程”(实际是 goroutine,模拟线程逻辑)
for i := 1; i <= 3; i++ {
go threadTask(i)
}
wg.Wait() // 等待所有线程完成
fmt.Printf("所有线程执行完成,最终计数器值:%d\n", counter)
}
// 输出结果(共享计数器,需加锁保证安全):
// 线程 1 开始执行
// 线程 1 累加后,计数器值:1
// 线程 2 开始执行
// 线程 2 累加后,计数器值:2
// 线程 3 开始执行
// 线程 3 累加后,计数器值:3
// 线程 1 执行完成
// 线程 2 执行完成
// 线程 3 执行完成
// 所有线程执行完成,最终计数器值:3关键说明:3 个“线程”共享 counter 变量,需通过 sync.Mutex 加锁保证线程安全,体现了线程“资源共享、隔离性弱”的特点。
(三)协程实现
Go 原生 goroutine 演示。
Go 对协程(goroutine)的支持是原生的,只需一个 go 关键字就能启动,且能轻松支持百万级并发。
下面代码启动 1000 个 goroutine,演示协程的轻量和高并发能力:
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
// 协程执行的任务:输出协程 ID
func coroutineTask(id int) {
defer wg.Done()
// 模拟任务执行耗时
time.Sleep(50 * time.Millisecond)
fmt.Printf("协程 %d 执行完成\n", id)
}
func main() {
start := time.Now()
// 启动 1000 个协程(goroutine)
wg.Add(1000)
for i := 1; i <= 1000; i++ {
go coroutineTask(i)
}
wg.Wait() // 等待所有协程完成
duration := time.Since(start)
fmt.Printf("1000 个协程全部执行完成,总耗时:%v\n", duration)
}
// 输出结果(部分):
// 协程 123 执行完成
// 协程 456 执行完成
// ...(中间省略 998 行)
// 协程 1000 执行完成
// 1000 个协程全部执行完成,总耗时:约 60ms关键说明:1000 个协程总耗时仅约 60ms,远低于同等数量线程的执行耗时。
这是因为 goroutine 切换在用户态完成,开销极小,体现了协程“轻量、高并发”的核心优势。
四、实战选型
那么什么时候用进程、线程、协程?
三者没有绝对的“优劣”,只有“场景适配性”。结合前面的理论和实战,我们总结不同场景的选型建议:
用进程的场景:需要强隔离、独立容错
当你需要“一个组件崩溃不影响整体系统”时,优先选进程。比如:
- 浏览器的多标签页(每个标签页一个进程,某标签页崩溃不影响其他);
- 微服务架构中的每个服务实例(服务间独立部署,一个服务崩溃不影响其他);
- 沙箱环境(如代码在线执行平台,用进程隔离用户代码,防止恶意攻击)。
用线程的场景:需要多核并行、中等并发
当你需要利用多核 CPU 并行执行,且并发量适中(数千级)时,选线程。比如:
- 后端服务的请求处理(如 Java Tomcat 的线程池,每个请求分配一个线程);
- 数据计算任务(如批量处理数据,用线程池并行计算,利用多核资源);
- 实时性要求较高的任务(线程由内核调度,响应优先级较高)。
用协程的场景:高并发、轻量级任务
当你需要支持“百万级并发”,且任务是轻量级(如网络请求、数据解析)时,优先选协程。比如:
- 高并发 API 服务(如 Go 写的网关服务,单个进程支持 10 万+ 并发连接);
- 爬虫程序(同时爬取数千个网页,用协程发起网络请求,等待响应时切换执行其他任务);
- 消息队列消费者(用协程消费消息,轻量且高效,支持高吞吐量)。
常见问题
Q1. 线程是怎么调度的
线程调度是操作系统内核的基本功能之一,用于决定在多个线程之间分配 CPU 时间的算法和策略。线程调度的具体实现方式可能因操作系统而异,但通常包括以下几个方面:
| 调度策略 | 描述 | 特性 |
|---|---|---|
| 时间片轮转调度 | 操作系统为每个线程分配一定的时间片,在时间片结束后,操作系统会将 CPU 时间切换到下一个就绪线程上 | 这种调度方式可以保证公平性,避免某个线程长时间占用 CPU,但在高负载环境下可能会导致上下文切换频繁,影响系统性能 |
| 优先级调度 | 操作系统为每个线程分配一个优先级,优先级高的线程会先获得 CPU 时间 | 种调度方式可以根据不同线程的需求,合理分配 CPU 时间,但可能会导致优先级低的线程长时间得不到执行,产生“饥饿”现象。 |
| 抢占式调度 | 操作系统可以在任何时候中断正在执行的线程,将 CPU 时间切换到其他就绪线程上 | 这种调度方式可以及时响应高优先级线程的请求,但可能会导致上下文切换频繁,影响系统性能 |
| 同步机制 | 操作系统提供了一些同步机制(如锁、信号量等),可以用于协调多个线程之间的执行顺序和访问共享资源的方式 | 这些同步机制可以通过阻塞线程或者挂起线程的方式来控制线程的执行。 |
Q2. 协程是如何调度的?
协程的调度由语言开发者自定义实现,比如 go 实现的 GMP 调度模型:Go 原理之 GMP 并发调度模型
Q3. 协程为什么比线程轻量?核心原因是什么?
核心原因是“调度层级”和“资源占用”:
- 调度开销:协程是用户态调度,切换时无需陷入内核态,仅保存程序计数器、栈指针等少量上下文,耗时纳秒级;线程是内核态调度,切换需保存寄存器、内存映射等大量上下文,耗时微秒级;
- 内存占用:协程初始栈仅几 KB(如 goroutine 2 KB),且动态扩容;线程初始栈通常几 MB(如 Java 线程默认 1 MB),内存占用是协程的数百倍。
Q4. Go 的 goroutine 是协程,为什么能利用多核 CPU?
因为 Go 有 GMP 调度模型,通过“逻辑处理器 P”将协程(G)映射到操作系统线程(M)上,而 P 的数量默认等于 CPU 核心数。
比如 4 核 CPU 会创建 4 个 P,每个 P 绑定一个 M,这样 4 个协程就能在 4 个核上并行执行,既利用了多核资源,又保留了协程的轻量优势。
Q5. 进程、线程、协程的“并发”和“并行”有什么区别?
并发是“同一时间多个任务交替执行”(看起来同时),并行是“同一时间多个任务真正同时执行”(依赖多核):
- 单线程中的协程:只能并发(交替执行),不能并行;
- 多线程中的线程:在多核 CPU 上可并行,在单核上只能并发;
- 多进程中的进程:在多核 CPU 上可并行,且进程间独立隔离。
Q6. 协程阻塞会影响其他协程吗?如何避免?
会的——如果一个协程执行了“阻塞内核的操作”(如同步 IO、sleep),会导致整个线程阻塞,该线程上的所有协程都无法执行。
解决方式:用“非阻塞 IO”或“异步操作”,比如 Go 中的 net/http 包用非阻塞网络 IO,当协程发起网络请求时,会释放线程给其他协程执行,避免阻塞。
总结
进程、线程、协程的演进,本质是“不断降低调度开销、提升并发能力”的过程:
- 进程:解决了“程序独立运行”的问题,代价是资源开销大、并发低。
- 线程:解决了进程并发能力不足的问题,共享资源降低开销,但仍受内核调度限制。
- 协程:解决了线程并发量不足的问题,用户态调度极致轻量,支持百万级并发,但依赖语言 runtime 优化。
最后用一句话总结选型逻辑: “强隔离用进程,多核并行用线程,高并发轻量用协程”。 理解三者的核心差异,结合业务场景选择合适的并发模型,才能写出高效、稳定的程序。
如果大家对进程/线程/协程的区别还有哪些问题,欢迎大家在评论区分享交流~~~
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/process-thread-goroutine/
备用原文链接: https://blog.fiveyoboy.com/articles/process-thread-goroutine/