【经验项】GO协程应该怎么使用

问题发现

在生产环境下,微服务时不时直接panic,导致服务重启。而在k8s下服务重启5次后,会进入惩罚模式重启时长变5分钟(可以修改,但是治标不治本)。 最终要的降低程序的影响面。

根因:微服务都是HTTP/grpc服务,在底层实现都是通过go协程来处理http请求,并且未进行panic的recover操作。从而导致整个服务的崩溃。

解决方式

在grpc的server拦截器中添加github.com/grpc-ecosys...的使用。并且配合Prometheus的上报,捕获程序的panic次数,进行快速告警。

关键字GO存在什么问题

可以参考之前的文章:
【100 Mistakes】golang并发的坑-1
【100 Mistakes】golang并发的坑-2.md

主要的原因有以下几点:

  1. go协程的panic未处理,会引发整个程序的panic。

  2. go协程的泄漏,如果goroutine启动后没有正确退出或没有及时终止,可能会导致goroutine泄漏。

  3. go协程滥用,如果goroutine在使用完资源后不正确地释放资源,可能会导致资源泄漏。资源泄漏可能包括内存泄漏、文件描述符泄漏等,最终会耗尽系统资源。

本篇文章主要针对1,3的解决方案:github.com/sourcegraph...

conc库

短小精悍的go协程库(相比较来说,用起来比较舒服),由Sourcegraph开源的,该组织在github还没有WebIDE时,提供过一个github网页代码查看插件。

具体的使用例子可以查看:

一个优秀的开源框架,往往代码单测以及举例都会比较详细。 conc就是这样的代码。

使用场景 协程池

当有集合对象需要进行处理,满足以下2个条件可以使用:

  1. 处理时间,比较耗时的时候。

  2. 各个对象的处理相对独立。

这样的情况下,可以使用conc.Pool 来并发处理。

官方推荐:池是高效的,但不是零成本。不应该使用很短的时间任务。启动和拆卸的开销约为 1μs,并且每个任务的开销约为 300ns。

简单的使用:代码

go 复制代码
        g := pool.New()

        var completed atomic.Int64

        for i := 0; i < 100; i++ {

            g.Go(func() {

                time.Sleep(1 * time.Millisecond)

                completed.Add(1)

            })

        }

        g.Wait()

问题来了,这跟waitGroup有啥区别?

区别在于:

  1. pool调用的Go函数,内部自己封装了panic相关处理。

  2. 方便使用,调用goroutine时,不用再使用Done, Add。

  3. 可以通过WithMaxGoroutines,直接指定最大协程数。

推荐的最重要的理由:它补充了一个场景----往往函数是有错误返回的,那么该如何处理?
代码

go 复制代码
func ExampleErrorPool() {

    p := pool.New().WithErrors()

    for i := 0; i < 3; i++ {

        i := i

        p.Go(func() error {

            if i == 2 {

                return errors.New("oh no!")

            }

            return nil

        })

    }

    err := p.Wait()

    fmt.Println(err)

    // Output:

    // oh no!

}

在Wait的时候,会返回所有协程的错误,如果有或者若干个的时候,会进行换行。其内部是通过joinError进行组合的error切片。

另外还有一些常用的功能,比如

scss 复制代码
WithContext(context.Background())

WithCancelOnError()

NewWithResults()

使用场景 迭代增强

切片提供了2个强力方法ForEach()ForEachIdx(),有过其他语言基础,应该能够从名字就能看出来;
iter_test.go

go 复制代码
input := []int{1, 2, 3, 4}

iterator := iter.Iterator[int]{

    MaxGoroutines: len(input) / 2,

}

  


iterator.ForEach(input, func(v *int) {

    if *v%2 != 0 {

        *v = -1

    }

})

  


fmt.Println(input)

  


// Output:

// [-1 2 -1 4]
go 复制代码
        ints := make([]int, 10000)

        iter.ForEachIdx(ints, func(i int, val *int) {

            *val = i

        })

如果需要对切片内数据进行操作的时候,它会通过多协程的方式来,进行操作。并且协程安全的。很有意思的源码。

这里只是对切片进行原地修改,当我们需要返回值或者返回错误的时候,可以使用iter.Map 方法进行。

使用场景 有序CallBack

什么是有序callBack?

场景:当需要对一组数据进行操作后,需要有序 调用callback方法来做进一步处理时,可以使用conc.Stream来进行操作;
stream_test.go

go 复制代码
func ExampleStream() {

    times := []int{20, 52, 16, 45, 4, 80}

  


    s := stream.New()

    for _, millis := range times {

        dur := time.Duration(millis) * time.Millisecond

        s.Go(func() stream.Callback {

            time.Sleep(dur)

            // This will print in the order the tasks were submitted

            return func() { fmt.Println(dur) }

        })

    }

    s.Wait()

  


    // Output:

    // 20ms

    // 52ms

    // 16ms

    // 45ms

    // 4ms

    // 80ms

}

可以看到,在异步做处理后,进一步调用回调方法,其输出的结果还是有序的。

源码分析

对于conc这种短小精悍的库,我们可以使用goplantuml直接来查看其内部的对象封装情况。

