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) 场景.