从 0 到 1 手撸一个协程池

23 小时 50 分钟前
 IIInsomnia

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

2344 次点击
所在节点    Go 编程语言
37 条回复
bli22ard
23 小时 3 分钟前
OP 水平可以,不过 goroutine 本身已经轻量级,还需要 pool 化吗?想不到需要 pool 化 goroutine 的场景
AEnjoyable
22 小时 59 分钟前
@bli22ard 任务执行队列,控制任务量,对运行中的任务通过 ctx 控制
bli22ard
22 小时 27 分钟前
@AEnjoyable
任务执行队列?
为什么要有任务队列,有任务需要执行,立刻就去执行就可以啊
控制任务量
一些场景下,可能要进行并发数量控制,但是这个属于并发数量控制的范围,和 pool 化 goroutine 是两个东西
对运行中的任务通过 ctx 控制
直接运行 goroutine 一样也可以 ctx 去控制
knightdf
22 小时 18 分钟前
我们目前用的 ants ,有时间看看你这个
layxy
22 小时 4 分钟前
@bli22ard 某些中间件连接有上限,还是需要池化技术,不过可以不通过协程池来实现类似能力
lvlongxiang199
22 小时 3 分钟前
@AEnjoyable "控制任务量" 可以用信号量. https://pkg.go.dev/golang.org/x/sync/semaphore
dyllen
21 小时 39 分钟前
@bli22ard 我也觉得没必要池化,大部分实际是跟风大厂。
实际需要的只是控制最大协程数量。
IIInsomnia
21 小时 32 分钟前
其实我这里就是控制最大协程数量
lesismal
21 小时 7 分钟前
按照协程是否常驻,目前主要分两大类:

### 一、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 那种限流缺陷,比较平衡。
lesismal
20 小时 55 分钟前
另外,协程池做性能测试需要考虑不同的业务场景特点,比如:
1. 任务的消耗类型和消耗时长
2. 协程池 size 、并发度

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

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

测试方法不严谨或者测试参数不全面、不同框架的配置、测试参数不统一,就不能得出准确的结论,用户们应该自己去多看几眼、多自己跑代码测试一下,别轻信仓库官方自己声明的数据、不管官方是大厂还是个人开发者、也不管仓库主要作者是中国还是外国背景(因为都可能有类似问题)。
lesismal
20 小时 53 分钟前
扫了一眼 OP 的这个协程池,练手可以,但建议还是参考下 #9 吧
jianyang
20 小时 10 分钟前
@lesismal 👍
kk2syc
20 小时 6 分钟前
@lesismal 👍
kuanat
18 小时 10 分钟前
大概三年半前,由于众所周知的原因,各种会议特别多。团队有过一次讨论,就是要不要引入外部、通用协程池实现。现在我把当初讨论的重点摘录出来,权当抛砖引玉,看看这些观点能否经得起时间的考验。

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

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

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

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

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

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

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


PS
如果让现在的我来评论,观点会更加极端。“协程池”之类技术的核心是任务调度,而池化是有时代局限性的妥协。Go 语言设计出低心智负担的并发模型,就是为了让开发者不用费尽心力去管理什么协程池。当你觉得有什么需求是非协程池不可的时候,不妨重新思考一下“任务”的本质。在 Go 的思维模型里,任务的本质是数据,任务调度就是数据的流动,完成调度这个行为有太多更简洁的做法,过去把任务理解成执行动作的思路是低效的。
txzhanghuan
18 小时 1 分钟前
@kuanat 层主逻辑很清晰,其实网上所谓的通用组件很多时候并不通用,因为开发小体量通用组件的人并没有经历过很多业务场景和复杂的技术场景,导致其通用性存疑。并且很多这种类似协程池的小组件本身代码并不复杂,使用一个开源的所谓通用的组件还得考虑其维护成本和适配能力,不如就自己开发一个,某种程度上还能写到自己的 KPI 里面。
AEnjoyable
17 小时 57 分钟前
@IIInsomnia #8 我一般用信号量来控制
Nazz
17 小时 49 分钟前
用有缓冲 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()
}()
}
lesismal
17 小时 46 分钟前
@kuanat #14 基本上就是有需要限制协程数量的地方需要这个, 比如海量并发 epoll+逻辑协程池, 比如不同业务模块的协程池(例如常见的连接池)

没有限制协程数量的需求的场景, 硬要用协程池代替 go func 的基本都是画蛇添足
yangxin0
17 小时 45 分钟前
go 本身就是携程了。。。。
grzhan
17 小时 30 分钟前
感觉控制协程数量的场景属于内部造轮子可以解决的轻量需求,如果是我的话大概率也是自己造轮子来定制(参考开源实现)而不是引入第三方依赖。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1089474

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX