目录
技术背景
定义
Go语言的协程(goroutine)是一种轻量级的线程,由Go自身的运行时(runtime)进行调度和管理。可以通过go关键字来创建一个新的协程。
历史背景
在Go语言设计之初,设计者就希望提供一种并行编程的方式,但要比线程更加轻量、简单。传统的线程存在几个问题:
- 创建线程的代价很高,需要分配较大的内存
- 线程之间上下文切换代价大,效率低下
- 多线程编程复杂,需要手动处理线程同步、死锁等问题
为了解决这些问题,Go设计了协程
创新点
协程的革新之处在于:
- 协程非常轻量,创建的代价很小,可以在程序中大量使用
- 上下文切换只需堆栈复制几个CPU寄存器的值,成本很低
- 由Go的运行时调度器统一调度,自动复用、切换协程
- 在语言层面提供了channel通信机制,规避了线程同步复杂性
因此,基于协程的并行编程模型显得高效、简单、优雅。
发展趋势
未来协程和基于协程的并行编程模型可能会继续流行,因为硬件越来越多核、并行计算需求日益增长。除Go外,其它编程语言如Python、JavaScript等也在尝试引入协程或类似的轻量级并发机制。
协程能做什么
核心优势
- 高并发:可以创建大量的协程,提高并发能力利用多核优势
- 轻量级:协程创建、切换的开销远小于线程,资源消耗少
- 简单编程:使用channel通信机制,避免了线程编程的锁困难
- 高效利用:自动复用协程,协程阻塞自动切换,提高资源利用
技术实现
- 协程利用m:n调度模型
- m个协程运行在n个系统线程(可并发的执行体)上
- 当系统线程阻塞时,协程可切换到其它空闲的系统线程上运行
- 避免了传统线程1:1的调度开销
- 上下文切换低开销
- 协程切换时,只需要保存和恢复少量CPU寄存器的值
- 相比线程切换时保存和恢复较大的运行时栈,开销远小
- 按需分配堆栈
- 每个协程根据执行需要动态分配堆栈空间
- 节约内存使用,并可自动扩容
- 基于信号的异步抢占
- 当前协程执行太长,阻塞其它协程时
- 运行时会通过异步信号抢占CPU执行其它协程
实现原理
工作流程
- 协程的创建
- 通过go关键字调用内置函数go创建新的协程
- 为新协程分配内存,包括初始的栈空间和上下文信息
- 协程的执行
- 由Go的调度器M将新创建的协程放入运行队列
- 当前空闲的执行线程P获取运行队列中的协程并执行
- 协程切换
- 当前协程阻塞或主动让出CPU时
- 调度器M将其存储上下文并切换执行另一协程
- 协程结束
- 协程运行函数结束或遇runtime.Goexit()退出
- 协程的资源被系统自动回收利用
关键概念
- G-P-M模型
- G(Goroutine)代表协程实体
- P(Processor)代表执行线程,管理着Goroutine队列
- M(Machine)代表内核线程,承载P的运行
- 调度器(Scheduler)
- 全局调度器为每个P分配待执行的G
- 每个P上挂载一个本地调度器,调度挂载在其上的G
- 上下文切换
- Go采用叫做小生产者/大消费者的模型
- 小生产者分配协程栈空间,大消费者复制栈信息
- 抢占机制(Preemption)
- 当协程长时间占用CPU时,每隔一定时间会被强制抢占
- 为其他协程运行提供机会,实现公平调度

