每日一Go-9、Go语言并发编程基石:Goroutine(协程)

41 阅读4分钟

Goroutine 像高速公路上成千上万辆货车一样高效运作的轻量级并发模型

1、为什么需要Goroutine(协程)?

现代程序中,尤其是多核CPU下,常常需要同时做很多事,比如:

  • 公众号服务器同时处理上万个甚至百万请求
  • 搜索引擎的爬虫程序同时抓取多个网站
  • 短视频服务器的视频转码时同时压缩多个片段
  • 吃鸡游戏服务器同时计算多个玩家的行为

在传统语言(如C++、Java)中,要实现“并发”,需要用到线程(Thread),但是线程的代价高,每个线程都需要:

  • 几MB的栈空间
  • 操作系统参与调度(频繁上下文切换)
  • 创建、销毁核切换相对耗时 当线程一多(例如上万个),程序性能就会急剧下降。

2、协程是什么?

协程是Go语言在语言层面提供的一种轻量级并发单元,它由Go runtime 运行时直接管理,而不是系统直接管理。

可以这样去理解:

“在Go世界里,协程Goroutine就像是一辆小货车,可以轻松跑在Go的高速公路(Thread线程)上执行任务”

3、举个例子🌰:高速公路运输货物

物流公司每天都要运输各种各样的货物到全国各地:

3.1、传统线程模式(每个任务一辆车+一条路)

每运输一批货物,就得:

  • 新修一条高速
  • 安装收费站和信号灯
  • 派一辆车上去跑

显而易见,这样操作的问题是:

  • 修路成本太高
  • 管理复杂
  • 一旦车多,高速就堵死了

Go的调度器更聪明:

  • 先修几条高速
  • 把上千辆车安排到这些高速上
  • 有Go的调度员(Scheduler)自动分派每辆车在哪条高速上跑
  • 如果有车慢了、堵了,调度员会把别的车安排到更宽松的车道上

有了这个操作后:

  • 能轻松让10万辆货车同时运输
  • 每辆车启动几乎不耗时

调度员会智能安排,一切就高效地并行起来了

4、协程和线程的底层比较

特性协程传统线程
栈空间初始仅约2KB,可动态增长固定1~8MB
调度者Go运行时(runtime)操作系统内核
切换成本运行时上下文切换,很快内核级切换,比较慢
数量级轻松上万个一般不超过几千个
创建方式使用go关键字调用复杂的API

这也是为什么Go在高并发服务器(HTTP服务、微服务、爬虫、分布式系统)中表现优异的原因。

5、实战时间:多辆货车并发送货

package main
import (
    "fmt"
    "time"
)
func deliverGoods(truck string) {
    for i := 1; i <= 3; i++ {
        fmt.Printf("%s 正在运送第 %d 批货物\n", truck, i)
        time.Sleep(time.Second)
    }
}
func main() {
    // 启动三辆货车并发运货
    go deliverGoods("🚚 卡车A")
    go deliverGoods("🚛 卡车B")
    go deliverGoods("🚐 卡车C")
    fmt.Println("所有货车已出发!")
    time.Sleep(5 * time.Second) // 等待所有货车运完
}

运行如图所示:

image.png

6、重点:Go的调度机制(GMP模型)

Go的并发调度器采用GMP模型,即:

  • G(Goroutine):任务单位(函数)
  • M(Machine):操作系统线程
  • P(Processor):逻辑处理器,负责调度G到M上运行 简单来说:Go程序启动后,会创建若干P(相当于高速上的车道),每个P维护一个任务队列(待运行的Goroutine),M负责从P的队列里取出并运行任务。当某一辆货车被堵,调度器(天眼)会让别的车继续跑,不浪费时间。

其实,Goroutine 的世界就像我们的人生。 每个 Goroutine 都在执行自己的任务、奔赴自己的目标,有的忙碌运货(工作),有的在等待信号(机会),有的在休眠(休息)。在宏大的系统中,我们每个人看似独立,但都被同一个“调度器”——时间——所管理。有时我们并行前进,有时我们等待他人完成;有时系统为我们分配更多资源,有时我们要让出车道。可无论如何,整个程序都因为每一个小小的 Goroutine 的努力而继续运行。

就像人生:你不需要掌控全部线程,只要让自己的那条 Goroutine 跑得稳、跑得好。