Golang Go语言中从 0 到 1 手撸一个协程池

发布于 1周前 作者 songsunli 来自 Go语言

最近使用 ants ,发现任务不支持 context ,而且非阻塞模式下,拿不到 worker ,会返回 error ;于是决定自己实现一个,非阻塞模式下,任务会缓存到全局链表中待执行,性能还不错 https://github.com/shenghui0779/nightfall


Golang Go语言中从 0 到 1 手撸一个协程池
46 回复

OP 水平可以,不过 goroutine 本身已经轻量级,还需要 pool 化吗?想不到需要 pool 化 goroutine 的场景

更多关于Golang Go语言中从 0 到 1 手撸一个协程池的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


任务执行队列,控制任务量,对运行中的任务通过 ctx 控制


任务执行队列?
为什么要有任务队列,有任务需要执行,立刻就去执行就可以啊
控制任务量
一些场景下,可能要进行并发数量控制,但是这个属于并发数量控制的范围,和 pool 化 goroutine 是两个东西
对运行中的任务通过 ctx 控制
直接运行 goroutine 一样也可以 ctx 去控制

我们目前用的 ants ,有时间看看你这个

某些中间件连接有上限,还是需要池化技术,不过可以不通过协程池来实现类似能力

我也觉得没必要池化,大部分实际是跟风大厂。
实际需要的只是控制最大协程数量。

其实我这里就是控制最大协程数量

按照协程是否常驻,目前主要分两大类:

### 一、idle 协程常驻
1. ants ,好象是常驻协程 cond_t 方式实现
2. 常驻协程+chan 的多种方式

goroutine 比较轻量,runtime 自己就有协程复用相关的,所以每次 go 创建新协程其实成本不大,所以常驻协程未必就是好事情,反倒是常驻协程额外的 chan 或者 cond_t 会有相对一点性能损失以及常驻的内存开销
我和一些小伙伴对各种协程池做压测,想比喻非常驻协程的方案,ants 并不具有性能优势

### 二、idle 协程不常驻
1. 字节的 gopool 这种:协程数量控制+任务队列的方式。当前协程数量没有达到最大则新任务直接创建协程执行,每个协程执行完当前也都会检查队列里是否有新的任务、如果有继续取出任务执行、否则协程退出。如果当前协程数量达到最大值就加入队列等待被执行。字节 gopool 的队列用的 list ,其他实现也有复用 slice 的

goppol 避免了常驻协程的内存开销,协程用完就归还给 runtime ,清爽轻量,没有额外的 chan 、cond_t 之类的操作的损失、性能好,但缺点是 gopool 这种实现的队列,再并发任务数量巨大、任务执行较慢时,会导致队列 size 爆炸性增长,没办法像 chan 那样自然限流去实现系统平衡

2. nbio 的协程池,协类似 gopool 但队列替换成了一个常驻协程+chan 。任务协程数量没达到最大值时新任务直接创建新协程执行、否则加到 chan 队列里,只有一个常驻协程+chan 做队列可以实现自然限流( chan 满了阻塞、自动反馈给调用方)。任务协程用完就还给 runtime ,1 个常驻协程成本也很低,也弥补了 gopool 那种限流缺陷,比较平衡。

另外,协程池做性能测试需要考虑不同的业务场景特点,比如:
1. 任务的消耗类型和消耗时长
2. 协程池 size 、并发度

不同的测试参数,不同的协程池的压测数据可能各有快慢,要考虑实际的业务特点,用某个测试参数就得出某个或者某些协程池最快的结论是不太准确的。

一些库的作者自己宣称自己的库性能第一,但结论并不准确。
比如某些网络库号称自己 tcp 比标准库快、但其实可能是他的压测例子里自己的库的 write buffer size 大、标准库的 write buffer size 小,导致标准库浪费更多 syscall ,然后得出比标准库快的结论。
比如某些网络库号称自己的 http 拿过“天梯”第一,但其实他的压测用例只是实现了简单的 http 头和尾的判断,根本没法处理完整 http 协议或功能,拿去对比别人完整功能的框架,然后得到个第一的跑分,但作者自己知道、天体跑分仓库的 owner 也知道。但这种结果拿来给自己的仓库做宣传并且没有明确说明,就有点误导用户了

测试方法不严谨或者测试参数不全面、不同框架的配置、测试参数不统一,就不能得出准确的结论,用户们应该自己去多看几眼、多自己跑代码测试一下,别轻信仓库官方自己声明的数据、不管官方是大厂还是个人开发者、也不管仓库主要作者是中国还是外国背景(因为都可能有类似问题)。

