Go并发任务实战!

1. go并发基础回顾

"不要通过共享内存进行通信,而是通过通信共享内存"。Go 语言为什么鼓励我们遵循这一设计哲学,可以简单总结为以下三点:

首先,使用发送消息来同步信息相比于直接使用共享内存和互斥锁是一种更高级的抽象,使用更高级的抽象能够为我们在程序设计上提供更好的封装,让程序的逻辑更加清晰; 其次,消息发送在解耦方面与共享内存相比也有一定优势,我们可以将线程的职责分成生产者和消费者,并通过消息传递的方式将它们解耦,不需要再依赖共享内存; 最后,Go 语言选择消息发送的方式,通过保证同一时间只有一个活跃的线程能够访问数据,能够从设计上天然地避免线程竞争和数据冲突的问题;

本无主要分享实战内容,关于go协程及并发原理可以参考这篇文章goroutine及调度器GMP模型,此处不再赘述。

2. go并发任务实战

Go语言之所以厉害,是因为它在服务端的开发中,总能抓住程序员的痛点,以最直接、简单、高效、稳定的方式来解决问题。高并发是Golang语言最大的亮点,因此接下来我们简单看看go如何实现各种并发任务的吧。

2.1 只运行一次的任务

只运行一次的任务即需要控制某个逻辑调用多少次都只能运行一次,相信在大家的开发中对这种需求并不陌生,最常见的也是大家最熟悉的单例模式了。话不多说,上代码。

go 复制代码
import (
	"fmt"
	"sync"
)

type Singleton struct {
	Name string
}

var (
	singleInstance *Singleton
	once           sync.Once
)

func GetSingleInstance() *Singleton {
	once.Do(func() {
		fmt.Println("init singleton")
		singleInstance = &Singleton{"single"}
	})
	return singleInstance
}

有没有发现很简单呢?为了验证我们还是写个测试跑一跑吧

go 复制代码
func TestSingleton(t *testing.T) {
	var wg sync.WaitGroup
	for i := 0; i < 6; i++ {
		wg.Add(1)
		go func() {
			t.Log(GetSingleInstance().Name)
			t.Logf("%x", GetSingleInstance())
			wg.Done()
		}()
	}
	wg.Wait()
}

运行结果

=== 复制代码
init singleton
    task_test.go:15: single
    task_test.go:15: single
    task_test.go:16: &{73696e676c65}
    task_test.go:15: single
    task_test.go:16: &{73696e676c65}
    task_test.go:15: single
    task_test.go:16: &{73696e676c65}
    task_test.go:15: single
    task_test.go:16: &{73696e676c65}
    task_test.go:15: single
    task_test.go:16: &{73696e676c65}
    task_test.go:16: &{73696e676c65}
--- PASS: TestSingleton (0.00s)
PASS

输出结果可以看到,只执行了一次init singleton,并且后面输出没个实例都是一样的哦,好了就是如此简单了,当然你也可以自己动手用加锁的方式去实现了。

2.2 任意一个结束就返回的任务

有的时候,我们是否需要同时去请求多个服务但是只要其中一个有结果就立刻返回了呢?比如我们日常生活中,早晚高峰期打车的时候,不知道是否同时打开滴滴、高德、百度等多个软件,同时打车呢?反正我是的,因为是在太难打了555。这个时候就只需要其中一个打到车就可以了。那go怎么实现呢?

go 复制代码
//定义一个简单的任务(如打车)
func doTask(taskId int) string {
	sleep := rand.Intn(100) * int(time.Millisecond)
	time.Sleep(time.Duration(sleep))
	return fmt.Sprintf("task-%d", taskId)
}

func FirstRspTask() string {
	taskNum := 6
	resp:=make(chan string)
	for i := 0; i < taskNum; i++ {
		go func(tId int) {
			resp <- doTask(tId)
		}(i)
	}
	return <-resp
}

首先定义一个打车任务,当前就只写了一个实现,用返回值模拟不同的实现(真实开发中应该会是不同的策略实现了)。然后里面每个任务都随机睡眠一段时间,为了模拟各服务提供方响应时间不一样,执行测试的时候,可以看到每次返回结果的服务都是随机的了。下面是模拟一个方法去同时调用多个服务,只要成功了一个就返回,通过channel的特性实现。return <-resp会阻塞直到有一个任务结束将结果写入这个channel。最后还是一样的写个测试方法验证一把了

go 复制代码
func TestFirstRspTask(t *testing.T) {
	resp := FirstRspTask()
	t.Log(resp)
}
diff 复制代码
=== RUN   TestFirstRspTask
    task_test.go:26: task-4
  
=== RUN   TestFirstRspTask
    task_test.go:26: task-2

运行多次你会看到,每次都是随机一个返回。 到此我们的功能就实现了,但是这里还有一个坑,请看下面的测试代码输出。

