第六章:并发编程—Go的杀手锏
上一章:《第五章:Go的“面向对象”编程》
文章目录
- 1.核心概念:从Java线程到Go协程
- 2.Goroutine:轻量级线程
- 3.Channel:Goroutine间的通信管道
- 3.1 Channel的创建与使用
- 3.2 Channel方向
- 3.3 多个Goroutine通信
- 3.4 Channel的关闭和检测
- 3.5 生产者-消费者模式
- 3.6 Channel超时处理
- 4.同步原语:sync包
- 4.1 sync.WaitGroup:等待一组Goroutine完成
- 4.2 sync.Mutex:互斥锁
- Demo 1: 无锁场景(竞态条件示例)
- Demo 2: 基础互斥锁使用(解决竞态条件)
- Demo 3: 互斥锁常见注意事项(避免死锁)
- 5.实战模式:常用并发模式
- 6.核心哲学:通过通信来共享内存
- 7.总结
1.核心概念:从Java线程到Go协程
在深入之前,让我们先理解基本概念和差异:
特性 | Java (传统线程模型) | Go (CSP并发模型) |
---|---|---|
基本单位 | 线程(Thread),内核态,重量级(~1MB+) | 协程(Goroutine),用户态,极轻量(~2KB) |
创建成本 | 高,数量受限(千级别) | 极低,可轻松创建百万个 |
通信方式 | 基于共享内存,需通过锁(synchronized, Lock)同步 | 基于消息传递,通过 Channel 通信 |
调度方式 | 操作系统内核调度,上下文切换成本高 | Go 运行时(runtime)自身调度,上下文切换成本极低 |
哲学 | 通过共享内存来通信 | 通过通信来共享内存 |
虽然Java21之后默认支持虚拟线程,可以轻松的创建和销毁,但两者之间还是有很多性能的差异
对比维度 | Go (CSP并发模型) | Java (虚拟线程) | 关键差异 |
---|---|---|---|
核心理念 | 通信顺序进程(Communicating Sequential Processes) “不要通过共享内存来通信,而应该通过通信来共享内存” | 基于线程的轻量级并发 延续传统线程模型,但降低开销 | Go强调“通过通信共享内存”,Java仍基于“共享内存 + 同步” |
并发单位 | Goroutine(轻量级协程) 由Go运行时管理,栈初始仅2KB,可动态增长 | 虚拟线程(Virtual Threads) 由JVM管理,栈大小固定但开销远低于平台线程 | 两者都是轻量级,但Go的Goroutine更轻(栈更小,调度更激进) |
创建成本 | 极低go func() 可轻松创建数万Goroutine | 低(相比平台线程) 但仍高于Goroutine,受限于JVM内存管理 | Go创建成本更低,更适合高并发短任务(如Web服务器) |
调度机制 | M:N 调度(G-P-M模型) Goroutine由Go运行时在少量OS线程上多路复用 | 平台线程 + 虚拟线程协作 虚拟线程绑定到平台线程执行(Fork-Join池) | Go是用户态完全调度,Java是平台线程托管执行 |
通信方式 | 通道(Channel) 通过 chan 进行同步/异步通信,支持 select 多路复用 | 共享内存 + 同步原语 使用 volatile 、synchronized 、Lock 、Atomic 等 | Go用通道通信,Java用锁和原子操作同步 |
错误处理 | panic / recover 机制Goroutine崩溃可被捕获,不直接导致整个程序退出 | 异常传播 虚拟线程中的异常需显式处理或通过 Thread.UncaughtExceptionHandler | Go的 recover 可局部恢复,Java异常需外部捕获 |
阻塞处理 | 非阻塞I/O自动调度 当Goroutine阻塞(如网络I/O),Go运行时自动调度其他Goroutine | 需显式使用 Structured Concurrency 或 try-with-resources 阻塞时平台线程被占用 | Go对阻塞更友好,Java需注意平台线程饥饿 |
编程范式 | 基于通道的数据流编程 典型模式:生产者-消费者、扇入扇出 | 基于线程的任务并行 典型模式: ExecutorService 、ForkJoinPool | Go更偏向函数式/响应式风格,Java更偏向命令式/任务化 |
调试与监控 | 支持 pprof 分析Goroutine泄漏可通过 runtime.NumGoroutine() 监控 | 可通过 jstack 、JFR 监控虚拟线程支持 Thread.startVirtual() 创建 | Java工具链更成熟,Go更简洁 |
典型场景 | 高并发网络服务(如API网关、微服务) 大量短生命周期任务 | 传统企业应用迁移 | |
高吞吐I/O密集型应用(如数据库连接池) | Go适合云原生新项目,Java适合遗留系统升级 |
两者并非互斥,而是不同哲学下的演进路径:Go从一开始就为并发设计,Java则在传统模型上做轻量化升级。
- 哲学差异
Go:“通过通信共享内存”
并发单元(Goroutine)通过通道(Channel)传递数据,避免共享状态,天然减少竞态条件。
Java:“共享内存 + 显式同步”
虚拟线程仍共享堆内存,需通过锁、原子操作等机制保护共享状态。 - 调度粒度
Go:M:N 调度,Go运行时可在单个OS线程上调度成千上万个Goroutine,切换开销极小。
Java:1:1 或 M:1 调度,虚拟线程最终仍绑定到平台线程执行,若大量阻塞I/O可能导致平台线程饥饿。 - 编程复杂度
Go:通道和select让并发逻辑清晰,但需学习新范式。
Java:对熟悉传统线程的开发者更友好,但需小心处理死锁、活锁等问题。 - 生态系统
Go:标准库内置context、sync、channel,生态统一。
Java:虚拟线程是Project Loom的一部分,需Java 19+,且需与现有ExecutorService等兼容。
思维转换:在Go中,你不再需要小心翼翼地管理线程池和纠结于复杂的锁机制。你可以像创建普通变量一样创建成千上万个并发任务,让Go运行时去高效地调度它们。
2.Goroutine:轻量级线程
Goroutine是Go并发模型的基本执行单元。它由Go运行时管理,而不是操作系统。
如何启动一个Goroutine?
只需在函数调用前加上关键字 go即可。
package mainimport ("fmt""time"
)func say(s string) {for i := 0; i < 5; i++ {time.Sleep(100 * time.Millisecond)fmt.Printf("%s %d\n", s, i+1) // 修复:使用Printf格式化输出}
}func main() {//同步调用say("同步调用")//异步调用go say("异步调用1")go say("异步调用2")time.Sleep(1000 * time.Millisecond)fmt.Println("main函数结束")}
与Java对比:
// Java中实现类似功能需要创建Thread或使用线程池
new Thread(() -> {for (int i = 0; i < 3; i++) {try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }System.out.println("异步:World");}
}).start();
3.Channel:Goroutine间的通信管道
Channel是连接并发Goroutine的管道,你可以通过它发送和接收值。这是Go实现“通过通信来共享内存”的核心。
3.1 Channel的创建与使用
Channel使用make创建,并通过<-操作符进行发送和接收。
func main() {// 创建一个字符串类型的Channelmessages := make(chan string)// 启动一个Goroutine,向Channel发送消息go func() {time.Sleep(1 * time.Second)messages <- "ping" // 发送消息"ping"到Channel}()// 从Channel接收消息(这会阻塞,直到有消息到来)msg := <-messagesfmt.Println(msg) // 输出: ping// 默认发送和接收是阻塞的,这使得同步可以在没有锁的情况下完成
}
有缓冲 vs 无缓冲 Channel
类型 | 行为 | 使用场景 |
---|---|---|
无缓冲Channel make(chan T) | 发送和接收必须同时准备好,否则阻塞。是同步的。 | 用于强同步的Goroutine间通信 |
有缓冲Channel make(chan T, size) | 缓冲区满时发送阻塞,缓冲区空时接收阻塞。是异步的。 | 用于生产者和消费者模型,解耦并发任务 |
ackage mainimport ("fmt"
)// ========== Channel基础概念 ==========func basicChannelDemo() {fmt.Println("=== 基础Channel演示 ===")// 创建一个int类型的channelch := make(chan int)// 启动goroutine发送数据go func() {fmt.Println("发送数据: 42")ch <- 42 // 发送数据到channelfmt.Println("数据已发送")}()// 主goroutine接收数据fmt.Println("等待接收数据...")value := <-ch // 从channel接收数据fmt.Printf("接收到数据: %d\n", value)
}// ========== 带缓冲的Channel ==========func bufferedChannelDemo() {fmt.Println("\n=== 带缓冲Channel演示 ===")// 创建缓冲大小为3的channelch := make(chan string, 3)// 发送数据(不会阻塞,因为有缓冲区)ch <- "Apple"ch <- "Banana"ch <- "Cherry"fmt.Printf("Channel长度: %d, 容量: %d\n", len(ch), cap(ch))// 接收数据for i := 0; i < 3; i++ {fruit := <-chfmt.Printf("接收到: %s\n", fruit)}
}
3.2 Channel方向
// 只能发送的channel
func sender(ch chan<- int, start int) {for i := start; i < start+5; i++ {fmt.Printf("发送: %d\n", i)ch <- itime.Sleep(100 * time.Millisecond)}close(ch) // 关闭channel
}// 只能接收的channel
func receiver(ch <-chan int, name string) {for value := range ch { // 使用range遍历channelfmt.Printf("%s 接收到: %d\n", name, value)}fmt.Printf("%s 完成接收\n", name)
}func main() {fmt.Println("\n=== Channel方向演示 ===")ch := make(chan int, 2)go sender(ch, 1)go receiver(ch, "Receiver-1")time.Sleep(1 * time.Second)
}
上面这段代码是一个经典的生产者-消费者模式,这是Go并发编程中最基础、最常用的设计模式之一。这种模式在实际应用中非常广泛,例如:
- 任务队列:多个生产者(如不同的Goroutine)向队列发送任务,而消费者(如一个Goroutine)从队列中取出任务并处理。
- 数据管道:如HTTP服务器处理请求时,将请求数据通过Channel传递给处理函数。
- 并发计算:如MapReduce模型,将数据分布到多个Goroutine处理,最后将结果合并。
代码特点
- 单向channel:sender使用chan<- int(只发送),receiver使用<-chan int(只接收),这是一种良好的实践
- 带缓冲的channel:make(chan int, 2)创建了容量为2的channel
- channel关闭:sender在发送完所有数据后关闭channel,使receiver的range循环能正常结束
- 生产者-消费者模型:sender是生产者,receiver是消费者
下面继续针对Go的Channel进行补充说明:
在Go中,receiver(接收者)只有在sender(发送者)发送了数据后才会被触发。这是Go Channel工作原理的核心特性。
在上面的这段代码中:
ch := make(chan int, 2) // 创建一个容量为2的带缓冲Channel
go sender(ch, 1, 5) // 启动sender goroutine
go receiver(ch, "Receiver-1") // 启动receiver goroutine
具体工作流程如下:
- channel初始化:创建了一个容量为2的带缓冲channel
- make(chan int, 2):可以存储2个int值
- goroutine启动:
- go sender(…):启动sender goroutine,它会开始向channel发送数据
- go receiver(…):启动receiver goroutine,它会等待从channel接收数据
- channel工作原理:
- 当receiver开始执行for value := range ch时,它会尝试从channel接收数据
- 如果channel中没有数据,receiver会阻塞(等待),直到有数据可接收
- sender会先发送数据到channel(直到缓冲区满或接收方准备好)
- 当sender发送了第一个数据时,receiver就会开始接收数据
这种"等待数据到达"的设计是Go并发编程的核心优势,它:
- 避免了轮询(busy waiting)
- 保证了数据同步
- 简化了并发控制逻辑
3.3 多个Goroutine通信
func worker(id int, jobs <-chan int, results chan<- int) {for job := range jobs {fmt.Printf("Worker %d 开始处理 job %d\n", id, job)time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)results <- job * 2 // 处理结果fmt.Printf("Worker %d 完成处理 job %d\n", id, job)}
}func main() {fmt.Println("\n=== Worker Pool演示 ===")const numJobs = 5const numWorkers = 3jobs := make(chan int, numJobs)results := make(chan int, numJobs)// 启动workersfor w := 1; w <= numWorkers; w++ {go worker(w, jobs, results)}// 发送jobsfor j := 1; j <= numJobs; j++ {jobs <- j}close(jobs)// 收集结果for a := 1; a <= numJobs; a++ {result := <-resultsfmt.Printf("结果: %d\n", result)}
}
这段代码是一个典型的Worker Pool(工作池)模式的实现,用于并发处理任务。让我们一起详细分析一下:
work函数做的事情:
- worker函数接收三个参数:
- id: worker的标识
- jobs: 任务通道(只读),从这个通道中接收任务
- results: 结果通道(只写),将处理结果发送到这个通道
- 函数内部使用for job := range jobs循环,等待从jobs通道接收任务
- 每个任务处理时会随机休眠一段时间(模拟处理时间)
- 处理完成后将结果(job * 2)发送到results通道
main方法里做的事情则是:
- 启动3个worker goroutine
- 将5个任务(1-5)发送到jobs通道
- 关闭jobs通道(表示任务已全部发送完毕)
- 从results通道接收并打印5个结果
读到此处,如果之前是从事Java的同学不难发现这个和我们经典的ThreadPoolExecutors设计有着异曲同工之妙,但是却更加的轻量和优雅
工作池模式的核心思想
- 固定数量的worker:预先创建一定数量的worker goroutine,而不是为每个任务都创建一个新的goroutine
- 任务队列:任务通过channel发送到任务队列,worker从队列中取任务处理
- 结果返回:worker处理完成后将结果发送到结果通道
了解其中的大致流程之后,我们可以还注意到运行结果出现了死锁,原因是:未正确关闭结果通道(results),这将导致程序在接收完所有结果后永久阻塞,无法正常退出
在Go中,for range循环必须在通道被关闭后才会结束。代码中:
- 创建了results通道(容量为jobNums=5)
- 但从未关闭这个通道
- 当所有5个结果被接收后,for range循环会继续等待(因为通道未关闭),导致程序永久阻塞
解决方式也很简单,我们只需要控制通道结束时进行通知 ,待所有的woker完成任务之后关闭通道,然后打印结果即可
package mainimport ("fmt""math/rand""sync""time"
)func worker(id int, jobs <-chan int, results chan<- int) {for job := range jobs {fmt.Printf("worker %d start job %d\n", id, job)time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)results <- job * 2fmt.Printf("worker %d finish job %d\n", id, job)}
}
func main() {const jobNums = 5const workerNums = 3//创建jobs和worksjobs := make(chan int, jobNums)results := make(chan int, jobNums)//启动workerswg := sync.WaitGroup{}wg.Add(workerNums)for i := 0; i < workerNums; i++ {go func(id int) {defer wg.Done()worker(id, jobs, results)}(i)}//发送任务for i := 0; i < jobNums; i++ {jobs <- i}close(jobs)// 等待所有worker完成wg.Wait()close(results) // 关键:关闭结果通道,让range循环正常退出//收集结果for result := range results {fmt.Printf("结果:%d\n", result)}
}
里面引入了一个新的角色:sync包,这便是我们下一节讲述的重点,但是后面针对Channel的演示demo,会大量的出现sync的同步等待逻辑,所以建议大家先去阅读一下第4节:同步原语:sync包之后在来学习3.3 3.4以及3.5的内容
3.4 Channel的关闭和检测
是Go并发编程中我们一定要记住,通道关闭是显式操作,不是自动的,这个是书写时会经常遇到得非常典型的错误
package mainimport ("fmt"
)func main() {ch := make(chan int, 3)// 发送一些数据然后关闭go func() {for i := 1; i <= 3; i++ {ch <- i}close(ch)fmt.Println("Channel已关闭")}()// 接收数据并检测channel是否关闭for {value, ok := <-chif !ok {fmt.Println("Channel已关闭,退出接收")break}fmt.Printf("接收到: %d\n", value)}// 使用range自动处理关闭的channelfmt.Println("\n使用range接收:")ch2 := make(chan string, 2)go func() {ch2 <- "Hello"ch2 <- "World"close(ch2)}()for msg := range ch2 {fmt.Printf("Range接收: %s\n", msg)}
}
3.5 生产者-消费者模式
package mainimport ("fmt""math/rand""sync""time"
)func producer(ch chan<- int, wg *sync.WaitGroup, id int) {defer wg.Done()for i := 1; i <= 5; i++ {product := rand.Intn(100)fmt.Printf("Producer %d 生产: %d\n", id, product)ch <- producttime.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)}
}func consumer(ch <-chan int, wg *sync.WaitGroup, id int) {defer wg.Done()for product := range ch {fmt.Printf("Consumer %d 消费: %d\n", id, product)time.Sleep(time.Duration(rand.Intn(300)) * time.Millisecond)}
}func producerConsumerDemo() {fmt.Println("\n=== 生产者-消费者演示 ===")ch := make(chan int, 10) // 缓冲区大小为10var wg sync.WaitGroupvar wg2 sync.WaitGroup// 启动生产者wg.Add(2)go producer(ch, &wg, 1)go producer(ch, &wg, 2)// 启动消费者wg2.Add(2)go consumer(ch, &wg2, 1)go consumer(ch, &wg2, 2)// 等待生产者完成wg.Wait()// 关闭channel,通知消费者结束close(ch)// 等待消费者完成wg2.Wait()fmt.Println("生产者-消费者演示完成")
}func main() {producerConsumerDemo()
}
执行流程如下:
- 创建带缓冲的channel
- 启动生产者,添加到WaitGroup
-
- 启动消费者,添加到WaitGroup2
- 等待所有生产者完成(wg.Wait())
- 关闭channel(close(ch))
- 等待所有消费者完成(wg2.Wait())
- 程序安全退出
上面的逻辑是有生产者产生的数据会立即被消费者消费,但是发送速度 和消费速度不一致,如果发送过快或者消费过慢都会导致队列(缓冲区)被打满后,发送操作会阻塞,直到Channel中有空间可用为止,这个和我们消息队列的批量发送和消费逻辑有些相似,当消费速度远低于发送度时就会产生大量的消息挤压,但是如果使用传统的Java实现会比较复杂和臃肿,在Go你可以很容易的就能够平衡发送和消费之间的速度差异过大的问题
3.6 Channel超时处理
package mainimport ("fmt""sync""time"
)func send(ch chan<- string) {time.Sleep(5 * time.Second)ch <- "数据终于来了"close(ch)
}func consumer(ch <-chan string, wg *sync.WaitGroup) {defer wg.Done()// 使用select实现超时select {case msg := <-ch:fmt.Printf("接收到: %s\n", msg)case <-time.After(1 * time.Second):fmt.Println("超时了,放弃等待")}
}func timeoutDemo(wgConsumer *sync.WaitGroup) {fmt.Println("\n=== Channel超时演示 ===")ch := make(chan string)// 启动一个可能很慢的goroutinego send(ch)go consumer(ch, wgConsumer)
}
func main() {var wg sync.WaitGroupwg.Add(1)timeoutDemo(&wg)// 等待goroutine完成wg.Wait()
}
4.同步原语:sync包
4.1 sync.WaitGroup:等待一组Goroutine完成
这是Channel之外最常用的同步工具,用于替代Java中的CountDownLatch。
读到此处,无论你是否对Java的CountDownLatch有了解,我这儿都简单给大家介绍一下
- CountDownLatch:一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;
与之相对的是CyclicBarrier
- CyclicBarrier:多个线程互相等待,直到到达同一个同步点,再继续一起执行。
举个例子帮助大家理解一下:
- CountDownLatch:假设老师跟同学约定周末在公园门口集合,等人齐了再发门票。那么,发门票(这个主线程),需要等各位同学都到齐(多个其他线程都完成),才能执行。
- CyclicBarrier:多名短跑运动员要开始田径比赛,只有等所有运动员准备好,裁判才会鸣枪开始,这时候所有的运动员才会疾步如飞。
下面继续回到本节的主角:sync包
package mainimport ("fmt""sync""time"
)func worker(id int, wg *sync.WaitGroup) {defer wg.Done() // 通知WaitGroup,当前Goroutine完成fmt.Printf("Worker %d 开始工作\n", id)time.Sleep(time.Second) // 模拟工作耗时fmt.Printf("Worker %d 工作完成\n", id)
}func main() {var wg sync.WaitGroup // 创建WaitGroupfor i := 1; i <= 5; i++ {wg.Add(1) // 每启动一个Goroutine,计数器+1go worker(i, &wg) // 启动Goroutine}wg.Wait() // 等待所有Goroutine完成fmt.Println("All workers finished")
}
4.2 sync.Mutex:互斥锁
当需要在多个Goroutine中安全地访问共享资源时,可以使用互斥锁。
package mainimport ("fmt""sync"
)type SafeCounter struct {mu sync.Mutexv map[string]int
}// 使用锁保证增加操作是原子性的
func (c *SafeCounter) Inc(key string) {c.mu.Lock()defer c.mu.Unlock() // 使用defer确保锁一定会被释放c.v[key]++
}func (c *SafeCounter) Value(key string) int {c.mu.Lock()defer c.mu.Unlock()return c.v[key]
}func main() {c := SafeCounter{v: make(map[string]int)}var wg sync.WaitGroupfor i := 0; i < 1000; i++ {wg.Add(1)go func() {defer wg.Done()c.Inc("somekey")}()}wg.Wait()fmt.Println(c.Value("somekey")) // 输出: 1000
}
是不是很像我们Java中的RetreentLock的lock和unLock
下面根据几个demo深入了解一下
Demo 1: 无锁场景(竞态条件示例)
当多个 goroutine 并发修改共享变量时,会出现竞态条件(结果小于预期):
package mainimport ("fmt""sync"
)func incr(count *int, iterations int) {for i := 0; i < iterations; i++ {*count++}
}func doIncrProcess(count *int, iterations int, wg *sync.WaitGroup) {defer wg.Done()incr(count, iterations)
}
func main() {//共享变量countcount := 0//用于等待所有goroutine完成var wg sync.WaitGroup// 5个goroutinenumGoroutine := 500// 每个goroutine执行1000次自增iterations := 1000// 注册等待的goroutine数量wg.Add(numGoroutine)for i := 0; i < numGoroutine; i++ {go doIncrProcess(&count, iterations, &wg)}wg.Wait()// 预期结果应为 500*1000=500000,但实际结果可能会小于500000fmt.Printf("无锁场景:预期=%d, 实际=%d\n",numGoroutine*iterations, count)
}
Demo 2: 基础互斥锁使用(解决竞态条件)
通过 sync.Mutex 保证同一时间只有一个 goroutine 能修改共享变量:
package mainimport ("fmt""sync"
)func incr(count *int, iterations int, mu *sync.Mutex) {for i := 0; i < iterations; i++ {mu.Lock() // 加锁:独占访问共享资源*count++ // 临界区:安全修改共享变量mu.Unlock() // 解锁:释放资源供其他goroutine使用}
}func doIncrProcess(count *int, iterations int, mu *sync.Mutex, wg *sync.WaitGroup) {defer wg.Done()for i := 0; i < iterations; i++ {mu.Lock() // 加锁:独占访问共享资源*count++ // 临界区:安全修改共享变量mu.Unlock() // 解锁:释放资源供其他goroutine使用}
}
func main() {counter := 0var mu sync.Mutex // 声明互斥锁var wg sync.WaitGroupnumGoroutines := 500iterations := 1000wg.Add(numGoroutines)for i := 0; i < numGoroutines; i++ {doIncrProcess(&counter, iterations, &mu, &wg)}wg.Wait()// 互斥锁保证了每次只有一个goroutine修改counter,结果必定等于预期fmt.Printf("有锁场景:预期=%d, 实际=%d\n", numGoroutines*iterations, counter)
}
Demo 3: 互斥锁常见注意事项(避免死锁)
sync.Mutex 必须严格遵循 “加锁后必须解锁” 的原则,否则会导致死锁。以下是错误示例和正确用法对比:
- 错误示例(死锁风险):
// 错误:在循环中使用defer导致解锁延迟,造成死锁
go func() {defer wg.Done()for j := 0; j < iterations; j++ {mu.Lock()defer mu.Unlock() // 问题:defer会在goroutine结束时才执行,导致锁无法释放counter++}
}()
- 正确示例(安全解锁):
// 正确:每次加锁后立即解锁,避免死锁
go func() {defer wg.Done()for j := 0; j < iterations; j++ {mu.Lock()counter++mu.Unlock() // 关键:在同一循环迭代中完成解锁}
}()
核心总结:
- sync.Mutex 通过 Lock() 和 Unlock() 实现互斥访问,确保共享资源在同一时间只能被一个 goroutine 修改
- 无锁并发修改共享变量会导致竞态条件(数据不一致)
- 必须严格保证 Lock() 和 Unlock() 成对出现,避免死锁(尤其注意循环和函数返回场景)
- 可通过 go run -race main.go 命令检测代码中的竞态条件
通过以上示例,可以直观看到 sync.Mutex 如何解决并发安全问题,以及使用时的关键注意事项。
5.实战模式:常用并发模式
注意,本章的实战已经在第三小结里已经体现,大家可以自行通过
- Worker Pool(工作池)
- 使用有缓冲Channel创建一组Worker(Goroutine)来处理任务,这是控制并发度的经典模式。
- 生产者-消费者模式
这两个实现在检验一下
6.核心哲学:通过通信来共享内存
让我们通过一个例子来体会Go与Java在并发哲学上的根本区别。
任务:多个Goroutine并发地对一个计数器进行增加操作,并保证线程安全。
Java方式(共享内存+锁):
public class Counter {private int value = 0;private final Object lock = new Object();public void increment() {synchronized(lock) {value++;}}public int getValue() {synchronized(lock) {return value;}}
}
// 多个线程共享同一个Counter实例,通过锁来同步访问。
Go方式(通信共享内存):
type Counter struct {// 计数器值不再是直接共享的字段incCh chan int // 用于发送增加命令的ChannelgetCh chan int // 用于获取值的Channel
}func NewCounter() *Counter {c := &Counter{incCh: make(chan int),getCh: make(chan int),}go c.run() // 启动一个管理的Goroutinereturn c
}// 管理的Goroutine独占访问计数器值
func (c *Counter) run() {var value intfor {select {case <-c.incCh: // 接收到增加命令value++case c.getCh <- value: // 接收到获取值命令// 值已发送}}
}func (c *Counter) Increment() {c.incCh <- 1 // 发送增加命令
}func (c *Counter) GetValue() int {return <-c.getCh // 发送获取命令并等待结果
}func main() {counter := NewCounter()var wg sync.WaitGroupfor i := 0; i < 1000; i++ {wg.Add(1)go func() {defer wg.Done()counter.Increment()}()}wg.Wait()fmt.Println(counter.GetValue()) // 输出: 1000
}
在Go的方式中:
- 计数器的真实值value被封装在管理的Goroutine中,没有其他Goroutine能直接访问它。
- 其他Goroutine只能通过向特定的Channel发送命令来间接操作计数器。
- 所有对共享资源的访问都序列化在了管理的Goroutine中,根本不需要使用锁。
这就是“通过通信来共享内存”的精髓:将需要共享的数据限制在单个Goroutine中,通过Channel来传递操作指令和结果,从而避免数据竞争。
7.总结
通过本章,你已经掌握了Go并发编程的核心:
- Goroutine:极轻量的并发执行体,创建简单,成本极低。
- Channel:Goroutine间的通信管道,是同步和数据传递的首选方式。
- 同步工具:sync.WaitGroup用于等待一组任务完成,sync.Mutex用于保护共享资源的底层访问。
- 并发模式:掌握了Worker Pool和生产者-消费者等常用模式。
- 思维转换:深刻理解了“通过通信来共享内存”的哲学,并知道如何用它来编写更安全、更清晰的并发代码。
Go的并发模型让你从繁琐的线程管理和复杂的锁机制中解放出来,让你能够更专注于业务逻辑本身。这是Go在现代高并发场景下如此强大的根本原因。