简述
一个WebServer中,我们往往需要在服务停止前等待已有的任务完成,避免强制当断打断了业务流程,导致事务性操作被意外破坏。
优雅停止中还有一个超时的问题,但不在本文讨论范围,就跳过咯。
也就是常说的柔性关闭,也有说是优雅中止的。golang通过chan的设计,可以很方便的实现优雅中止,但实际运用中却也有些坑要小心。
业务模型
Server接收Client投递的Task,并将Task排入Queue逐个完成,假设Task都是一些耗时任务,可能有以下原型(伪代码):
var server Server
var taskCh chan Task
func Start(){
for {
select{
case <-server.Done():
close(taskCh)
case task,ok := <-tackCh:
if !ok{
return
}
Handle(task) // 处理Task的方法,实现略
}
}
}
func (s *Server) Close(){
// 这里还可以回收各种业务对象
// 假设Server中有个done chan struct{}对象
close(s.done)
}
func (s *Server)Done() <-chan struct{}{
return s.done
}
大体上,以上的逻辑是可行的,当执行server.Close()
的时候,关闭了内部的done
信道,则Start()
中的循环会执行到case <-server.Done()
,然后关闭taskCh
,拒绝接受新的Task,并开始处理剩下的task,直到case task,ok := <-tackCh:
中的ok
变为false
为止,结束方法并返回。
理论上这个设计既简单也使用,但实际使用的时候,却发现有时候会panic
(不是每次,随机发生),输出如下:
panic: close of closed channel
也就是重复关闭了taskCh
,这是怎么回事呢?踩了一天的坑以后,笔者找到了以下原因,不确定是否是对的,但至少现在笔者的问题解决了。
主要与golang的两个语言特性(功能设计)有关:
close的信道
当一个信道被关闭之后,这个信号仍然是可以被<-
消费的,只是返回的ok
的取值变成了false
,而且可以持续返回结果。
var done = make(chan struct{})
close(done)
res,ok:=<-done
fmt.Println(res,ok) // {} false
res,ok=<-done
fmt.Println(res,ok) // {} false
select的抉择
select
关键字让我们可以从语法层面方便的实现监听多个信道返回的结果,以实现其他语言中类似TaskWait(...)
的效果。
老司机也许能看出这是什么语言……
如果select
监听的多个信道同一时间只有一个信道有消息,那么使用自然没有问题,然而,并发之所以不好处理正是由于其联动所带来的复杂度。
诚然,go的chan设计已经极大地简化了并发设计要考虑的东西。
当多个信道同时返回消息的时候,应该优先处理哪个呢?也许有童鞋会说,虽然在宏观的视角,看到的东西是并发的,但在微观的视角,多个信道总会有先来后到,只要按照这个时序处理即可。
道理是没错,但是实现起来会有很多问题,多核的场景先不说,这种策略实际上等价于要将多个信道的结果投递到同一个信道中重新排序,无论是实现还是使用都会极大增加复杂度,而且可读性随着信道嵌套的增加会呈指数级下降。
其实本质与goroutine的调度与传统的thread不同也有关系,轮到select的goroutine执行的时候,各个信道都有东西是非常非常正常的。
所以,golang的设计者们采用的是伪随机选择的策略,也就是说,当多个信道同时有结果被返回的时候,会随机选择一条信道进行接收处理。
笔者一手由果推因的逻辑玩得出神入化Orz
解决
结合上述两个特性,select
完全有可能在特定场景下持续收到done
信号,而且由于done
信号是通过close(done)
,所以也无法通过ok
来判定到底是什么情况下收到,而重复关闭taskCh
的结果就会导致panic
。
为了处理这个问题,笔者曾经尝试加锁,由于具体业务的复杂性,服务中止时要回收的对象种类繁多,加锁的方案非常的复杂,而且会各种异常和死锁。
车牌号
go build -race
,咦?我在说啥呢……
虽然有-race
的黑科技,但仍架不住天生的复杂度,而且锁加多了,性能毕竟会受到严重的影响,从收益和投入来看并不值得。另外,针对具体业务加锁的方案显然不具备适用性,各个场景要回收的东西需要加不同的锁,无论是开发还是维护都非常麻烦。
笔者加了一天锁以后,终于在第二天早上忽然想到了一个简单的方案。
如果有更好的方案欢迎告诉笔者,感谢!!
实际上,这个场景会panic的核心问题在于信道的关闭不能重复执行,而golang的官方包中恰恰有针对不能重复执行的的工具。
伪代码中表现为信道的close仅是为了简化逻辑,实际业务中可能是一组对象的回收过程(重复回收往往也会报错)。
答案就是sync.Once{}
,Once
的Do方法可以保证该方法只会执行一次,与其通过深度耦合业务的逻辑控制,防止close(taskCh)
被重复调用,不如直接控制它只能执行一次。
var closeOnce = new(sync.Once)
func Start(){
for {
select{
case <-server.Done():
// 这样无论Done被select多少次,都不用担心重复回收了。
closeOnce.Do(func(){
close(taskCh)
})
case task,ok := <-tackCh:
if !ok{
return
}
Handle(task) // 处理Task的方法,实现略
}
}
}
小结
小结一下,笔者首先在处理并发并没有仔细考虑done方案的回收逻辑,发现问题的时候也只是想通过堆锁的方式去解决,最后踩了个大坑,sync.Once
其实笔者一直都有使用,但这里需要控制唯一性的时候却恰恰想不到……不得不说是灯下黑啊,故写下本文,以警示自己,也希望能给各位童鞋带来一点启发。
想通这个问题的笔者只能说内牛满面,花了一天的时间折腾的锁由此也可以全部删掉了Orz