【Golang】异步编程小技巧

我们通过一个简单的例子看一下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的也是我们控制协程退出的机制,但是每次使用都需要去 AddDone ,我们通过一个简单的封装来减少这个操作,避免忘记 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 种状态:

  1. Gidle:Goroutine 处于空闲状态,即没有被创建或者被回收;
  2. Grunnable:Goroutine 可以被调度器调度执行,但是还未被选中执行;
  3. Grunning:Goroutine 正在执行中,被赋予了M和P的资源;
  4. Gsyscall:Goroutine 发起了系统调用,进入系统调用阻塞状态;
  5. Gwaiting:Goroutine 被阻塞等待某个事件的发生,比如等待 I/O、等待锁、等待 channel 等;
  6. Gscan:GC正在扫描栈空间
  7. Gdead:没有正在执行的用户代码
  8. Gcopystack:栈正在被拷贝,没有正在执行的代码
  9. Gpreempted:Goroutine 被抢占,即在运行过程中被调度器中断。等待重新唤醒

在 Go 中,每个 Goroutine 都是由 Go 运行时调度器(Scheduler)进行调度的。调度器负责将 Goroutine 转换成线程上的执行上下文,并在多个线程之间分配 Goroutine 的执行。

Go 调度器的调度策略是基于协作式调度的。 也就是说,调度器会在 Goroutine 主动让出执行权(例如在 I/O 操作、channel 操作、time.Sleep() 等操作中)时,将 CPU 的执行权转交给其他 Goroutine。这种调度策略可以保证 Goroutine 之间的调度是非常轻量级的。

在 Go 中,Goroutine 的调度时机一般有以下几种情况:

  1. 当前 Goroutine 主动让出执行权时,调度器会将 CPU 的执行权转交给其他 Goroutine。
  2. 当前 Goroutine 执行的时间超过了 Go 运行时所设置的阈值时,调度器会将当前 Goroutine 暂停,将 CPU 的执行权转交给其他 Goroutine。
  3. 当前 Goroutine 进行 I/O 操作、channel 操作或者其他系统调用时,调度器会将当前 Goroutine 暂停,将 CPU 的执行权转交给其他 Goroutine。
  4. 当前 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 相关项目的时候也会如虎添翼,帮助我们快速理解。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值