扫了一眼 OP 的这个协程池,练手可以,但建议还是参考下 #9 吧

大概三年半前,由于众所周知的原因,各种会议特别多。团队有过一次讨论,就是要不要引入外部、通用协程池实现。现在我把当初讨论的重点摘录出来,权当抛砖引玉,看看这些观点能否经得起时间的考验。

1.
工程管理层面,我的观点是所有引入第三方依赖的行为都要慎重,当然我并不反对引入依赖,完全自己造轮子的工程量和实现质量都是更大的问题。那这个引入的平衡点在哪里?有几个考量因素:第三方库的规模以及项目中实际用到的功能占比,第三方项目的生产环境成熟度,团队内部 review/debug 甚至换人重写的成本代价。

无论从哪方面看,协程库这种功能单一、实现简单的组件都应该内部实现。额外的初期开发成本,相比维护 debug 甚至人员调整、供应链风险等等方面的收益简直不值一提。

2.
实际上就我个人观点,我认为要不要引入三方协程池实现这件事本身根本不需要上会讨论,之所以拿出来讨论是为团队建设服务的。团队协作的困难在于成员之间建立共识,考虑到团队成员背景、能力和经验不同,最常见的情况就是以为是共识但实际上不是,所以就需要反复沟通交流。还有更常见的情况,技术路线的决策结果很容易自上而下传达,而支撑决策的论据在传递过程中就缺失了,项目成员工作在不知所以然的状态。我更倾向于让成员建立更多的共识,这样可以减少后续的沟通成本。

支持引入一方的核心论据是三方库的性能高,并且辅以 cpu 内存占用等等数据。其实以最朴素的科研思维来看,测试数据集特征、测试方法以及因果相关性匹配程度,都比单纯的跑分结果重要。

补充一点关于网络并发的历史发展,很多年前有过 c1k/c10k 之类的说法,但是之后很少有说 c100k/c1m 的,因为这个过程主要依赖的是横向扩展而非单体。单体应用并发 1m 协程是个极其小众的场景。再就是需要用到协程/线程池这个技术,有一个隐含的前提就是协程/线程昂贵,是事实上的瓶颈。放到二十年前这个前提是成立的,然而随着软硬件的发展,cpu 核心数、内存大小甚至带宽都可能是瓶颈,唯独 golang 协程很难成为瓶颈。

3.
回归技术层面,“通用”协程库的问题有哪些?调度逻辑是个与业务特性强相关的功能,通用实现的性能指标和业务需求很可能不匹配。

再具体到实现细节,“通用”库为了实现其通用性,对于“任务”这个概念的定义实际上被抽象成了“可以执行任务的函数方法”,作为调用方,不得不去匹配一个特定形式的签名,与之相关的超时、重试逻辑和异常处理都要做出调整。继续思考下去会发现,如果要完成与协程的通信,加入 chan 管理又是大工程。这一切的根源都是“通用”协程库对于“任务”这一概念特殊抽象的结果,选择了这样的抽象就注定了这样的处理方式。既然通用库侵入性这么大,实现难度又低,为什么不自己写呢。放弃通用性,可维护性


PS
如果让现在的我来评论,观点会更加极端。“协程池”之类技术的核心是任务调度,而池化是有时代局限性的妥协。Go 语言设计出低心智负担的并发模型,就是为了让开发者不用费尽心力去管理什么协程池。当你觉得有什么需求是非协程池不可的时候,不妨重新思考一下“任务”的本质。在 Go 的思维模型里,任务的本质是数据,任务调度就是数据的流动,完成调度这个行为有太多更简洁的做法,过去把任务理解成执行动作的思路是低效的。

层主逻辑很清晰,其实网上所谓的通用组件很多时候并不通用,因为开发小体量通用组件的人并没有经历过很多业务场景和复杂的技术场景,导致其通用性存疑。并且很多这种类似协程池的小组件本身代码并不复杂,使用一个开源的所谓通用的组件还得考虑其维护成本和适配能力,不如就自己开发一个,某种程度上还能写到自己的 KPI 里面。

#8 我一般用信号量来控制

用有缓冲 chan 控制下并发就够了

type channel chan struct{}

func (c channel) add() { c <- struct{}{} }

func (c channel) done() { <-c }

func (c channel) Go(f func()) {
c.add()
go func() {
f()
c.done()
}()
}

