经常写Go的小伙伴都知道,Go语言的goruntine是这门编程语言的一大利器,相比线程,基于协程的goruntine更加轻量和高效,并且在语法上十分的简单。
为什么协程比线程更高效?
协程(Coroutine)和线程(Thread)是两种不同的并发执行控制结构,它们在多个方面存在显著的差异。
首先在定义上,协程是程序级别的执行单元,是轻量级的。线程是操作系统级别的并发执行单元,是重量级的。
协程的堆栈是动态的,可以根据需要增长和缩小,内存使用效率高。线程有自己的固定大小的堆栈,堆栈大小限制了线程的数量,并可能导致堆栈溢出错误。协程的创建和切换成本非常低,因为它们是在用户级别管理的,不需要系统级的上下文切换。线程的创建和切换成本相对较高,因为它们由操作系统管理,并涉及到系统级的上下文切换。
举个例子,你要给一个人的银行卡账户里转账,协程就好比电子(网上)银行卡,线程就好比ATM机,你使用网银转账的话可以绑定某宝或某信直接动动手指就能转账,但是去ATM机的话就想对麻烦一些,但本质上都是会通过银行的信息系统完成最终的转账,只是不同方式的操作成本不同。
协程我们说完了,但是如果想更好的使用协程高并发处理任务的话并不是一件容易的事情,我们经常会了解多线程这个概念,那么在实际的场景中,这个多线程的多到底是多少才合适?这就是一个比较值得思考的问题。为了能更好的利用线程,慢慢的演化出了池化思想,没错,就是线程池,而到了协程,这个思想依旧通用,下面就来分享一下本篇文章的重点:Go语言中优雅的使用协程池。
为什么演进出了池化思想?
池化思想的演进主要源于对资源高效利用和系统性能优化的需求,它是一种将资源(如线程、数据库连接等)预先创建并统一管理,以实现资源高效利用和降低开销的策略。
减少资源创建与销毁的开销:在多线程编程中,线程的创建和销毁是资源密集型的操作,需要消耗大量的时间和系统资源。通过线程池,可以预先创建并维护一组可复用的线程,避免了频繁创建和销毁线程的开销。
提升系统响应速度:池化技术通过预分配资源,使得系统在面对任务时能够迅速响应,无需等待资源的创建。这提高了系统的响应速度和吞吐量。
Go优雅的协程池:Ants
如果想让项目中的goruntine使用的更加高效,协程池似乎是一个必备的工具,因为Go语言非常的简单,自己手写一个协程池也并非难事,但是做为一名还未达到顶尖水平的Gopher,学习他人的优秀代码是一个需要经历的过程,众所周知,程序员的日常只有三件事:学习!学习!还是特么的学习!
话不多说,本次分享的Ants是一个非常好用的Go协程池包,它的Github地址:github.com/panjf2000/ants
使用前需要先下载依赖:
代码语言:shell复制go get -u github.com/panjf2000/ants/v2
然后我们只实现一个功能:计数,就是将一个名为num的变量从1加到10000,假设每次操作耗时1ms
(1)单线程
单线程场景下:
代码语言:go复制var num int32
func addNum(i int32) {
atomic.AddInt32(&num, i)
time.Sleep(time.Millisecond)
fmt.Println("now num =", num)
}
func TestNum(t *testing.T) {
runTimes := 10000
for i := 0; i < runTimes; i {
addNum(1)
}
fmt.Printf("result num = %d n ", num)
}
看下执行耗时,竟然执行了156秒:
代码语言:shell复制result num = 10000
--- PASS: TestNum (156.18s)
PASS
(2)初步使用Ants
现在我们启用Ants:
代码语言:go复制func TestAnt(t *testing.T) {
defer ants.Release()
var wg sync.WaitGroup
syncCalculateSum := func() {
addNum(1)
wg.Done()
}
runTimes := 10000
for i := 0; i < runTimes; i {
wg.Add(1)
_ = ants.Submit(syncCalculateSum) //需要执行的方法
}
wg.Wait()
fmt.Printf("running goroutines: %dn", ants.Running())
fmt.Printf("num = %d n ", num)
}
执行结果,竟然只有0.31秒!
代码语言:shell复制running goroutines: 8376
num = 10000
--- PASS: TestAnt (0.31s)
PASS
但它的弊端是竟然同时使用了8376个协程,同时使用大量资源对服务器无非是一次大的考验,因此不太优雅。
(3)使用Ants控制协程数量
想要优雅,我们可以使用有限协程数量的协程池,比如定个小目标,先用它100个:
代码语言:go复制func TestAntPool(t *testing.T) {
defer ants.Release()
var wg sync.WaitGroup
f := func() {
addNum(1)
wg.Done()
}
runTimes := 10000
pool, _ := ants.NewPool(100)
for i := 0; i < runTimes; i {
wg.Add(1)
_ = pool.Submit(f)
}
wg.Wait()
fmt.Printf("running goroutines: %dn", pool.Running())
fmt.Printf("num = %d n ", num)
}
执行结果,只有1.21秒,虽然耗时长了一点,但是优雅了许多!
代码语言:shell复制running goroutines: 100
num = 10000
--- PASS: TestAntPoolAndWithPanicHandler (1.21s)
PASS
(3)Ants的另一种使用形式
除此之外,Ants还支持另一种形式,执行具体的函数:
代码语言:go复制func TestAntWithFunc(t *testing.T) {
defer ants.Release()
runTimes := 10000
var wg sync.WaitGroup
p, _ := ants.NewPoolWithFunc(10, func(i interface{}) {
addNum(i.(int32))
wg.Done()
})
defer p.Release()
for i := 0; i < runTimes; i {
wg.Add(1)
_ = p.Invoke(int32(1)) //这个地方参数可以传结构体
}
wg.Wait()
fmt.Printf("running goroutines: %dn", p.Running())
fmt.Printf("finish all tasks, result is %dn", num)
}
此外,Ants还提供了NewMultiPool类,初始化多个协程池,可以根据预先定义的策略:轮询或者最少使用策略,从多个协程池中获取worker。
代码语言:go复制func TestMultiPool(t *testing.T) {
defer ants.Release()
runTimes := 10000
var wg sync.WaitGroup
f := func() {
addNum(1)
wg.Done()
}
//10表示初始化10个协程池,-1位置参数表示协程池的容量,值为-1时代表不限制容量
mp, _ := ants.NewMultiPool(10, -1, ants.RoundRobin)
defer func() {
_ = mp.ReleaseTimeout(5 * time.Second)
}()
for i := 0; i < runTimes; i {
wg.Add(1)
_ = mp.Submit(f)
}
wg.Wait()
fmt.Printf("running goroutines: %dn", mp.Running())
fmt.Printf("finish all tasks, result is %dn", num)
}
好了,关于Ants的使用我们先分享到这里~
协程池的应用场景
协程池并不是在任何时候任何业务里都适用,它也是有一些典型的应用场景的,比如:
高并发处理:当系统需要处理大量并发请求时,使用协程池可以有效地管理并发任务,避免资源耗尽,提高系统稳定性和性能。
资源密集型任务:对于需要较长时间执行或占用较多系统资源的任务,使用协程池可以控制并发数量,避免系统资源被过度占用。
任务调度和负载均衡:在需要对任务进行调度和负载均衡的场景中,协程池可以提供有效的任务排队和调度机制,确保任务能够按照预定的策略执行。
综上所述,Go语言的协程池在并发编程中具有重要的作用,通过合理使用协程池,可以优化系统资源的使用,提高并发性能和吞吐量,同时简化并发编程的复杂性。