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 和一个 CancelFuncWithValue 没有 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("所有菜都好了,可以上桌")
}