底层实现
- 栈空间管理:自动扩展缩容,按需动态分配
- 内存分配:TC(Thread Caching)技术,复用内存
- 异常捕获:signal捕获机制,检测到异常状态做恢复
- 信号监控:管理goroutine的各种监控控制信号
- 协程调度:GOMAXPROCS参数控制,支持本地局部调度
通道(Channel)
通道(Channel)是一种特殊的类型,你可以使用它通过不同Go协程之间发送和接收具有类型的值。通道被设计为引用类型,零值为nil。通道的主要操作包括发送数据,接收数据,关闭通道
Go语言中的通道在发送和接收时都有可能阻塞。具体行为取决于通道的缓冲区状态:
- 对于无缓冲或已满的通道进行发送操作,或对空通道进行接收操作,都会导致当前Go协程阻塞。
- 对于有可用缓冲区的通道进行发送操作,或对非空的通道进行接收操作,都应该非阻塞地立即成功
通道创建
ch := make(chan type, value)
type 是通道内元素的类型。 value 是可选的,表示通道的缓冲大小。
package main
import "fmt"
// 求和函数
func sum(s []int, c chan int) {
sum := 0
for _, v := range s {
sum += v
}
c <- sum // 把 sum 发送到通道c
}
func main() {
s := []int{7, 2, 8, -9, 4, 0}
c := make(chan int) // 创建一个通道
go sum(s[:len(s)/2], c) // 启动一个Go协程,计算前半部分的和
go sum(s[len(s)/2:], c) // 启动一个Go协程,计算后半部分的和
x, y := <-c, <-c // 从通道c中接收
fmt.Println(x, y, x+y) //打印结果
}
该程序首先创建一个通道c,然后启动两个Go协程。第一个协程计算切片s的前半部分元素的和,第二个协程计算切片s的后半部分元素的和。每个Go协程计算完毕后,就将结果发送到通道c。主线程从通道c接收两个结果,并打印出来
由于使用make(chan int)创建的是无缓冲的通道,所以读取操作(<-c)会阻塞,直到有协程向通道发送数据。同时,由于每个协程向通道发送数据后就结束了,所以读完第一个结果后,主函数会继续阻塞,直到第二个协程发送了第二个结果
使用协程
使用步骤
- 导入"runtime"包,设置可使用的CPU核数
runtime.GOMAXPROCS(runtime.NumCPU())
- 使用go关键字创建协程
go func() {
// 协程函数体
}()
- 通过channel或sync包进行协程间通信和同步
// 创建channel
ch := make(chan int)
// 启动协程写入数据
go func() {
ch <- 1
}()
// 主协程从channel读取数据
data := <-ch
- 等待所有协程结束(可选)
// 创建WaitGroup
wg := new(sync.WaitGroup)
wg.Add(1) // 计数加1
go func() {
// 协程函数体
wg.Done() // 完成后计数减1
}()
wg.Wait() // 阻塞等待所有协程完成
使用协程的目的是提高程序的并发性能,充分利用多核计算资源。协程相比线程更加轻量、易于使用,能够大幅降低并发编程的复杂度,提高了开发效率。合理利用协程可以让程序在多核环境下获得极高的并行处理能力。
常见问题
- 协程泄漏
- 忘记关闭导致协程阻塞,被Go运行时长期挂起
- 可通过context包传递取消信号,及时退出
- 协程competing
- 多个协程同时读写共享变量
- 需使用互斥锁、channel、原子操作等同步机制
- 死锁
- 多个协程相互等待资源造成死锁
- 避免获取多把锁,遵循获取锁的固定次序
最佳实践及注意事项
最佳实践
- 控制协程数量
- 过多的协程会给调度器增加压力
- 如果任务是IO密集型,可创建较多协程
- 如果是CPU密集型,建议控制协程数量
- 避免共享内存
- 尽量通过channel通信替代共享内存存取
- 减少锁的使用,降低并发编程复杂度
- 使用缓冲Channel
- 合理设置缓冲区大小,避免频繁阻塞
- 减少协程切换,提高并发程序性能
- 利用多核并行
- 通过GOMAXPROCS设置可使用CPU核心数
- 配合协程并行执行任务,充分利用多核
注意事项
- channel要及时关闭
- 发送端要显式关闭,接收端检测通道关闭
- 否则会发生协程泄漏,资源永远无法被释放
- 注意协程competing
- 多协程读写共享变量时要加锁保护
- 或使用原子操作、channel通信等方式
- 注意死锁和资源泄露
- 避免多把锁嵌套加锁,固定加锁顺序
- 及时释放锁、关闭channel、减少协程等
代码示例
示例1: 基本协程使用
package main
import (
"fmt"
"sync"
)
func printHello(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("Hello")
}
func main() {
var wg sync.WaitGroup
wg.Add(2) // 计数加2
go printHello(&wg)
go printHello(&wg)
wg.Wait() // 等待两个协程结束
fmt.Println("All goroutines finished")
}
运行结果:
Hello
Hello
All goroutines finished
解析:
- 在main函数中创建一个WaitGroup变量
- 通过wg.Add(2)设置要等待的协程数量为2
- 创建两个协程,分别调用printHello函数
- 当两个协程结束后,wg.Wait()结束阻塞
- 主协程打印"All goroutines finished"
示例2: 协程通信
package main
import (
"fmt"
)
func sendData(ch chan<- int) {
ch <- 10
close(ch) // 关闭通道
}
func main() {
ch := make(chan int)
go sendData(ch)
data := <-ch
fmt.Println("Received data:", data)
// 检测通道是否已关闭
_, isOpen := <-ch
fmt.Println("Channel open?", isOpen)
}
运行结果:
Received data: 10
Channel open? false
解析:
- 在sendData协程中向ch写入数据10并关闭通道
- 主协程从ch中读取数据并打印
- 再次从ch读取数据时,会返回通道已关闭的信号
示例3: 计算密集型任务并行
package main
import (
"fmt"
"runtime"
"sync"
)
func calcSum(num int, result chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
sum := 0
for i := 0; i < num; i++ {
sum += i
}
result <- sum
}
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // 设置可使用CPU核心数
const numCalcs = 20 // 计算的任务数量
result := make(chan int, numCalcs)
var wg sync.WaitGroup
wg.Add(numCalcs)
for i := 0; i < numCalcs; i++ {
go calcSum(5000000, result, &wg)
}
wg.Wait()
close(result) // 关闭通道
total := 0
for res := range result {
total += res
}
fmt.Println("Total sum:", total)
}
解析:
- 函数calcSum执行一个计算密集型任务,求和0到num的所有数
- 主协程设置可用CPU核心数为当前机器的实际CPU核心数
- 创建20个协程,每个协程计算0到500万的和并发送结果到result通道
- 等待所有协程执行完成
- 主协程从result通道中读取所有结果,计算总和并打印
- 该示例展示了如何通过并行计算来充分利用多核CPU资源,大幅提升计算密集型任务的执行效率。
示例4: IO密集型任务并行
package main
import (
"fmt"
"sync"
)
//Job 定义我们的任务
type Job func()
//Worker 控制结构体,里面封装了并发控制需要的channel和sync.WaitGroup
type Worker struct {
JobQueue chan Job // 任务队列
wg sync.WaitGroup // 控制等待所有的任务执行完
MaxWorks int // 定义最大并发
}
//NewWorker 初始化worker
func NewWorker(maxWorker int) *Worker {
return &Worker{
JobQueue: make(chan Job, maxWorker),
MaxWorks: maxWorker,
}
}
//Start 启动worker,开始监听任务
func (w *Worker) Start() {
fmt.Println("Worker Started!")
for i := 0; i < w.MaxWorks; i++ {
go w.work()
}
}
//Wait 等待任务执行完毕
func (w *Worker) Wait() {
w.wg.Wait()
}
func (w *Worker) work() {
for job := range w.JobQueue {
job()
w.wg.Done()
}
}
//AddJob 添加job到任务队列,如果任务队列已满则阻塞
func (w *Worker) AddJob(job Job) {
w.JobQueue <- job
w.wg.Add(1)
}
func main() {
// 初始化一个最多并发为5的worker
worker := NewWorker(5)
// 启动worker
worker.Start()
// 新增任务,这里模拟添加10个任务(任务内容就是打印数字)
for i := 0; i < 10; i++ {
url := "http://example.com/resource/" + strconv.Itoa(i)
job := func() {
resp, err := http.Get(url)
...
doSomethingWithResponse(resp)
}
worker.AddJob(job)
}
// 等待所有任务执行完毕
worker.Wait()
}
解析:
- 定义了一个Worker类, 里面有一个JobQueue字段,其实是一个有限buffer的channel,用来接受所有的任务(Job)。MaxWorks字段是最大并发数,也就是将要启动多少个协程来并发的执行任务。
- Start方法会启动MaxWorks个协程来从JobQueue中获取任务并执行。当所有任务执行完毕后Worker会自动停止。
- AddJob方法用来向Worker添加Job,如果JobQueue满了则会阻塞,直到有其他协程将Job取出后才能继续添加。
- Wait方法可以用来阻塞主线程,直到所有的协程执行完毕后主线程才继续执行,防止主线程提前结束导致协程未能正常执行。
- 上述示例中,为了控制并发数,我们设定了一个有边界的channel,这样在达到最大并发数时,下一个任务将被阻塞,直到有任务完成释放了channel空间,才会继续执行。