Go 语言进阶之并发编程 | 学习笔记
日期:2024 - 11 - 23
阅读时长:约 3 分钟
在深入学习 Go 语言的过程中,并发编程是其极具特色且非常重要的一部分内容。以下是我对这部分知识学习后的笔记整理,方便后续复习与进一步探索。
前言
随着软件系统的日益复杂,对程序处理多任务能力的要求也越来越高,Go 语言凭借其强大且简洁的并发编程支持,在众多应用场景中大放异彩。记录并发编程相关的知识,有助于我更好地掌握 Go 语言在高性能应用开发方面的优势。
并发与并行的深入理解
并发(Concurrency)
- 概念:多个任务在逻辑上同时执行,但在物理层面(比如单核 CPU 上),这些任务是交替执行的,通过快速的切换让我们感觉好像它们是同时在运行一样。可以类比为一个人在厨房做菜,需要同时看着锅里煮的菜、切菜以及准备调料等,这个人在不同任务间快速切换,营造出同时做几件事的效果。
- Go 语言中的体现:Go 程序可以轻松地创建多个协程(Goroutine)来实现并发执行任务,即使是在单核 CPU 的环境下,也能高效地在不同协程间切换,让程序看起来像是在同时做很多事。
并行(Parallelism)
- 概念:多个任务真正意义上在同一时刻在不同的硬件资源(多核 CPU 的不同核心)上同时执行。例如,一个有多个炉灶的厨房,不同的炉灶可以同时用来炒菜、炖汤等,各个任务并行开展,互不干扰,利用多核的优势同时推进工作。
- Go 语言中的体现:Go 语言能够充分利用多核 CPU 的优势,合理地将协程分配到不同的核心上运行,实现真正的并行计算,从而大幅提升程序的执行效率,尤其是处理计算密集型任务时效果更为显著。
协程(Goroutine)详解
协程与线程的对比
- 协程(Goroutine) :是用户态的轻量级线程,它的栈大小通常是 MB 级别,创建和销毁的开销相对较小,Go 语言自身负责协程的创建和调度工作,能轻松创建成千上万个协程同时运行。比如可以想象成一个个很轻便的小机器人,Go 语言可以快速地派出大量它们去执行各种小任务,并且灵活调配它们的工作顺序。
- 线程:处于内核态,栈大小一般是 KB 级别,线程相对较重,其创建、切换以及停止等操作会较大程度地占用系统资源。并且一个线程内部可以运行多个协程,就好像一辆大卡车(线程),可以搭载多个小包裹(协程)一样,每个小包裹都代表着一个可以独立执行的小任务。
协程的创建与使用示例
以下是一个简单的通过开启协程快速打印信息的案例:
package main
import (
"fmt"
"time"
)
func hello(i int) {
fmt.Println("hello goroutine : ", i)
}
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
// 使用go关键字创建协程
go func(j int) {
hello(j)
}(i)
}
// 让主线程等待一段时间,确保子协程有机会执行完
time.Sleep(time.Second)
}
在上述代码中,通过go
关键字就能轻松创建协程去执行hello
函数,不过要注意如果主线程过早结束,子协程可能来不及执行完,所以这里通过time.Sleep
让主线程等待一会儿。
CSP(Communicating Sequential Processes)并发模型
核心思想
CSP 并发模型强调 “以通信的方式来共享内存”,这与传统的多线程通过共享内存来通信的方式截然不同。传统共享内存通信容易出现数据竞争等复杂问题,而 CSP 模式更注重通过消息传递来协调各个协程之间的工作,就好比各个协程之间通过传递纸条(消息)来沟通协作,而不是直接去争抢同一块黑板(共享内存)上的空间。
示例体现
例如,我们可以通过通道(Channel)来实践 CSP 模型:
package main
func CalSquare() {
src := make(chan int)
dest := make(chan int, 3)
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}()
for i := range dest {
fmt.Println(i)
}
}
在这个代码中,创建了两个通道src
和dest
,一个协程往src
通道发送数据,另一个协程从src
通道接收数据并进行平方运算后再发送到dest
通道,最后从dest
通道接收数据并打印,各个协程之间通过通道有序地传递数据、协同工作,很好地体现了 CSP 模型的特点。
通道(Channel)相关知识
通道的创建与类型
- 创建方式:使用
make
函数来创建通道,格式为make(chan 元素类型, [缓冲大小])
。例如make(chan int)
创建的是无缓冲通道,make(chan int, 2)
创建的是有缓冲通道,缓冲大小为 2。 - 通道方向:操作符
<-
用于指定通道的方向,可以实现发送或者接收数据。如果未指定方向,则通道为双向通道,意味着既可以往通道里发送数据,也可以从通道接收数据。
通道的阻塞机制
- 无缓冲通道:如果通道不带缓冲,发送方会阻塞直到接收方从通道中接收了值。就好像两个人面对面传递东西,必须一方递出同时另一方接住,发送者要等接收者准备好才能继续下一步操作。
- 有缓冲通道:发送方则会阻塞直到发送的值被拷贝到缓冲区内;如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。接收方在有值可以接收之前会一直阻塞,类似于一个有容量的盒子,装满了就不能再放东西进去了,必须等别人从盒子里拿走一些东西后才能继续往里放。
并发安全相关机制
互斥锁(Mutex)解决数据竞争
- 问题场景:当多个协程同时操作同一块内存资源(比如全局变量)时,很容易出现竞态问题(数据竞态),导致数据的不一致性等错误情况。
- 互斥锁作用:互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个协程可以访问共享资源。Go 语言中使用
sync
包的Mutex
类型来实现互斥锁。以下是一个示例代码:
package main
import (
"sync"
"time"
)
var (
x int64
lock sync.Mutex
)
func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock()
x += 1
lock.Unlock()
}
}
func addWithoutLock() {
for i := 0; i < 2000; i++ {
x += 1
}
}
func Add() {
x = 0
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
fmt.Println("WithoutLock:", x)
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
fmt.Println("WithLock:", x)
}
在上述代码中,对比了使用互斥锁和不使用互斥锁对全局变量x
进行自增操作的情况,能明显看到不使用互斥锁时,由于多个协程同时修改x
,结果会出现错误,而使用互斥锁能保证每次只有一个协程能修改x
,保证数据的正确性。
等待组(WaitGroup)解决数据竞争
- 作用:Go 语言中除了可以使用通道(Channel)和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步,等待组可以保证在并发环境中完成指定数量的任务。等待组的值在内部维护着一个计数,此计数的初始默认值为零。
- 示例代码:
package main
import (
"fmt"
"sync"
)
func HelloPrint(i int) {
fmt.Println("Hello WaitGroup :", i)
}
func ManyGoWait() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done()
HelloPrint(j)
}(i)
}
wg.Wait()
}
func main() {
ManyGoWait()
}
在这个例子中,通过WaitGroup
来确保 5 个协程都执行完HelloPrint
函数后,主线程才继续往下执行,保证了整个并发任务的完整性。
小结
Go 语言的并发编程内容丰富且实用,从基础的并发、并行概念区分,到协程、CSP 模型、通道以及并发安全机制等,每一部分都相互关联,共同构建起高效且可靠的并发编程体系。