#14 基本上就是有需要限制协程数量的地方需要这个, 比如海量并发 epoll+逻辑协程池, 比如不同业务模块的协程池(例如常见的连接池)

没有限制协程数量的需求的场景, 硬要用协程池代替 go func 的基本都是画蛇添足

go 本身就是携程了。。。。

感觉控制协程数量的场景属于内部造轮子可以解决的轻量需求,如果是我的话大概率也是自己造轮子来定制(参考开源实现)而不是引入第三方依赖。

写了好些年 go 没有用过任何协程池的东西,要控制协程数量的地方基本都是自己实现。

请教一下大佬,有 go 协程池相关的工程实践文章分享吗?比如使用协程池和不使用协程池,如 cpu 、内存利用率等指标的提升。因为我接触到的也就公司网关的项目利用协程池,普通的服务似乎只需要 k8s 扩容就可以

实现协程池需要这么多代码?

#18

我遇到需要极大协程池的只有一个负载平衡路由的 dispatcher ,然而“通用”协程池动态调节对于这个场景也是没有意义的,一次性预热分配好常驻就可以了。

我这里很多资深(职级)程序员都有一种对于 universal/variable/dynamic 方案的迷思,反倒需要我经常强调动态可变实现带来的不可预测性才是大忌。即便是一个理想的动态实现,使用的时候还是会根据硬件、网络实测一个并发上限并一直沿用下去,那这和写一个固定的实现没有什么区别。且不说真正业务产生的 cpu 和内存消耗数量级层面就高于调度器的损耗,即便节省出来资源,也不可能在这种有关键性能的位置搞 cpu 或者内存共享。

也许我这样说有点暴论,但我认为,一个承载能力为 N 的系统和一个承载能力在 0~N 之间变动的系统在生产环境是没有区别的,而前者的复杂度耕地,可维护性更高。

#22

我一样很少用到,过去几年里都是如此。你可以看 #24 的回复,我跟你的观点是一致的,动态扩容是实例层面的事情,go 应用单体不应该考虑。这里讨论的是真正存在压力的生产环境。

性能指标这方面,还是要具体情况具体分析。我是极其反对单纯以 cpu 或内存占用作为性能指标的,除非它是真正的瓶颈。笼统地说我一般会跑一下火焰图和计时任务,观察一下可能的热点或者瓶颈在哪里,只有确认了瓶颈存在才会考虑优化。网络编程这个领域,多数时候加钱( cpu 和内存)比加人工更有效。

具体到并发相关的参数,考虑一般有限资源环境中的调度问题,调度行为一定会产生损耗,根据特定的业务场景,追求低延迟就看前 5%的耗时,追求高吞吐就看 95% 的完成时间。看统计分布比看单一指标更有意义。

IM socket 大并发场景

#24

> 一次性预热分配好常驻就可以了

没必要, runtime 复用协程的性能是足够的, 我们做 RPC 框架的压测, 每个 RPC Call 都是 go func() 一个协程处理, 处理完后就退出了, 每秒几十万 Call, 也就是每秒几十万次 go func(), 毫无压力. 可以参考鸟窝老师的文章, 这里的 RPC 框架的测试都是每个 RPC Call 都 go func() 的, go func() 成本很低的:
https://colobu.com/2022/07/31/2022-rpc-frameworks-benchmarks/

所以, 预热分配除了浪费常驻内存, 额外的 chan 或者 cond_t 反而可能比直接 go func()还慢, 真的没什么必要, 限制协程数量+队列+go func() 可能才是更优解



> 反倒需要我经常强调动态可变实现带来的不可预测性才是大忌。

接#28, go func()的成本很低, 如果稳定性有问题, 那 runtime 也不稳定了, 预热创建也会不稳定. 较早版本的 go 可能没这么优秀, 1.15 还是 1.18 哪个版本之后来着, runtime go func()和协程复用已经足够优秀了, 所以你不必太担忧这个问题

> 即便是一个理想的动态实现,使用的时候还是会根据硬件、网络实测一个并发上限并一直沿用下去,那这和写一个固定的实现没有什么区别

预热创建和动态创建销毁, 在软硬件的常驻占用和能源消耗硬件持久健康上还是有区别的, 规模越大, 成本效益越大


