实现一个简易的golang版本的CompletetableFuture
背景
将入侵4.0 java代码迁移到入侵5.0 golang项目时,有些并行处理的功能用到了java的CompletetableFuture框架,但是golang中好像没有类似的框架(可能我没有找到),所以打算手动写一个简易的golang版本的CompletetableFuture。
在入侵java代码中用到CompletetableFuture的地方:
1、多个任务同时执行,设置了超时时间,最后获取每个任务的执行结果
2、多个任务同时执行,设置了超时时间,不需要获取每个任务的执行结果
当然CompletetableFuture还有很多其他特性,但是java版本目前主要是用到了这两个,或者是基于这两个的一些变种。
实现思路
CompletetableFuture特性
在实现一个golang版本的CompletetableFuture框架前,先了解下对于以上2种场景,CompletetableFuture的特性:
- 任务执行超时后,会抛出TimeoutException,如果catch住异常,调用方还是可以取到每个任务的执行结果
- 某一个任务报错,会抛出第一个异常,如果catch住异常,还是能取到其他正常任务的执行结果
场景分析
- 对于上面的第1种场景,需要封装。
- 对于上面的第2种场景,因为不需要返回值,其实直接写几个goroutine就行了,不需要封装。
实现思路
golang中的并发框架默认的有goroutine、sync.WaitGroup,以及第三方库golang.org/x/sync/errgroup,可以考虑封装这些并发框架
实现目标
- 整个任务结束后,可以获取每个任务的返回值
- 可以设置整个任务的超时时间,任务超时后,会抛出一个timeout的error,调用方还是可以获取超时前正常执行任务的执行结果
- 当有任务出现error时,返回出现的第一个error,调用方还是可以获取每个任务的执行结果
实现代码
关键字说明
在写代码前,先介绍下golang中的关键字/第三方库:
atomic
- 提供了原子操作的相关函数,可以实现无锁并发编程
goroutine
- 轻量级:goroutine是一种轻量级的线程(协程),启动和销毁成本非常低,可以轻松的创建大量的goroutine
- 平等调度:所有的goroutine是平等的被调度执行的,没有父子goroutine的概念
- 非阻塞:执行是非阻塞的,不会等待。这意味着一个goroutine不会阻塞其他goroutine的执行
- channel通信:支持利用channel安全的进行通信
select
- 阻塞:当select不包含default,且没有任何通道准备好进行通信时,select语句会阻塞,直到至少有一个通道准备好通信
- 随机选择:当有多个通道都准备好进行通信,select语句会随机选择一个准备好的通道进行操作
- 可中断:通过在某些通道上执行特定的操作(如关闭通道),可以中断正在阻塞的select语句。这种特性可用于取消操作、超时控制等
- 并发安全:多个 goroutine 可以同时使用select语句对相同的通道进行操作
channel
- 类型安全:每个导通都有一个特性的元素类型,只能传输该类型的值
- 并发安全:通道在并发环境下是安全的,多个goroutine可以同时对一个通道进行发送和接收操作,通道内部保证了并发操作的原子性和顺序性
- 传递引用:通道传递的是值的引用,而不是副本
- 阻塞特性:关闭操作:向已关闭的通道中发送会触发panic错误。关闭通道时,会发送一个零值到监听的select中
- 无缓冲:当通道没有接收者时,发送操作会被阻塞,直到有接收者准备好接收值。同样地,当通道没有发送者时,接收操作会被阻塞,直到有发送者发送值到通道
- 有缓冲:发送操作会在通道已满时被阻塞,直到有接收者从通道中取走一个值。同样地,接收操作会在通道为空时被阻塞,直到有发送者发送一个值到通道
- 关闭操作:向已关闭的通道中发送会触发panic错误。关闭通道时,会发送一个零值到监听的select中
WaitGroup
- 计数功能:WaitGroup通过维护一个计数器来追踪正在运行的goroutine的数量。每个需要等待的goroutine在启动时调用Add方法增加计数器,而在结束时调用Done方法减少计数器
- 阻塞等待:调用Wait方法会阻塞当前的goroutine,直到计数器归零,也就是等待所有被追踪的goroutine都调用了Done方法
- 可重用性:WaitGroup实例可以重复使用。在一组goroutine执行完毕后,可以通过再次调用Add方法添加新的等待项,然后再次调用Wait方法来等待新的一组goroutine的结束
- 并发安全:WaitGroup是并发安全的,其内部实现保证了对计数器的增减操作是原子的
errgroup
- 错误传播:errgroup会等待一组goroutine执行完成,并返回第一个发生的错误(如果有),并立马调用cancel方法
- 并发执行:底层封装的WaitGroup,具有WatiGroup的特性
CompletableFuture:任务及执行结果
package async type CompletableFuture[T any] struct { Supplier Supplier[T] Result chan T } // Get 获取任务执行结果 func (c *CompletableFuture[T]) Get() *T { var defaultVal *T if len(c.Result) == 0 { return defaultVal } val, ok := <-c.Result if !ok { return defaultVal } return &val } func SupplyAsync[T any](supplier Supplier[T]) *CompletableFuture[T] { return &CompletableFuture[T]{ Supplier: supplier, Result: make(chan T, 1), } } func All[T any](futures ...*CompletableFuture[T]) []*CompletableFuture[T] { if len(futures) == 0 { return nil } futureList := make([]*CompletableFuture[T], len(futures)) for index, future := range futures { futureList[index] = future } return futureList } // Close 关闭每个任务中的通道 func Close[T any](futures []*CompletableFuture[T]) { if len(futures) == 0 { return } for _, future := range futures { close(future.Result) } }
Supplier:任务
package async type Supplier[T any] struct { Apply func() (T, error) } func NewSupplier[T any](f func() T) Supplier[T] { supplier := Supplier[T]{ Apply: func() (T, error) { return f(), nil }, } return supplier }
实现多任务并行执行,有三种方式:
- 使用 atomic + goroutine + select + channel 实现多个任务并行执行
- 使用 WaitGroup + goroutine + select + channel 实现多个任务并行执行(推荐)
- 使用 errgroup + goroutine + select + channel 实现多个任务并行执行
第1种方式:
package async import ( "context" "errors" "sync/atomic" "time" ) // Execute 使用 atomic + goroutine + select + channel 实现多个任务并行执行 func Execute[T any](futures []*CompletableFuture[T], timeout time.Duration) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() doneChan := make(chan struct{}) errChan := make(chan error) var counter int32 for _, future := range futures { go func(f *CompletableFuture[T]) { defer func() { if atomic.AddInt32(&counter, 1) == int32(len(futures)) { closeChan(doneChan, errChan) } }() supplier := f.Supplier result := f.Result res, err := supplier.Apply() if err != nil { errChan <- err return } result <- res }(future) } select { case err := <-errChan: // 返回遇到的第一个error return err case <-ctx.Done(): // ctx超时后,通道没有关闭,所以这里需要关闭通道 defer closeChan(doneChan, errChan) // 返回ctx timeout return errors.New("ctx timeout") case <-doneChan: return nil } } func closeChan(doneChan chan struct{}, errChan chan error) { close(doneChan) close(errChan) }
第2种方式:
package async import ( "context" "errors" "sync" "time" ) // ExecuteWithWaitGroup 使用 WaitGroup + goroutine + select + channel 实现多个任务并行执行 func ExecuteWithWaitGroup[T any](futures []*CompletableFuture[T], timeout time.Duration) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() wg := &sync.WaitGroup{} doneChan := make(chan struct{}) errChan := make(chan error) for _, future := range futures { wg.Add(1) go func(f *CompletableFuture[T]) { defer wg.Done() supplier := f.Supplier result := f.Result res, err := supplier.Apply() if err != nil { errChan <- err return } result <- res }(future) } go func() { wg.Wait() closeChan2(doneChan, errChan) }() select { case err := <-errChan: // 返回遇到的第一个error return err case <-ctx.Done(): // ctx超时后,不会执行wg.Wait()后的代码,所以这里需要关闭通道 defer closeChan2(doneChan, errChan) // 返回ctx timeout return errors.New("ctx timeout") case <-doneChan: return nil } } func closeChan2(doneChan chan struct{}, errChan chan error) { close(doneChan) close(errChan) }
第3种方式:
package async import ( "context" "errors" "golang.org/x/sync/errgroup" "time" ) // ExecuteWithErrGroup 使用 errgroup + goroutine + select + channel 实现多个任务并行执行 func ExecuteWithErrGroup[T any](futures []*CompletableFuture[T], timeout time.Duration) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() doneChan := make(chan struct{}) errChan := make(chan error) eg, ctx := errgroup.WithContext(ctx) for _, future := range futures { supplier := future.Supplier result := future.Result eg.Go(func() error { res, err := supplier.Apply() if err != nil { errChan <- err return err } result <- res return nil }) } go func() { if err := eg.Wait(); err != nil { // do nothing } closeChan3(doneChan, errChan) }() select { case err := <-errChan: // 返回遇到的第一个error return err case <-time.After(timeout): // 超时后,不会执行eg.Wait()后的代码,所以这里需要关闭通道 defer closeChan3(doneChan, errChan) return errors.New("execute timeout") case <-doneChan: return nil } } func closeChan3(doneChan chan struct{}, errChan chan error) { close(doneChan) close(errChan) }
测试
测试场景
测试场景分为以下几种:
- 多个任务,都在超时之前正常执行完成
- 多个任务,有一个任务在超时前未完成
- 多个任务,在超时前,有一个任务返回error
- 多个任务,在超时后,有一个任务返回error
场景一
测试代码:
package test import ( "git.qingteng.cn/ms-public/go_study/pkg/util/async" "log" "testing" "time" ) type ResultInfo struct { Msg string } func TestAsyncUtil(t *testing.T) { param := "my param" param2 := "my param2" param3 := "my param3" // 超时时间 timeout := 5 * time.Second future1 := async.SupplyAsync(async.NewSupplier[ResultInfo](func() ResultInfo { return task1(param) })) future2 := async.SupplyAsync(async.NewSupplier[ResultInfo](func() ResultInfo { return task2(param2) })) future3 := async.SupplyAsync(async.NewSupplier[ResultInfo](func() ResultInfo { return task3(param3) })) futures := async.All(future1, future2, future3) defer async.Close(futures) err := async.Execute(futures, timeout) if err != nil { log.Printf("execute failed:%v\n", err) // 如果出现error,希望任务立马停止,这里可以放开return // return } // 打印每个任务的结果 log.Printf("future1的结果是:%v\n", *future1.Get()) log.Printf("future2的结果是:%v\n", *future2.Get()) log.Printf("future3的结果是:%v\n", future3.Get()) //future3返回nil,所以这里直接取值 } func task1(param string) ResultInfo { time.Sleep(1500 * time.Millisecond) // 模拟任务执行时间 log.Println("任务A完成,param --> " + param) return ResultInfo{Msg: "A"} } func task2(param string) ResultInfo { time.Sleep(2500 * time.Millisecond) // 模拟任务执行时间 log.Println("任务B完成,param --> " + param) return ResultInfo{Msg: "B"} } func task3(param string) ResultInfo { time.Sleep(3500 * time.Millisecond) // 模拟任务执行时间 log.Println("任务C完成,param --> " + param) return ResultInfo{Msg: "C"} }
测试结果:
2023/11/27 18:35:53 任务A完成,param --> my param 2023/11/27 18:35:54 任务B完成,param --> my param2 2023/11/27 18:35:55 任务C完成,param --> my param3 2023/11/27 18:35:55 future1的结果是:{A} 2023/11/27 18:35:55 future2的结果是:{B} 2023/11/27 18:35:55 future3的结果是:{C}
场景二
对于场景二,只需要调整场景一单测代码中的超时时间为3秒即可。
测试结果:
2023/11/27 19:02:19 任务A完成,param --> my param 2023/11/27 19:02:20 任务B完成,param --> my param2 2023/11/27 19:02:20 execute failed:ctx timeout 2023/11/27 19:02:20 future1的结果是:{A} 2023/11/27 19:02:20 future2的结果是:{B} 2023/11/27 19:02:20 future3的结果是:<nil>
场景三
对于场景三,需要修改supplier3方法:
future3 := async.SupplyAsync(async.Supplier[ResultInfo]{ Apply: func(any) (ResultInfo, error) { return task3(param3) }, }) func task3(param string) (ResultInfo, error) { time.Sleep(3500 * time.Millisecond) // 模拟任务执行时间 log.Println("任务C完成,param --> " + param) return ResultInfo{Msg: "C"}, errors.New("task3 error") }
测试结果:
2023/11/27 19:01:53 任务A完成,param --> my param 2023/11/27 19:01:54 任务B完成,param --> my param2 2023/11/27 19:01:55 任务C完成,param --> my param3 2023/11/27 19:01:55 execute failed:task3 error 2023/11/27 19:01:55 future1的结果是:{A} 2023/11/27 19:01:55 future2的结果是:{B} 2023/11/27 19:01:55 future3的结果是:<nil>
场景四
对于场景四,只需要调整场景三单测代码中的超时时间为3秒即可。
测试结果(和场景二是一样的):
2023/11/27 19:02:19 任务A完成,param --> my param 2023/11/27 19:02:20 任务B完成,param --> my param2 2023/11/27 19:02:20 execute failed:ctx timeout 2023/11/27 19:02:20 future1的结果是:{A} 2023/11/27 19:02:20 future2的结果是:{B} 2023/11/27 19:02:20 future3的结果是:<nil>
知识改变世界