Golang并发编程的正确打开姿势

1. 版权

本文为原创, 遵循 CC 4.0 BY-SA 版权协议, 转载需注明出处: https://blog.youkuaiyun.com/big_cheng/article/details/116920830.
文中代码属于 public domain (无版权).

2. 描述

假设我现在有一个定时处理任务: 每次处理一批数据; 处理完如果还有则继续处理下一批数据; 处理完如果没有了则等待3min; 等待期间如果有请求则等完后继续处理, 如果没请求则再等3min. 如此一直运行下去.

直接使用goroutine + select + channel 来实现上述流程控制, 示例代码如下:

var fooReqC = make(chan interface{})

func ctlFoo() {
	sec := 3 * 60 // 3min
	resultC := make(chan int)
	running := false
	scheduled := false // 预约?
	for {
		select {
		case i := <-resultC: // 执行完一次
			running = false
			if i == 1 || scheduled { // 再执行一次
				scheduled = false
				go foo(resultC)
				running = true
			}
		case <-time.After(time.Duration(sec * 1_000_000_000)):
			if !running && scheduled { // 再执行一次
				scheduled = false
				go foo(resultC)
				running = true
			}
		case <-fooReqC: // 来请求
			scheduled = true
		}
	} // for
}

func foo(resultC chan<- int) {
	// body
}

foo()代表一次处理, 每次处理完将1/0发送到resultC - 1代表要继续处理. 在主循环for loop里要同时考虑三种情况: 执行完了、等待完了、有请求. 例如等待完了: 等待期间是否在并发执行? 每个分支里都要考虑所有其他分支的影响. 直观感觉就是该代码可读性可维护性较差.

3. 分析

分析示例代码:

  • 首先不论是执行完还是等待完, 期间是否有请求都会影响下一次的选择
  • 其次执行和等待都是以从一个channel里接收到数据而结束

所以纯粹从代码结构上我们可以抽离出一个通用功能: 一边在一个channel上等一个结果, 一边从另一个channel不停地接收数据:

// 从waitC 接收一次, 并且期间持续从pollingC 接收.
// 返回从waitC 接收的Time值, 及期间是否从pollingC 接收了.
func waitWhilePolling(waitC <-chan time.Time, pollingC <-chan interface{}) (ti time.Time, p bool) {
Loop:
	for {
		select {
		case ti = <-waitC:
			break Loop
		case <-pollingC:
			p = true
		}
	}
	return
}

导致该方法返回的唯一条件是waitC接收到了结果, 但waitC阻塞期间方法一直在轮询pollingC - 这保证了pollingC 不会阻塞.

用抽离出的方法来改造原流程算法:

// FixedIdle调度:
//  - fn执行一次后, 如需继续执行 则再次执行; 否则停3min
//  - 停3min后, 如果期间有请求则再次执行, 否则再停3min
func ctlFixedIdle(fn func(chan<- time.Time), reqC <-chan interface{}) {
	waitC := make(chan time.Time)
	for {
		go fn(waitC)
		ti, reqed := waitWhilePolling(waitC, reqC)

		if !ti.IsZero() || reqed {
			// 继续
		} else {
			for {
				if _, reqed = waitWhilePolling(time.After(3*time.Minute), reqC); reqed {
					break
				}
			}
		}
	}
}

在并发执行fn 时一边等结果一边观察是否有请求. 等到结果后判断如果要继续则continue for, 否则再内嵌一个for loop 来一边等3min 一边观察是否有请求 - 没请求就一直等下去.

由此可见使用waitWhilePolling方式可轻易描述原控制算法, 并且代码更易读易调整.

4. 测试

并发代码的测试比较不容易. 以下的测试代码存在不完善, 可供参考.

测试之前我们将原算法稍微调整一下:

// onReq调度:
//  - 每次fn 执行完后, 发送Time值到参数channel
//  - 如果该Time值非零值, 或执行期间reqC 收到请求, 则继续执行一次
//  - 否则, 一直等到reqC 收到请求
func ctlOnReq(fn func(chan<- time.Time), reqC <-chan interface{}) {
	waitC := make(chan time.Time)
	for {
		go fn(waitC)
		ti, reqed := waitWhilePolling(waitC, reqC)

		if !ti.IsZero() || reqed {
			// 继续
		} else {
			<-reqC
		}
	}
}

就是不再等足3min, 而是一等到有请求就继续执行.

定义一个测试类型, 来封装支撑数据:

// onReq 测试类型
type onReqTestObj struct {
	i    int
	next bool // 是否继续

	reqC chan interface{}
	exitC chan interface{}
}

测试用执行方法fn:

// onReq fn
func (o *onReqTestObj) fn(waitC chan<- time.Time) {
	_fn_s0(o)
	o.i = o.i + 1

	_fn_s1(o)

	if o.next {
		waitC <- time.Now()
	} else {
		waitC <- time.Time{}
	}
}

每次执行分2步: 将o.i++、根据o.next 相应返回继续/不继续. 在每步之前各插入一个测试辅助方法 - 在其中实现测试逻辑.

onReq调度就是每次执行完后, 要么继续执行要么等到请求. 我们设计如下测试场景:
a) 不继续
b) 继续一次
c) 不继续但期间有请求
d) 继续且期间有请求

首先po出完整的测试代码:

// 测试流程:
//  - a) 不继续: i 0->1 =>reqC
//  - b) 继续一次: i 1->2 true=>next; i 2->3 false=>next =>reqC
//  - c) 不继续但期间有请求: i 3->4 期间=>reqC
//  - d) 继续且期间有请求: i 4->5 true=>next 期间=>reqC
func _fn_s1(o *onReqTestObj) {
	if o.i == 1 { // a)
		go func() {
			time.Sleep(2 * time.Second)
			o.reqC <- ""
		}()
	} else if o.i == 2 { // b)
		o.next = true
	} else if o.i == 3 { // b)
		o.next = false
		go func() {
			time.Sleep(2 * time.Second)
			o.reqC <- ""
		}()
	} else if o.i == 4 { // c)
		o.reqC <- ""
	} else if o.i == 5 { // d)
		o.next = true
		o.reqC <- ""
	}
}

a)
ctlOnReq主循环第一轮时: s0时i=0; s1时i=1. 所以i=1 是a) 场景.
这时主循环在等待fn =>waitC, 而s1 在=>waitC 之前, 所以s1 用一个goroutine 在2s 后=>reqC: 在这2s 内s1 应该执行完 -> fn 执行完 -> ctlOnReq 往下执行到等reqC -> 2s后之前s1 的goroutine =>reqC - 主循环第一轮得以结束.

注意如上这个2s的预期在原则上(应该)是不能保证的: 当这个2s goroutine运行完成时, 运行go fn(waitC) -> fn -> s1 的goroutine 是可能还没有运行到fn =>waitC 的 (具体会和当时的Go语言运行环境/实际存在多少goroutine/ 等很多因素随机有关). 当然通常在自己电脑上简单测试时2s 的间隔是足够了的. 另, 发生该情况时实际变成了c) 场景"不继续但期间有请求".

b) 可参照分析. 通过修改o.next 来切换场景.

c) 时s1 =>reqC: 这时fn 未结束, 主循环还waitWhilePolling 等在waitC 上. 此时fn -> s1 => reqC 会被主循环waitWhilePolling 轮询到 - 这就是为何前面定义waitWhilePolling 方法时要"保持pollingC 不被阻塞" 的原因, 否则fn -> s1 => reqC 一旦被阻塞, fn 无法结束, 主循环就会卡在waitWhilePolling (见后TestCtlOnReq, 这里的pollingC 是无缓存的).

s0 中止场景d) (主循环将卡在 <-reqC), 通知结束测试:

func _fn_s0(o *onReqTestObj) {
	if o.i == 5 {
		o.next = false
		o.exitC <- "" // 结束
	}
}

Go测试方法反而很简单 (因为逻辑都移到了s1):

func TestCtlOnReq(t *testing.T) {
	o := onReqTestObj{}
	o.reqC = make(chan interface{})
	o.exitC = make(chan interface{})

	go ctlOnReq(o.fn, o.reqC)
	<-o.exitC
}

留意Go的测试方法(应该)类似main方法, 只要方法本身完成 测试就完成 而不会等待其中的goroutine完成.

如上测试了4个场景, 其实还可以一边=>reqC 一边测试:

func TestCtlOnReq_concurReq(t *testing.T) {
	o := onReqTestObj{}
	o.reqC = make(chan interface{})
	o.exitC = make(chan interface{})

	go func() {
		for {
			o.reqC <- ""
		}
	}()
	go ctlOnReq(o.fn, o.reqC)
	<-o.exitC
}

在持续=>reqC 时, 有些a/b) 场景就应该对应变成了c/d) 场景.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值