引起 golang 不稳定的更多是过载, 海量连接之类的导致的协程数量, 对象数量, 对应的内存和调度和 gc 压力, 对应的 oom 和 stw 问题. 为了搞这些, 我搞了这个:
https://github.com/lesismal/nbio
1m websocket connections on 4core cpu ubuntu vm, 1k payload echo test, server costs 1g mem, 能跑 10w qps:
https://github.com/lesismal/go-websocket-benchmark?tab=readme-ov-file#1m-connections-1k-payload-benchmark-for-nbiogreatws



> 也许我这样说有点暴论,但我认为,一个承载能力为 N 的系统和一个承载能力在 0~N 之间变动的系统在生产环境是没有区别的,而前者的复杂度耕地,可维护性更高。

看上去有道理, 但这就和 javaer 说"堆机器就行了"是一个道理, 都属于看上去对, 但只适用于普通场景

一旦放大到规模效应, 海量的业务, 这差别就大了, 别人努力去搞动态的方向上是对的, 如果实现的方式能达到较优解就更好了

协程池就是 c++/java 的惯性思维,go 里面必要性不大,用 chan 控制下协程数量简单又优秀。

协程创建成本很低,就是一个结构体 + 栈空间(如果是无栈协程成本就更低)。 栈空间和结构体所需内存 golang runtime 已经通过 fixalloc + pool 实现了,原则上是不需要协程池了。

脱裤子放屁。

之前写过一个 go 任务库,也遇到了要不要用协程池的问题,顺便请教一下大家。

目前的实现是一个 service 一个 goroutine ,想改成一个 service 有多个 goroutine ,就是每次发起 call 调用时就新建一个 goroutine ,但是要限制同一个时刻确保一个 service 只有一个 goroutine 在运行,其他的 goroutine 要等别的执行完再执行。不知道如何实现。

仓库地址 https://github.com/hanxi/gtask

在加个 chan ?

赞一个,有空学习学习

不知道怎么加。

目前用的这个: https://github.com/sourcegraph/conc
感觉感觉挺不错,使用方式还挺多

其实我一开始也只是控制协程的数量,执行完 return 交给 runtime 回收,但是在公司项目中压测结果不理想,虽然控制了数量,但是会频繁的创建,导致在公司项目中压测 CPU 和内存不理想,最终还是选择了协程复用;不过我这个不是真正意义上的池化,而是采用「生产-消费」模式

#40 频繁创建协程没啥压力的, 我们压测 RPC Echo 每秒几十万都毫无压力而且性能和占用稳定, 除非是任务太多, 创建速度大于执行完的退出速度导致协程数量越来越大或者有泄漏之类的搞爆了

还有退出的资源也不会及时 GC ,有等待期

#42

> 还有退出的资源也不会及时 GC ,有等待期

不管是用协程池协程常驻去执行 func()还是 go func(), 在 func()内创建的资源都是相同的, 这些资源应该都是 func() return 后可以被 gc, 如果不及时两者也都是不及时, 要想优化 func()内的资源还是得自己加 Pool 之类的

所以主要区别应该还是常驻协程复用和 runtime 复用协程的区别吧

大佬说的对

用 sync.pool 不行吗?

在Go语言中实现一个协程池(Goroutine Pool)可以显著提升并发任务的执行效率,特别是在需要频繁创建和销毁协程的场景下。下面是一个从0到1构建简单协程池的示例:

  1. 定义任务类型:首先,你需要定义一个任务类型,这通常是一个函数类型,接受无参数并返回无值或错误。
type Task func() error
  1. 实现协程池结构:接下来,定义一个协程池结构,包含任务队列、工作协程数量、工作通道等字段。
type GoroutinePool struct {
    tasks      chan Task
    workerPool chan struct{}
    wg         sync.WaitGroup
}
  1. 初始化协程池:实现一个构造函数来初始化协程池,包括启动指定数量的工作协程。
func NewGoroutinePool(workerCount int) *GoroutinePool {
    pool := &GoroutinePool{
        tasks:      make(chan Task),
        workerPool: make(chan struct{}, workerCount),
    }
    for i := 0; i < workerCount; i++ {
        pool.workerPool <- struct{}{}
        go pool.worker()
    }
    return pool
}
  1. 工作协程逻辑:每个工作协程从任务队列中获取任务并执行,完成后释放工作通道中的一个位置。

  2. 提交任务:实现一个方法来提交任务到任务队列。

  3. 关闭协程池:实现一个方法来优雅地关闭协程池,确保所有任务完成。

由于篇幅限制,这里只提供了大致框架和部分代码示例。具体实现细节(如工作协程逻辑、任务提交和关闭协程池的方法)需要根据实际需求进行补充和完善。

回到顶部