go 复制代码
func TestFirstRspTask(t *testing.T) {
	t.Logf("%d goroutine before task", runtime.NumGoroutine())
	resp := FirstRspTask()
	t.Log(resp)
	time.Sleep(time.Second) //确保任务都执行完了
	t.Logf("%d goroutine after task", runtime.NumGoroutine())
}
yaml 复制代码
=== RUN   TestFirstRspTask
    task_test.go:24: 2 goroutine before task
    task_test.go:26: task-1
    task_test.go:28: 7 goroutine after task
--- PASS: TestFirstRspTask (1.02s)
PASS

这段测试中,我把运行前后的goroutine数量都打印出来了,从输出中我们可以看到,虽然等任务都执行结束,但是协程数量并没有降下来,这是为什么呢?其实是因为channel的阻塞特性,上面代码中我定义的是resp:=make(chan string)一个不带缓冲区的channel,也就是只能写入一次,需要等待写入的数据被取出才能继续写入。因此,当第一个任务执行结束写入之后,结果被最后的return取走,后面其他任务写入就会被一直阻塞。这种情况如果不处理,就会导致每多一次请求,携程数量就多几个,然后就是协程泄露导致OOM异常。处理的方式也很简单,就是替换成一个带缓冲区的channelresp := make(chan string, taskNum),如下

go 复制代码
func FirstRspTask() string {
	taskNum := 6
	resp := make(chan string, taskNum)
	//resp:=make(chan string)
	for i := 0; i < taskNum; i++ {
		go func(tId int) {
			resp <- doTask(tId)
		}(i)
	}
	return <-resp
}

再次运行测试观察结果已经OK了。

yaml 复制代码
=== RUN   TestFirstRspTask
    task_test.go:24: 2 goroutine before task
    task_test.go:26: task-4
    task_test.go:28: 2 goroutine after task
--- PASS: TestFirstRspTask (1.02s)
PASS

2.3 所有任务结果都需要返回的任务

需要所有子任务都返回的任务场景应该比单个返回的更加常见。比如需拿到多个渠道数据,做一个整体排序;经常一个API接口都需要请求多个RPC,将所有结果打包成API的需要的数据格式等。

go 复制代码
import (
	"fmt"
	"math/rand"
	"strings"
	"sync"
	"time"
)

//真实场景一般是多个RPC,这里依然使用任务ID,模拟是不同的RPC
func callRpc(taskId int) string {
	sleep := rand.Intn(100) * int(time.Millisecond)
	time.Sleep(time.Duration(sleep))
	return fmt.Sprintf("call rpc-%d", taskId)
}
func AllRspTask() string {
	taskNum := 6
	respCh := make(chan string, taskNum)
	for i := 0; i < taskNum; i++ {
		go func(tId int) {
			respCh <- callRpc(tId)
		}(i)
	}
	var resp strings.Builder
	for i := 0; i < taskNum; i++ { //拿到所有结果再返回
		resp.WriteString(<-respCh)
		resp.WriteString("\n")
	}
	return resp.String()
}

和单个任务一样,定义了一rpc方法,根据ID模拟调用不同的rpc,然后将结果写入channel。主要的区别在返回结果的时候,单个返回的时候,接收到第一个结果就立刻返回了;但是在需要聚合所有结果的任务中,需要遍历获取聚合所有的结果最后统一返回。是不是也很简单呢?话不多说,赶紧测试一下吧

go 复制代码
func TestAllRspTask(t *testing.T) {
	resp := AllRspTask()
	t.Log(resp)
}

运行结果如下,会输出所有的请求结果。

r 复制代码
=== RUN   TestAllRspTask
    task_test.go:34: call rpc-2
        call rpc-3
        call rpc-4
        call rpc-5
        call rpc-0
        call rpc-1

当然这种情况下可以直接用锁的方式实现,虽然不是使用的channel,但也是一种常见的方式。

go 复制代码
func AllRspTaskV2() string {
	taskNum := 6
	var wg sync.WaitGroup
	resp := make([]string, taskNum)
	for i := 0; i < taskNum; i++ {
		wg.Add(1)
		go func(tId int) {
			defer func() {
				if r := recover(); r != nil {
					//捕获panic异常
				}
				wg.Done()
			}()
			resp[tId] = callRpc(tId)
		}(i)
	}
	wg.Wait()
	return strings.Join(resp, "\n")
}

2.4 循环任务的取消

任务的取消主要是指某一个或多个任务一直运行,但我们希望取消其中的一个任务,该如何实现呢?

go 复制代码
import (
	"fmt"
	"time"
)