Pool包的ER图

plantuml中的类图,可以帮我们快速的查看对象的依赖关系。

好玩的知识

堆栈捕获

当goroutine出现panic后,我们具体需要做什么操作,conc源码中给了一个非常好的实现方式:

go 复制代码
type Recovered struct {

    //  panic的原始值

    Value any

    // 当恐慌发生时,runtime.Callers 返回的调用者列表

    // 恢复了。可用于生成更详细的堆栈信息

    // 运行时.CallersFrames。

    Callers []uintptr

    // 来自恢复恐慌的 goroutine 的格式化堆栈跟踪。

    // 比Callers更容易使用。

    Stack []byte

}

定义了一个Recovered的结构体,在Catcher结构体中,调用Catcher.Try()来封装传入的结构体,如果出现了panic后,创建Recovered对象来记录panic的详细信息。pool或conc.WaitGroup底层都使用Catcher来调用函数。

ER图:

可以直接下载源码执行:panics_test.go

iter的实现

在conc中iter的实现也比较有趣,通过golang的atomic库+WaitGroup即可实现,实现代码+注释才75行左右;

关键步骤如下:

go 复制代码
var idx atomic.Int64

    // Create the task outside the loop to avoid extra closure allocations.

    task := func() {

        i := int(idx.Add(1) - 1)

        for ; i < numInput; i = int(idx.Add(1) - 1) {

            f(i, &input[i])

        }

    }

  


    var wg conc.WaitGroup

    for i := 0; i < iter.MaxGoroutines; i++ {

        wg.Go(task)

    }

    wg.Wait()

当切片对象传入后,声明一个idx对象,用来保存执行索引(原子操作),然后通过WaitGroup创建goroutine来进行操作。

Stream的实现

stream最有趣的地方在于其异步并且有序的 执行回调函数。异步比较容易实现,在异步后并保持回调的有序性,这里可以思考下如何实现?

在goroutine中,通过chan来保证执行的有序性。那么就可以在执行goroutine前,创建一个chan与其通过闭包的方式进行一一绑定,在后续回调的时候,实现同步方式。

ER图如下:

从图中,我们可以看到定义了三个类型:

go 复制代码
type callbackCh chan func()   // 保存回调函数的通道

type Callback func()        //  定义回调函数

type Task func() Callback   // 定义任务类型

  


var callbackChPool = sync.Pool{  

    New: func() any {

        return make(callbackCh, 1)

    },

}

  


func (s *Stream) Go(f Task) {

    s.init()

  


    // 获取一个channel类型, 即callbackCh

    ch := getCh()

   

    // 将ch 放入到队列中, 这里转同步了  queue的类型为  chan callbackCh

    // 在 callBackCh 上 加上了一层 作为channel。

    // s.queue 在callbacker()函数中循环等待

    s.queue <- ch

  


    // Submit the task for execution.

    s.pool.Go(func() {

        defer func() {

            // In the case of a panic from f, we don't want the callbacker to

            // starve waiting for a callback from this channel, so give it an

            // empty callback.

            if r := recover(); r != nil {

                ch <- func() {}

                panic(r)

            }

        }()

  


        // 最后将回调函数插入到s.queue中

        callback := f()

        ch <- callback

    })

}

  


func (s *Stream) callbacker() {

    var panicCatcher panics.Catcher

    defer panicCatcher.Repanic()

  


    // !!!!!!For every scheduled task, read that tasks channel from the queue.

    for callbackCh := range s.queue {

        // Wait for the task to complete and get its callback from the channel.

        callback := <-callbackCh

  


        // Execute the callback (with panic protection).

        if callback != nil {

            panicCatcher.Try(callback)

        }

  


        // Return the channel to the pool of unused channels.

        putCh(callbackCh)

    }

}

  

在这个代码里面还有一个有趣的点, s.queue 通过在Wait函数中 close(s.queue)来进行break循环。所以channel的特征,活学活用!!!!

小结

conc的代码实现非常漂亮,并且其单测的代码也可以借鉴。

感兴趣,可关注公众号 【小唐云原生】

相关推荐
蒙娜丽宁2 天前
Go语言错误处理详解
ios·golang·go·xcode·go1.19
qq_172805592 天前
GO Govaluate
开发语言·后端·golang·go
littleschemer3 天前
Go缓存系统
缓存·go·cache·bigcache
程序者王大川4 天前
【GO开发】MacOS上搭建GO的基础环境-Hello World
开发语言·后端·macos·golang·go
Grassto4 天前
Gitlab 中几种不同的认证机制(Access Tokens,SSH Keys,Deploy Tokens,Deploy Keys)
go·ssh·gitlab·ci
高兴的才哥5 天前
kubevpn 教程
kubernetes·go·开发工具·telepresence·bridge to k8s
少林码僧5 天前
sqlx1.3.4版本的问题
go
蒙娜丽宁5 天前
Go语言结构体和元组全面解析
开发语言·后端·golang·go
蒙娜丽宁6 天前
深入解析Go语言的类型方法、接口与反射
java·开发语言·golang·go
三里清风_6 天前
Docker概述
运维·docker·容器·go