10 KiB
10 KiB
一、核心概念:进程、线程与协程
- 进程 (Process)
- 定义: 程序的一次执行过程,是操作系统进行资源分配的基本单位。
- 特点: 拥有独立的内存空间,创建和切换开销巨大,资源占用多。
- 线程 (Thread)
- 定义: 进程内的执行单元,是 CPU 调度的基本单位。一个进程至少包含一个主线程。
- 特点: 共享进程的内存空间,创建和切换开销相比进程小,但仍有相当成本。多线程共享数据时需要处理同步问题(如锁)。
- 协程 (Coroutine / Goroutine)
- 定义: Go 语言实现的特有并发体,是一种用户态的、极其轻量级的线程。
- 特点:
- 开销极小: 创建成本远低于线程,内存占用仅为KB级别(初始约2KB),可以轻松创建数十万甚至上百万个。
- 调度灵活: 由 Go 语言的运行时(Runtime)进行调度,而非操作系统。调度器能够在 I/O 操作等场景下自动切换,实现高效的并发。
- 非抢占式: 协程出让 CPU 控制权通常发生在特定的时机(如Channel操作、系统调用、函数调用等),而不是由操作系统强制中断。
对比总结
| 特性 | 进程 (Process) | 线程 (Thread) | 协程 (Goroutine) |
|---|---|---|---|
| 角色 | 资源分配单位 | CPU 调度单位 | Go语言的并发执行体 |
| 开销 | 巨大 | 较大 | 极小 |
| 数量级 | 百级 | 万级 | 百万级 |
| 调度方 | 操作系统 | 操作系统 | Go 运行时 (Runtime) |
| 内存 | 独立 | 共享进程内存 | 共享进程内存 |
二、Goroutine:Go 并发编程的基石
Go 语言通过 go 关键字来创建 Goroutine,实现并发执行。一个 Goroutine 就是一个与其他函数或方法同时运行的函数。
1. 启动一个 Goroutine
语法非常简单,在函数或方法调用前加上 go 关键字即可。
go myFunction() // 启动一个新的 Goroutine 来执行 myFunction
2. Goroutine 的执行特性
- 立即返回:
go语句会立即返回,不会等待新的 Goroutine 执行完毕。 - 主 Goroutine 退出:
main函数本身运行在一个特殊的 Goroutine 中,称为主 Goroutine。如果主 Goroutine 退出,整个程序就会结束,所有其他正在运行的 Goroutine 也会被强制终止。
示例:主 Goroutine 提前退出
下面的代码中,hello() 可能根本不会打印任何内容,因为主 Goroutine 在它开始执行前就已经结束了。
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("Hello from new Goroutine!")
}
func main() {
// 启动一个新的 Goroutine
go hello()
fmt.Println("main function finished.")
// 如果没有下面的休眠,程序会立刻退出,"Hello from new Goroutine!" 很可能没有机会打印
// 这是一种不推荐的做法,仅用于演示。正确的方式是使用 sync.WaitGroup。
time.Sleep(1 * time.Second)
}
3. sync.WaitGroup:等待 Goroutine 完成
为了解决主 Goroutine 提前退出的问题,我们需要一种机制来等待其他 Goroutine 执行完毕。sync.WaitGroup 是最常用的工具。
wg.Add(n):增加计数器,表示需要等待 n 个 Goroutine。wg.Done():当一个 Goroutine 完成任务时调用,使计数器减一。通常使用defer wg.Done()来确保执行。wg.Wait():阻塞主 Goroutine,直到计数器归零。
规范化代码示例:
package main
import (
"fmt"
"sync"
"time"
)
// 定义一个 WaitGroup
var wg sync.WaitGroup
func worker(id int) {
// 在函数退出时,通知 WaitGroup 任务已完成
defer wg.Done()
fmt.Printf("Worker %d starting\n", id)
// 模拟耗时操作
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
// 启动 3 个 Goroutine
for i := 1; i <= 3; i++ {
// 每启动一个 Goroutine,计数器加 1
wg.Add(1)
go worker(i)
}
fmt.Println("Main: Waiting for workers to finish...")
// 等待所有 Goroutine 完成(即等待计数器归零)
wg.Wait()
fmt.Println("Main: All workers have finished. Exiting.")
}
三、并发中的资源竞争与同步
当多个 Goroutine 同时访问和修改同一个共享资源(如一个变量)时,就会产生资源竞争 (Race Condition),这会导致程序结果不可预测,是并发编程中的常见陷阱。
1. 资源竞争问题演示(售票)
下面的代码模拟了多个售票窗口同时售票的场景,由于没有同步机制,会导致超卖或数据不一致。
规范化代码示例:
package main
import (
"fmt"
"sync"
"time"
)
var ticket = 10 // 共享资源:总票数
func saleTickets(name string, wg *sync.WaitGroup) {
defer wg.Done()
for {
// 在检查和修改票数之间,可能会发生上下文切换,导致竞争
if ticket > 0 {
// 模拟处理耗时,增加竞争发生概率
time.Sleep(10 * time.Millisecond)
fmt.Printf("%s 售出 1 张票, 剩余 %d 张\n", name, ticket-1)
ticket--
} else {
fmt.Printf("%s 发现票已售罄\n", name)
break
}
}
}
func main() {
var wg sync.WaitGroup
// 启动 4 个售票窗口
wg.Add(4)
go saleTickets("窗口1", &wg)
go saleTickets("窗口2", &wg)
go saleTickets("窗口3", &wg)
go saleTickets("窗口4", &wg)
wg.Wait()
fmt.Println("所有售票窗口关闭。")
}
运行上述代码,你会看到类似 剩余 -1 张 的错误结果,这就是资源竞争的后果。
2. 解决方案一:sync.Mutex 互斥锁
互斥锁(Mutex)是一种保护**临界区(Critical Section)**的常用工具,它确保同一时间只有一个 Goroutine 可以访问被保护的共享资源。
mutex.Lock(): 获取锁。如果锁已被其他 Goroutine 持有,则当前 Goroutine 会阻塞,直到可以获取锁。mutex.Unlock(): 释放锁,允许其他等待的 Goroutine 获取。
规范化代码示例(修复后的售票问题):
package main
import (
"fmt"
"sync"
"time"
)
var ticket = 10 // 共享资源
var mutex sync.Mutex // 互斥锁
var wg sync.WaitGroup
func saleTicketsWithLock(name string) {
defer wg.Done()
for {
// 在访问共享资源前加锁
mutex.Lock()
if ticket > 0 {
time.Sleep(10 * time.Millisecond)
fmt.Printf("%s 售出 1 张票, 剩余 %d 张\n", name, ticket-1)
ticket--
// 操作完成后解锁
mutex.Unlock()
} else {
// 如果票已售完,也要解锁,否则会造成死锁,其他Goroutine永远无法进入
mutex.Unlock()
fmt.Printf("%s 发现票已售罄\n", name)
break
}
}
}
func main() {
// 启动 4 个售票窗口
wg.Add(4)
go saleTicketsWithLock("窗口1")
go saleTicketsWithLock("窗口2")
go saleTicketsWithLock("窗口3")
go saleTicketsWithLock("窗口4")
wg.Wait()
fmt.Println("所有售票窗口关闭。")
}
3. Go 的哲学:通过通信共享内存
您笔记中提到了一句非常重要的话:"不要以共享内存的方式去通信,而要以通信的方式去共享内存。" 这是 Go 并发设计的核心哲学。
- 共享内存通信: 使用锁 (
sync.Mutex) 保护共享变量,是传统的多线程编程模型。 - 通信共享内存: 使用 Channel 在 Goroutine 之间传递数据(或数据的所有权)。数据在同一时间只被一个 Goroutine 持有,从而天然地避免了资源竞争。这是 Go 更推崇的方式。
示例:使用 Channel 控制并发任务
虽然 Channel 也能解决售票问题(实现起来更复杂),但它更常用于生产者-消费者模型。下面是一个简单的例子,展示其基本用法:
package main
import (
"fmt"
"time"
)
// jobs channel 用于发送工作,results channel 用于接收结果
func worker(id int, jobs <-chan int, results chan<- int) {
// 从 jobs channel 接收任务
for j := range jobs {
fmt.Printf("Worker %d started job %d\n", id, j)
time.Sleep(time.Second) // 模拟耗时
fmt.Printf("Worker %d finished job %d\n", id, j)
// 将结果发送到 results channel
results <- j * 2
}
}
func main() {
const numJobs = 5
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// 启动 3 个 worker Goroutine
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 发送 5 个任务到 jobs channel
for j := 1; j <= numJobs; j++ {
jobs <- j
}
// 关闭 jobs channel,表示没有更多任务了
// worker 的 for-range 循环会因此而结束
close(jobs)
// 从 results channel 收集所有结果
for a := 1; a <= numJobs; a++ {
<-results
}
fmt.Println("All jobs are done.")
}
四、runtime 包:与 Go 调度器交互
runtime 包提供了与 Go 运行时系统交互的能力,例如控制调度器或获取系统信息。
runtime.NumCPU(): 返回当前系统的 CPU 核心数。runtime.GOMAXPROCS(n): [补充知识点] 设置可同时执行 Go 代码的 CPU 最大数量。自 Go 1.5 起,默认值就是runtime.NumCPU(),通常无需手动设置。runtime.Gosched(): 让出当前 Goroutine 的执行权限,让调度器去执行其他 Goroutine。当前 Goroutine 会被放回队列,未来会恢复执行。runtime.Goexit(): 立即终止当前的 Goroutine,但不会影响其他 Goroutine。defer语句在该 Goroutine 中仍会被执行。
规范化代码示例:
package main
import (
"fmt"
"runtime"
"sync"
)
func exitDemo() {
defer fmt.Println("Goroutine A: defer is executed")
fmt.Println("Goroutine A: about to exit.")
runtime.Goexit() // 终止当前 Goroutine
fmt.Println("Goroutine A: this line will never be reached.")
}
func main() {
var wg sync.WaitGroup
// --- NumCPU ---
fmt.Printf("System CPU count: %d\n", runtime.NumCPU())
// --- Goexit demo ---
wg.Add(1)
go func() {
defer wg.Done()
exitDemo()
}()
// --- Gosched demo ---
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 2; i++ {
fmt.Println("Goroutine B:", i)
// 让出 CPU,让 main Goroutine 有机会执行
runtime.Gosched()
}
}()
// Main Goroutine 的工作
for i := 0; i < 2; i++ {
fmt.Println("Main Goroutine:", i)
runtime.Gosched()
}
wg.Wait()
fmt.Println("Main Goroutine finished.")
}