Skip to content

概念 ​

Go 的并发模型以 goroutine 和 channel 为核心,提供了一种简洁高效的并发编程方式。

其调度器采用 GMP 模型(G — Goroutine,M — 系统线程,P — 逻辑处理器),负责将 goroutine 分配到系统线程上执行,并通过 M 与 P 的协同配合,实现高效的并发管理。

Goroutine(协程) 是 Go 中的并发执行单元,相当于轻量级的线程。它由 Go 运行时统一调度,用户无需手动管理线程分配,只需使用 go 关键字即可启动。Goroutine 具有非阻塞特性,能够轻松支撑成千上万个并发任务。

Channel(通道) 则是 goroutine 之间通信的主要机制,支持同步和数据传递,有助于避免显式使用锁。通道通过 chan 关键字创建,并使用 <- 操作符进行数据的发送与接收

Go 的并发口号是:不要通过共享内存来通信,而要通过通信来共享内存

协程(goroutine ) ​

协程使用案例:

go
import (
	"fmt"
	"time"
)

func cook(dish string) {
	for i := 1; i <= 3; i++ {
		fmt.Println("后厨正在做:", dish)
		// 模拟做菜的时间
		time.Sleep(500 * time.Millisecond)
	}
}

func main() {
	// 开启一个协程
	go cook("红烧肉")
    // 主线程
	cook("西红柿炒蛋")
}

Go 语言的程序生命周期完全由主 goroutine(即运行 main 函数的 goroutine)决定。所以,当 main 函数执行完毕(主 goroutine 退出)时,Go 程序会终止整个进程,所有尚未执行完的 goroutine 都会被强制中断,不会等待它们完成

生命周期(context) ​

当 main goroutine 下达一道命令让两个 goroutine 去负责执行。但是如果中间有需要让负责执行的两个 goroutine 立马停止会很麻烦。而 context.Context 就是一个带“取消按钮”的令牌,main goroutine 把令牌交给每一个负责执行的 goroutine,只要 main goroutine 按下按钮(调用 cancel),所有拿到这个令牌的 goroutine 都会同时收到“定制”信号

Context 不传递数据,它只传递控制信号和截止时间(以及少量请求范围的元数据)

详情 ​

Context 是一个接口,包含四个方法:

go
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

停止信号(Done() <-chan struct{}):返回一个只读的 channel,当这个 channel 被关闭时,意味着“可以停止了”

停止原因(Err() error):如果 Done 的 channel 已经被关闭,Err() 会返回一个非 nil 的错误,说明取消原因:

  • context.Canceled:主动调用 cancel
  • context.DeadlineExceeded:超时

最后时间(Deadline() (time.Time, bool)):返回 Context 的绝对截止时间,以及是否设置了截止时间

携带数据(Value(key) interface{}):可以携带请求相关的数据(如用户ID、追踪ID)。注意:仅用于传递跨 API 边界的请求范围数据,对于业务参数不要传递

根与树 ​

根节点有两个:

go
ctx := context.Background()  // 大本营,永不取消,无超时,无值
ctx := context.TODO()        // 暂时不确定用啥,先占个位

TODO 只是语义上与 Background 一样,用来提示开发者将来要替换成具体的 context

衍生出子 Context ​
  1. WithCancel:生成一个可以主动取消的令牌
  2. WithDeadline / WithTimeout:生成一个带绝对/相对超时的令牌,时间到自动取消
  3. WithValue:在令牌上写入一些参数,传给下游

这些函数都会返回一个新的 Context 和一个 CancelFunc(WithValue 没有 CancelFunc,因为它不产生取消能力)。只要父 Context 被取消,它所有的子 Context 也会被自动取消

go
ctx := context.Background()
ctxA, cancelA := context.WithCancel(ctx)
ctxB, cancelB := context.WithTimeout(ctxA, 5*time.Second) // 5秒后自动取消
ctxC := context.WithValue(ctxB, "user", "小张") // 贴个标签

上述内容一层套一层形成一棵树

  • 如果调用 cancelA(),ctxA、ctxB、ctxC 全被取消
  • 如果 5 秒内没有调用 cancelA,ctxB 的超时也会触发,同样导致 ctxB、ctxC 取消

案例:

go
func cookWithCtx(ctx context.Context, dish string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println(dish, "被取消,停止烹饪")
            return
        default:
            fmt.Println("正在做", dish)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go cookWithCtx(ctx, "红烧肉")
    go cookWithCtx(ctx, "清蒸鱼")

    time.Sleep(2 * time.Second)
    cancel() // 总厨下令取消
    time.Sleep(time.Second) // 让它们打印完毕
}

通道(Channel) ​

创建一个通道(make(chan [类型], [传送带格数])):

go
// 完全体
var ch chan string = make(chan string)
// 简写
ch := make(chan string)

通道有两种(区别为是否赋值传送带格数):

  • 无缓冲通道:传送带上一格都没有,必须同时有人放、有人取。如果传送带上还有数据没有取否,则放的人会阻塞;如果取数据的人没有取到数据,那么取的人也会阻塞。用于精确同步。
  • 有缓冲通道:传送带上有可以暂存的格子,可以放满或取空而不阻塞

无缓冲:

go
func main() {
    ch := make(chan string) // 无缓冲传送带,传递string

    go func() {
        ch <- "红烧肉已完成" // 后厨放上结果
        fmt.Println("后厨已经做完了菜")
    }()

    msg := <-ch // 主厨师取走结果
    fmt.Println("取到:", msg)
}

有缓冲(三格):

go
ch := make(chan int, 3) // 传送带可以暂存3个整数
ch <- 1
ch <- 2
ch <- 3
// 再放就会阻塞,直到有人取走

放取数据:把数据送入通道和从通道中取出数据都是使用 <- 符号,这个符号同时也是通道存满数据时阻塞的位置

遍历与关闭通道 ​

Go 通过 range 关键字来实现遍历读取到的数据,类似于与数组或切片。格式如:v, ok := <-ch。如果通道接收不到数据后 ok 就为 false,这时通道就可以使用 close() 函数来关闭通道

go
func cook(dish []string, ch chan string) {
	for _, v := range dish {
		fmt.Println("后厨正在做:", v)
		ch <- v + " 已经做好了"
	}
	// 关闭通道
	close(ch)
}

func main() {
	var ch chan string = make(chan string)
	dish := []string{"红烧肉", "西红柿炒蛋", "青椒肉丝"}
	// 开启一个协程
	go cook(dish, ch)
	// 遍历
	for v := range ch {
		fmt.Println("获取到菜品:" + v)
	}
}

上述案例中对于通道关闭有些错误,正常做法应该为:谁创建,谁关闭。避免在接收者端关闭 channel 造成 panic

Select 语句 ​

类似于一个 goroutine 同时盯着几条传送带。哪个上面有数据来了,就去取走;如果好几条同时有数据,随机选一个处理。如果都没有,goroutine 可以等待(default 则不等待),也可以设置超时

同时监听多个 channel:

go
ch1 := make(chan string)
ch2 := make(chan string)

go func() {
    time.Sleep(1 * time.Second)
    ch1 <- "红烧肉"
}()
go func() {
    time.Sleep(2 * time.Second)
    ch2 <- "清蒸鱼"
}()

for i := 0; i < 2; i++ {
    select {
    // 等待ch1有数据
    case dish := <-ch1:
        fmt.Println("取到", dish)
    // 等待ch2有数据
    case dish := <-ch2:
        fmt.Println("取到", dish)
    // 等待三秒,如果没有数据,就打印"等太久了,不等了"
    case <-time.After(3 * time.Second):
        fmt.Println("等太久了,不等了")
        return
    }
}

同步工具 ​

如果存在多个协程要操作同一个资源,则是需要加锁的

互斥锁(sync.Mutex) ​

go
import "sync"

var (
	counter int
	mu      sync.Mutex
)

func increment() {
	mu.Lock() // 上锁
	counter++ // 这段代码一次只有一个人能进入
	mu.Unlock() // 解锁
}

等待所有(sync.WaitGroup) ​

go
var wg sync.WaitGroup

func work(id int) {
    defer wg.Done() // 导报一层等待
    fmt.Printf("厨师 %d 开始工作\n", id)
    time.Sleep(time.Second)
    fmt.Printf("厨师 %d 完成工作\n", id)
}

func main() {
    for i := 1; i <= 5; i++ {
        wg.Add(1) // 增加一重等待
        go work(i)
    }
    wg.Wait() // 等所有报告完成
    fmt.Println("所有菜都好了,可以上桌")
}