5.精通go协程(一)

155 阅读12分钟

目录

  1. 技术背景
  2. 协程能做什么
  3. 实现原理
  4. 通道(Channel)
  5. 使用协程
  6. 常见问题
  7. 最佳实践及注意事项
  8. 代码示例

技术背景

定义

Go语言的协程(goroutine)是一种轻量级的线程,由Go自身的运行时(runtime)进行调度和管理。可以通过go关键字来创建一个新的协程。

历史背景

在Go语言设计之初,设计者就希望提供一种并行编程的方式,但要比线程更加轻量、简单。传统的线程存在几个问题:

  • 创建线程的代价很高,需要分配较大的内存
  • 线程之间上下文切换代价大,效率低下
  • 多线程编程复杂,需要手动处理线程同步、死锁等问题

为了解决这些问题,Go设计了协程

创新点

协程的革新之处在于:

  • 协程非常轻量,创建的代价很小,可以在程序中大量使用
  • 上下文切换只需堆栈复制几个CPU寄存器的值,成本很低
  • 由Go的运行时调度器统一调度,自动复用、切换协程
  • 在语言层面提供了channel通信机制,规避了线程同步复杂性

因此,基于协程的并行编程模型显得高效、简单、优雅。

发展趋势

未来协程和基于协程的并行编程模型可能会继续流行,因为硬件越来越多核、并行计算需求日益增长。除Go外,其它编程语言如Python、JavaScript等也在尝试引入协程或类似的轻量级并发机制。

协程能做什么

核心优势

  • 高并发:可以创建大量的协程,提高并发能力利用多核优势
  • 轻量级:协程创建、切换的开销远小于线程,资源消耗少
  • 简单编程:使用channel通信机制,避免了线程编程的锁困难
  • 高效利用:自动复用协程,协程阻塞自动切换,提高资源利用

技术实现

  1. 协程利用m:n调度模型
    • m个协程运行在n个系统线程(可并发的执行体)上
    • 当系统线程阻塞时,协程可切换到其它空闲的系统线程上运行
    • 避免了传统线程1:1的调度开销
  2. 上下文切换低开销
    • 协程切换时,只需要保存和恢复少量CPU寄存器的值
    • 相比线程切换时保存和恢复较大的运行时栈,开销远小
  3. 按需分配堆栈
    • 每个协程根据执行需要动态分配堆栈空间
    • 节约内存使用,并可自动扩容
  4. 基于信号的异步抢占
    • 当前协程执行太长,阻塞其它协程时
    • 运行时会通过异步信号抢占CPU执行其它协程

实现原理

工作流程

  1. 协程的创建
    • 通过go关键字调用内置函数go创建新的协程
    • 为新协程分配内存,包括初始的栈空间和上下文信息
  2. 协程的执行
    • 由Go的调度器M将新创建的协程放入运行队列
    • 当前空闲的执行线程P获取运行队列中的协程并执行
  3. 协程切换
    • 当前协程阻塞或主动让出CPU时
    • 调度器M将其存储上下文并切换执行另一协程
  4. 协程结束
    • 协程运行函数结束或遇runtime.Goexit()退出
    • 协程的资源被系统自动回收利用

关键概念

  • G-P-M模型
    • G(Goroutine)代表协程实体
    • P(Processor)代表执行线程,管理着Goroutine队列
    • M(Machine)代表内核线程,承载P的运行
  • 调度器(Scheduler)
    • 全局调度器为每个P分配待执行的G
    • 每个P上挂载一个本地调度器,调度挂载在其上的G
  • 上下文切换
    • Go采用叫做小生产者/大消费者的模型
    • 小生产者分配协程栈空间,大消费者复制栈信息
  • 抢占机制(Preemption)
    • 当协程长时间占用CPU时,每隔一定时间会被强制抢占
    • 为其他协程运行提供机会,实现公平调度

协程MN模型转存失败,建议直接上传图片文件

底层实现

  • 栈空间管理:自动扩展缩容,按需动态分配
  • 内存分配: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)会阻塞,直到有协程向通道发送数据。同时,由于每个协程向通道发送数据后就结束了,所以读完第一个结果后,主函数会继续阻塞,直到第二个协程发送了第二个结果

使用协程

使用步骤

  1. 导入"runtime"包,设置可使用的CPU核数
runtime.GOMAXPROCS(runtime.NumCPU())
  1. 使用go关键字创建协程
go func() {
    // 协程函数体
}()
  1. 通过channel或sync包进行协程间通信和同步
// 创建channel 
ch := make(chan int)
// 启动协程写入数据
go func() {
    ch <- 1
}()
// 主协程从channel读取数据
data := <-ch
  1. 等待所有协程结束(可选)
// 创建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空间,才会继续执行。