在学习 Go 语言之前,我对并发的理解一直停留在一个比较浅的层面:
并发 = 同时运行多个任务。
比如:
- 开一个线程下载文件;
- 开多个线程处理请求;
- 多个任务一起执行,提高效率。
但真正学习 Go 的并发模型后,我发现 Go 对并发的理解并不是简单地“让多个任务同时跑”。
Go 更关注的是:
如何让多个执行单元安全、高效地协作。
这也是 Go 语言设计哲学中非常重要的一部分:
不要通过共享内存来通信,而应该通过通信来共享内存。
这句话也成为了 Go 并发设计的核心。
一、为什么需要并发?
我们先思考一个问题:
假设一个程序需要完成三个任务:
任务A:读取文件 任务B:处理数据 任务C:发送网络请求如果按照传统的顺序执行:
A → B → C那么:
- A 等待磁盘读取时,CPU 可能空闲;
- C 等待网络响应时,程序也无法做其他事情。
大量时间浪费在等待上。
如果能够:
A \ B \ C三个任务同时推进,那么程序整体效率就会提升。
这就是并发存在的意义:
充分利用计算资源,让程序在等待的时候继续做其他事情。
二、进程、线程、协程:Go 处在哪一层?
在理解 Go 并发之前,需要先区分几个概念。
1. 进程(Process)
进程是操作系统进行资源分配的基本单位。
例如:
打开浏览器:
Chrome进程打开微信:
微信进程每个进程都有:
- 独立内存空间;
- 文件资源;
- 系统资源。
进程之间相互隔离。
优点:
- 稳定;
- 一个进程崩溃不会影响其他进程。
缺点:
- 创建成本高;
- 通信复杂。
2. 线程(Thread)
线程是进程内部的执行单位。
例如:
一个浏览器进程:
Chrome进程 ├── UI线程 ├── 网络线程 └── 渲染线程多个线程共享进程资源。
优点:
- 创建成本低;
- 通信方便。
缺点:
共享数据容易产生问题。
例如:
两个线程同时修改一个变量:
count++可能出现:
线程A读取 count = 0 线程B读取 count = 0 线程A +1 线程B +1 最终: count = 1明明执行两次,结果却是 1。
这就是经典的:
线程安全问题。
3. 协程(Goroutine)
Go 没有直接让开发者操作线程。
而是提供:
Goroutine
它是一种轻量级执行单位。
例如:
普通线程:
线程1 线程2 线程3可能需要 MB 级别内存。
而 Goroutine:
goroutine1 goroutine2 goroutine3 ... goroutine10000可以轻松创建大量数量。
创建一个 Goroutine:
go task()非常简单。
例如:
package main import ( "fmt" "time" ) func hello() { fmt.Println("hello goroutine") } func main() { go hello() time.Sleep(time.Second) }这里:
go hello()表示:
启动一个新的 Goroutine 执行 hello。
三、Goroutine 和线程有什么区别?
很多初学者会认为:
Goroutine = Go里面的线程
其实不是。
它们关系:
操作系统 ↓ 线程 Thread ↓ Go Runtime ↓ Goroutine一个程序:
Go程序 ├── 线程1 │ ├── goroutine A │ ├── goroutine B │ ├── 线程2 ├── goroutine C └── goroutine DGo Runtime 会负责:
- 创建 Goroutine;
- 调度 Goroutine;
- 分配线程。
开发者不需要关心线程管理。
这也是 Go 简化并发开发的重要原因。
四、Go 的并发调度模型 GMP
Go 的并发调度依靠 GMP 模型。
三个核心:
G:Goroutine
表示用户创建的任务。
例如:
go func(){ fmt.Println("hello") }()这个函数就是一个 G。
M:Machine
表示操作系统线程。
真正执行代码的是线程。
P:Processor
表示调度器资源。
负责:
- 管理 Goroutine;
- 分配给线程执行。
整体关系:
P G G G G G ↓ M 操作系统线程简单理解:
G 是任务,M 是执行者,P 是调度管理员。
五、并发中的核心问题:数据共享
并发最大的难点不是启动多个任务。
而是:
多个任务同时修改数据怎么办?
例如:
count := 0 go func(){ count++ }() go func(){ count++ }()两个 Goroutine 同时修改:
可能产生:
结果: 1 而不是: 2怎么办?
Go 提供两种思想:
六、方式一:共享内存 + 锁
传统方式:
多个线程共享变量。
Go 提供:
sync.Mutex例如:
var mutex sync.Mutex mutex.Lock() count++ mutex.Unlock()含义:
修改数据之前:
加锁其他 Goroutine:
等待修改完成:
释放锁这样保证安全。
七、方式二:Channel(Go推荐方式)
Go 更推荐:
不共享数据,而通过通信传递数据。
例如:
创建 channel:
ch := make(chan int)发送数据:
ch <- 10接收数据:
num := <-ch例如:
func worker(ch chan int){ ch <- 100 } func main(){ ch := make(chan int) go worker(ch) result := <-ch fmt.Println(result) }流程:
worker goroutine | | ↓ channel | | ↓ main goroutine数据通过 channel 流动。
八、为什么 Go 推崇 Channel?
因为共享内存:
多个地方修改同一个变量容易出现:
- 加锁;
- 死锁;
- 数据竞争。
而 Channel:
生产者 ↓ Channel ↓ 消费者更加符合现实世界。
类似:
工厂生产:
生产线 → 仓库 → 消费者每个人负责自己的事情。
九、并发不是越多越好
学习并发后,一个容易犯的错误:
创建越多 Goroutine,程序越快。
实际上不是。
例如:
创建:
100万个goroutine可能导致:
- 调度压力增加;
- 内存增加;
- 上下文切换增加。
并发设计需要考虑:
- CPU数量;
- 任务类型;
- 数据竞争。
合理的并发:
不是数量多。
而是:
让任务之间合理协作。
十、Go 并发带来的思考
学习 Go 并发之后,我最大的感受是:
以前学习多线程:
关注的是:
如何让多个线程同时运行?
而 Go:
关注的是:
多个任务之间如何合作完成目标。
这是一种编程思想上的变化。
并发并不是简单地把任务拆开。
真正困难的是:
- 谁负责什么?
- 数据如何流动?
- 如何避免互相干扰?
Go 通过 Goroutine 降低了创建并发任务的成本,通过 Channel 提供了一种更安全的数据交换方式。
最终实现:
用简单的代码表达复杂的并发逻辑。
这也是 Go 语言能够在服务器开发、云计算、微服务领域广泛应用的重要原因。
总结
本文学习了 Go 并发中的几个核心概念:
- 进程、线程、协程的区别;
- Goroutine 的特点;
- GMP 调度模型;
- 并发中的数据竞争问题;
- Mutex 和 Channel 的区别;
- Go 的并发设计思想。
如果说传统并发是在解决:
“如何让多个任务一起运行?”
那么 Go 给出的答案是:
“如何让多个任务更优雅地协作。”
这也是 Go 语言并发设计最吸引人的地方。