func loopTask(cc chan struct{}, taskId int) {
	for {
		if Cancelled(cc) { //取消就结束循环
			break
		}
		//没有结束则睡眠10毫秒
		time.Sleep(10 * time.Millisecond)
	}
	fmt.Printf("task-%d cancelled\n", taskId)
}
func CancelTask() {
	cc := make(chan struct{})
	for i := 0; i < 6; i++ {
		go loopTask(cc, i)
	}
	time.Sleep(5 * time.Millisecond) //先执行一段时间
	cancelOne(cc)                    //取消一个任务
	time.Sleep(time.Second)
}

func Cancelled(cc <-chan struct{}) bool {
	select {
	case <-cc: //收到消息返回true
		return true
	default:
		return false
	}
}

func cancelOne(cc chan<- struct{}) {
	cc <- struct{}{}
}

首先是一个一直循环执行的任务loopTask,里面就简单模拟for循环,操作也就是睡眠10毫秒,不断检查任务是否需要取消。CancelTask模拟取消任务操作,创建6个协程去执行循环任务,一段时间后执行取消操作,然后结束。loopTask利用Cancelled来判断是否需要取消任务,其实现也很简单,就是利用select去取消channel获取值,拿到了则返回true否则返回false。当然你也就能想到CancelTask中的cancelOne实现了,其实就是往取消的通道中写入一个值。因为并不需要知道是什么,所以都直接写入的空结构体,这也是一种常见做法。

go 复制代码
func TestCancelTask(t *testing.T) {
	t.Logf("%d goroutine before task", runtime.NumGoroutine())
	CancelTask()
	t.Logf("%d goroutine after task", runtime.NumGoroutine())
}
go 复制代码
=== RUN   TestCancelTask
    task_test.go:38: 2 goroutine before task
task-1 cancelled
    task_test.go:40: 7 goroutine after task

从上面的测试输出可以看到,任务1被取消了。同时通过goroutine数量也可以看到,结束后比结束前多了5个,也是符合预期的,因为我们前面起了6个,取消了一个。 对于取消任务,可能大家会想我要取消所有的任务呢?其实这个实现起来也非常的简单,只需要把上面的cancelOne替换为下面的方法即可。

go 复制代码
func cancelAll(cc chan<- struct{}) {
	close(cc)
}

唯一的改动就是将通道写值改成了直接关闭通道,这主要是利用了channel关闭的广播机制,关闭的时候所有的阻塞通道都会收到消息也就能取消所有的任务了。我们替换之后再测试一次

go 复制代码
=== RUN   TestCancelTask
    task_test.go:38: 2 goroutine before task
task-1 cancelled
task-4 cancelled
task-3 cancelled
task-0 cancelled
task-2 cancelled
task-5 cancelled
    task_test.go:40: 2 goroutine after task

到此,利用channel取消一个或者所有任务的实现方式就介绍完了。但本文还没结束,我还想在加点料,那就是如何用context实现任务的取消。为什么要介绍它呢?因为用它可以非常方便的取消与之关联的子任务。比如任务A的有子任务B,任务B又有子任务C、D。基于context就能在取消B的同时直接取消C和D。赶紧来看看,若何做的吧。

go 复制代码
import (
	"context"
	"fmt"
	"time"
)

func loopTaskV2(ctx context.Context, taskId int) {
	for {
		if CancelledV2(ctx) { //取消就结束循环
			break
		}
		//没有结束则睡眠10毫秒
		time.Sleep(10 * time.Millisecond)
	}
	fmt.Printf("task-%d cancelled\n", taskId)
}
func CancelTaskV2() {
	ctx, cc := context.WithCancel(context.Background())
	for i := 0; i < 6; i++ {
		go loopTaskV2(ctx, i)
	}
	time.Sleep(5 * time.Millisecond) //先执行一段时间
	cc()                             //取消任务
	time.Sleep(time.Second)
}

func CancelledV2(ctx context.Context) bool {
	select {
	case <-ctx.Done(): //接收取消信号
		return true
	default:
		return false
	}
}

简单总结下,本文主要介绍了如何玩转go并发之任务,包括执行一次,一个结束就返回的任务,多个都结束返回的任务,以及任务的取消(取消一个及所有任务),希望对你有所帮助。

友情链接

gorm gen 安全高效的gorm

相关推荐
梁梁梁梁较瘦1 天前
边界检查消除(BCE,Bound Check Elimination)
go
梁梁梁梁较瘦1 天前
指针
go
梁梁梁梁较瘦1 天前
内存申请
go
半枫荷1 天前
七、Go语法基础(数组和切片)
go
梁梁梁梁较瘦2 天前
Go工具链
go
半枫荷2 天前
六、Go语法基础(条件控制和循环控制)
go
半枫荷3 天前
五、Go语法基础(输入和输出)
go
小王在努力看博客3 天前
CMS配合闲时同步队列,这……
go
Anthony_49264 天前
逻辑清晰地梳理Golang Context
后端·go
Dobby_055 天前
【Go】C++ 转 Go 第(二)天:变量、常量、函数与init函数
vscode·golang·go