我们通过一个简单的例子看一下Goroutine的使用
func main() {
go func() {
fmt.Println("Goroutine started")
// do some work
fmt.Println("Goroutine finished")
}()
// wait for Goroutine to finish
time.Sleep(time.Second)
fmt.Println("Program finished")
}
这里我们通过 time.Sleep
来等待协程执行结束在这个例子里面是没问题的,因为我们只是模拟了操作,并没有在 Goroutine
做任何费时的动作,但是一旦我们的操作超过1s,这个时候就会因为主程序结束而导致 Goroutine
没有正常执行完,我们希望等到执行完成再告诉我们的程序可以结束了。
这种情况可以在 Goroutine 的外部创建一个 channel,在 Goroutine 内部向 channel 中发送信号,当外部程序读取到该信号时,便可以退出 Goroutine 的执行。
退出控制
可以使用 chan struct{}
或 chan bool
这样的 channel,然后在 Goroutine 函数中通过向 channel 发送消息来通知外部程序退出。
func worker(done chan bool) {
fmt.Println("Goroutine started")
// do some work
fmt.Println("Goroutine finished")
done <- true
}
func main() {
done := make(chan bool, 1)
go worker(done)
// wait for worker to finish
<-done
fmt.Println("Program finished")
}
也可以使用 context
来对协程进行控制。
在Goroutine函数内部使用 context.Context
,并且在外部程序中调用 cancel()
方法,当外部程序调用 cancel()
时,Goroutine 函数会收到一个信号,可以在函数中检查该信号并退出执行。
func worker(ctx context.Context) {
fmt.Println("Goroutine started")
// do some work
select {
case <-ctx.Done():
fmt.Println("Goroutine cancelled")
return
default:
fmt.Println("Goroutine finished")
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
go worker(ctx)
// wait for worker to finish
time.Sleep(2 * time.Second)
fmt.Println("Program finished")
}
如果没有正确使用context会产生内存泄露。
那context 结构是什么样的?context 是 一个树状的结构,父context取消的话,从对应父context衍生出来的子context都会被取消,所以才能够实现我们的线程控制。
如果在 Goroutine 中执行的任务需要长时间运行,例如 I/O 操作或者阻塞操作,应该使用超时控制来防止 Goroutine 长时间阻塞。可以使用 time.After()
和 time.Tick()
来实现超时控制。
func worker() {
fmt.Println("Goroutine started")
select {
case <-time.After(3 * time.Second):
fmt.Println("Goroutine timeout")
case <-time.After(5 * time.Second):
fmt.Println("Goroutine finished")
}
}
func main() {
go worker()
// wait for worker to finish
time.Sleep(6 * time.Second)
fmt.Println("Program finished")
}
也可以直接使用 context
进行超时控制
waitgroup的也是我们控制协程退出的机制,但是每次使用都需要去 Add
和 Done
,我们通过一个简单的封装来减少这个操作,避免忘记 Add
或者 Done
导致程序不符合预期
import "sync"
type Group struct {
wg sync.WaitGroup
}
func (g *Group) Wait() {
g.wg.Wait()
}
func (g *Group) Start(f func()) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
f()
}()
}
这里最主要的是 Start
方法,内部将 Add
和 Done
进行了封装,虽然只有短短的几行代码,却能够让我们每次使用 waitgroup
的时候不会忘记去对计数器增加一和完成计数器。
数据安全
如何确保Goroutine的数据操作安全性?
可以用Channel来保证数据操作的安全
package main
import (
"fmt"
"time"
)
type Counter struct {
ch chan int
value int
}
func NewCounter() *Counter {
c := &Counter{
ch: make(chan int, 8),
value: 0,
}
go func() {
for {
// 将缓冲区的数据取出来递增到value上,没有数据则阻塞
select {
case <-c.ch:
c.value++
}
}
}()
return c
}
func (c *Counter) Inc() {
// 写入到管道中
c.ch <- 1
}
func (c *Counter) Value() int {
if len(c.ch) > 0 {
<-c.ch
c.value++
}
return c.value
}
func main() {
c := NewCounter()
for i := 0; i < 10; i++ {
go func() {
c.Inc()
}()
}
time.Sleep(2 * time.Second)
fmt.Print(c.value)
}
也可以通过 mutex
来保证数据的安全
package main
import (
"fmt"
"sync"
"time"
)
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
counter := Counter{}
for i := 0; i < 10; i++ {
go func() {
for j := 0; j < 10000; j++ {
counter.Inc()
}
}()
}
time.Sleep(time.Second)
fmt.Println(counter.Value())
}
这个递增的例子其实用 metux
会更好的理解,这里主要展示在不同场景下,可以灵活的去选择自己更好的方式进行并发数据安全的实现。
在 Kubernetes 中我们能看到很多的修改都是通过写入 channel 后再去执行,这样能保证单协程规避并发问题,也能够将生产和消费进行解耦。
但是如果我们仅仅只是通过上锁来修改 map
,那这个时候 channel 的性能就远不如直接上锁来的好,我们看以下的代码进行性能测试。
writeToMapWithMutex
是通过加锁的方式来操作map,而 writeToMapWithChannel
则是写入 channel 后再由另一个协程去消费。
package map_modify
import (
"sync"
)
const mapSize = 1000
const numIterations = 100000
func writeToMapWithMutex() {
m := make(map[int]int)
var mutex sync.Mutex
for i := 0; i < numIterations; i++ {
mutex.Lock()
m[i%mapSize] = i
mutex.Unlock()
}
}
func writeToMapWithChannel() {
m := make(map[int]int)
ch := make(chan struct {
key int
value int
}, 256)
var wg sync.WaitGroup
go func() {
wg.Add(1)
for {
entry, ok := <-ch
if !ok {
wg.Done()
return
}
m[entry.key] = entry.value
}
}()
for i := 0; i < numIterations; i++ {
ch <- struct {
key int
value int
}{i % mapSize, i}
}
close(ch)
wg.Wait()
}
通过 benchmark
进行测试
go test -bench .
goos: windows
goarch: amd64
pkg: golib/examples/map_modify
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkMutex-8 532 2166059 ns/op
BenchmarkChannel-8 186 6409804 ns/op
可以看到直接加锁修改map的效率是更高的,所以在修改不复杂的情况下我们优先选择直接 sync.Mutex
来规避并发修改的问题
限制数量
可以通过 带缓冲区的 channel
来控制,如果缓冲区已经被写满,则需要等待其他执行完的Goroutine将数据读走再继续操作。
package main
import (
"sync"
"fmt"
)
// 控制并发度为5
const N = 5
var APIList = []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
func main() {
var(
c = make(chan struct{},N)
wg sync.WaitGroup
)
for i := 0; i < len(APIList) ;i++{
c <- struct{}{}
wg.Add(1)
go func(index int){
fmt.Printf("%s", APIList[index])
_ = <-c
wg.Done()
}(i)
}
wg.Wait()
fmt.Println()
fmt.Print("done")
}
我们可以通过信号量来控制是否要运行协程,从而达到控制并发数量的目的。
type BoundedFrequencyRunner struct {
sync.Mutex
// 主动触发
run chan struct{}
// 定时器限制
timer *time.Timer
// 真正执行的逻辑
fn func()
}
func NewBoundedFrequencyRunner(fn func()) *BoundedFrequencyRunner {
return &BoundedFrequencyRunner{
run: make(chan struct{}, 1),
fn: fn,
timer: time.NewTimer(0),
}
}
边界运行期的结构体,可以看到它持有了需要执行的逻辑,还有定时器和主动触发的 chan
,都是为了能够让我们在初始化之后能够正常执行方法
// Run 触发执行 ,这里只能够写入一个信号量,多余的直接丢弃,不会阻塞,这里也可以根据自己的需要增加排队的个数
func (b *BoundedFrequencyRunner) Run() {
select {
case b.run <- struct{}{}:
fmt.Println("写入信号量成功")
default:
fmt.Println("已经触发过一次,直接丢弃信号量")
}
}
func (b *BoundedFrequencyRunner) Loop() {
b.timer.Reset(time.Second * 1)
for {
select {
case <-b.run:
fmt.Println("run 信号触发")
b.tryRun()
case <-b.timer.C:
fmt.Println("timer 触发执行")
b.tryRun()
}
}
}
func (b *BoundedFrequencyRunner) tryRun() {
b.Lock()
defer b.Unlock()
// 可以增加限流器等限制逻辑
b.timer.Reset(time.Second * 1)
b.fn()
}
panic的处理
可以使用recover函数来捕获Goroutine中的panic,并进行相应的处理,如果没有对相应的Goroutine 进行异常处理,会导致主线程 panic
所以我们可以通过代码的封装,不直接使用 go
关键字,来将每次启动的协程都带上 recover
。通过封装 HandleCrash
方法来实现,这个方式也是 Kubernets
中的实现。
package runtime
var (
ReallyCrash = true
)
// 全局默认的Panic处理
var PanicHandlers = []func(interface{}){logPanic}
// 允许外部传入额外的异常处理
func HandleCrash(additionalHandlers ...func(interface{})) {
if r := recover(); r != nil {
for _, fn := range PanicHandlers {
fn(r)
}
for _, fn := range additionalHandlers {
fn(r)
}
if ReallyCrash {
panic(r)
}
}
}
这里既支持了内部异常的函数处理,也支持外部传入额外的异常处理,如果不想要 Crash
的话也可以自己进行修改。
package runtime
func Go(fn func()) {
go func() {
defer HandleCrash()
fn()
}()
}
要起协程的时候可以通过 Go
方法来执行,这样也避免了自己忘记增加 panic
的处理。
底层实现
go
关键字启动后编译器器会通过cmd/compile/internal/gc.state.stmt和cmd/compile/internal/gc.state.call 两个方法将该关键字转换成runtime.newproc函数调用。
启动一个新的 Goroutine 来执行任务时,会通过 runtime.newproc
初始化一个 g
来运行协程。
在 Go 的运行时(runtime)系统中,G 表示 Goroutine,M 表示 Machine(即操作系统线程),P 表示 Processor。其中,G 是 Goroutine 执行的实体,M 是 Goroutine 的承载者,P 是调度器。
Goroutine 在 Go 运行时(runtime)系统中可以有以下 9 种状态:
-
Gidle:Goroutine 处于空闲状态,即没有被创建或者被回收; -
Grunnable:Goroutine 可以被调度器调度执行,但是还未被选中执行; -
Grunning:Goroutine 正在执行中,被赋予了M和P的资源; -
Gsyscall:Goroutine 发起了系统调用,进入系统调用阻塞状态; -
Gwaiting:Goroutine 被阻塞等待某个事件的发生,比如等待 I/O、等待锁、等待 channel 等; -
Gscan:GC正在扫描栈空间 -
Gdead:没有正在执行的用户代码 -
Gcopystack:栈正在被拷贝,没有正在执行的代码 -
Gpreempted:Goroutine 被抢占,即在运行过程中被调度器中断。等待重新唤醒
在 Go 中,每个 Goroutine 都是由 Go 运行时调度器(Scheduler)进行调度的。调度器负责将 Goroutine 转换成线程上的执行上下文,并在多个线程之间分配 Goroutine 的执行。
Go 调度器的调度策略是基于协作式调度的。 也就是说,调度器会在 Goroutine 主动让出执行权(例如在 I/O 操作、channel 操作、time.Sleep() 等操作中)时,将 CPU 的执行权转交给其他 Goroutine。这种调度策略可以保证 Goroutine 之间的调度是非常轻量级的。
在 Go 中,Goroutine 的调度时机一般有以下几种情况:
-
当前 Goroutine 主动让出执行权时,调度器会将 CPU 的执行权转交给其他 Goroutine。 -
当前 Goroutine 执行的时间超过了 Go 运行时所设置的阈值时,调度器会将当前 Goroutine 暂停,将 CPU 的执行权转交给其他 Goroutine。 -
当前 Goroutine 进行 I/O 操作、channel 操作或者其他系统调用时,调度器会将当前 Goroutine 暂停,将 CPU 的执行权转交给其他 Goroutine。 -
当前 Goroutine 被阻塞在同步原语(例如 sync.Mutex)时,调度器会将当前 Goroutine 暂停,将 CPU 的执行权转交给其他 Goroutine。
需要注意的是,在 Go 中 Goroutine 的调度是非确定性的,也就是说,Goroutine 之间的调度是不可预测的。这种调度策略可以保证 Goroutine 的执行具有随机性,可以充分利用多核 CPU 的性能。
具体性能优势的大小,取决于应用程序的具体实现、硬件环境和应用场景等因素,因此无法给出一个具体的数字来衡量其性能优势的大小。在不同的场景下,性能优势的差异也会有所不同。
在实现上采用的是用户态调度,不需要进行内核态和用户态之间的切换,从而可以更快地切换和调度多个 Goroutine。相比之下,传统的线程需要占用更多的资源和时间,因此在多并发的情况下,Go 的 Goroutine 会更加高效。
在实际应用中,要根据具体的场景和需求来选择合适的并发方式,不能盲目地追求 Goroutine 的性能优势而忽略其他的因素。
举个例子如果并发去访问同一个库,如果并发度是10的话,那么QPS的量将会被扩大10倍,如果这个时候数据库扛不住对应的并发,会造成雪崩的情况,所以这种时候并不适合用并发来优化程序的性能。
资源的消耗
1.内存的消耗
因为打开Goroutine时需要有对应的数据结构来存储,所以会产生内存的消耗。
通过开启协程并进行阻塞,来查看前后内存的变化情况
func getGoroutineMemConsume() {
var c chan int var wg sync.WaitGroup
const goroutineNum = 1000000 memConsumed := func() uint64 {
runtime.GC() //GC,排除对象影响 var memStat runtime.MemStats
runtime.ReadMemStats(&memStat)
return memStat.Sys
}
noop := func() {
wg.Done()
<-c //防止goroutine退出,内存被释放
}
wg.Add(goroutineNum)
before := memConsumed() //获取创建goroutine前内存 for i := 0; i < goroutineNum; i++ {
go noop()
}
wg.Wait()
after := memConsumed() //获取创建goroutine后内存 fmt.Println(runtime.NumGoroutine())
fmt.Printf("%.3f KB bytes\n", float64(after-before)/goroutineNum/1024)
}
每个协程至少需要消耗 2KB 的空间,那么假设计算机的内存是 2GB,那么至多允许 2GB/2KB = 1M 个协程同时存在。
2.CPU的消耗
因为开启goroutine后需要进行调度,而且每次开启一个任务时,执行任务也会占用CPU。
一个Goroutine消耗多少CPU 实际上跟执行函数的逻辑有着很大的关系,如果执行的函数是CPU密集型的计算,并且持续的时间很长,那么这个时候CPU就会优先到达瓶颈。
所以具体的 CPU 消耗需要看具体的逻辑才能够进行判断。
写在最后
理解 Goroutine 使用和原理,对我们在学习云原生的知识是有极大的帮助的,在 kubernetes
中用了非常多的异步编程技巧,如果我们没有异步编程的知识储备,那代码看起来会是云里雾里的,了解了上面这些协程的用法之后,在阅读 go
相关项目的时候也会如虎添翼,帮助我们快速理解。