当前位置: 首页 > 科技观察

GoCommonsPool发布及Golang多线程编程问题总结

时间:2023-03-17 14:26:37 科技观察

趁着元旦假期,整理了一个最近在学习Golang时“翻译”的Golang公共对象池,放在了github上(https://github.com/jolestar/go-commons-pool)开源。之所以叫“翻译”,是因为这个库的核心算法和逻辑是基于ApacheCommonsPool的,只是把原来的Java“翻译”成Golang。前段时间看kubernetes源码的时候,整体学了golang,但是这门语言是不需要学的东西,过几周就忘记了。有一次在Golang实践群聊天,有人问Golang有没有通用的对象池。搜索了一下,好像没有比较全的。Golang目前的pool有以下解决方案:1.sync.Poolsync.Pool使用起来很简单,只需要传一个func就可以创建一个对象。varobjPool=sync.Pool{New:func()interface{}{returnNewObject()}}p:=objPool.Get().(*Object)但是sync.Pool只是解决了对象复用的问题,对象的生命周期在pool循环在两次GC之间。GC之后,池中的对象会被回收。用户无法控制对象的生命周期,因此不适合在连接池等场景中使用。2.通过container/list实现一个自定义的pool,比如redigo就是使用这种方式。但这些自定义池大多不通用,功能也不完备。比如redigo目前没有获取连接池的超时机制。请参阅此问题BlockingwithtimeoutwhenGetPooledConn。Java中的commonspool功能比较完善,算法和逻辑都经过验证,应用广泛,所以直接翻译过来,顺便练习一下Golang的语法。作为一个通用的对象池,它需要包括以下主要功能:对象的生命周期可以被精确控制。Pool提供机制允许用户自定义对象的创建/销毁/验证逻辑。可以精确控制存活对象的数量。时长配置获取对象有超时机制避免死锁,方便用户实现故障转移。之前遇到过很多线上故障,都是连接池设置或者实现机制有缺陷导致的。ApacheCommonsPool的核心是基于LinkedBlockingDeque,空闲对象放在deque中。之所以是deque,而不是queue,是因为它支持LIFO(lastinfirstout)/FIFO(firstinfirstout)两种策略获取对象。然后是一个包含所有对象的Map,key是一个自定义对象,value是一个PooledObject,用来验证ReturnObject的合法性,在后台调度放弃时遍历,计算活动对象的数量。超时是通过Java锁的等待超时机制实现的。下面总结一下Java转Golang遇到的多线程问题。递归锁或可重入锁(RecursiveLock)Java中的synchronized关键字和LinkedBlockingDequeu中使用的ReentrantLock都是可重入的。Golang中的sync.Mutex是不可重入的。表达式为:ReentrantLocklock;publicvoida(){lock.lock();//dosomethinglock.unlock();}publicvoidb(){lock.lock();//dosomethinglock.unlock();}publicvoidall(){lock.lock();//dosomethinga();//dosomethingb();//dosomethinglock.unlock();}在上面的例子中,all方法嵌套调用了a方法,虽然调用a方法时也需要加锁,但是因为都有申请锁,而且锁是可重入的,所以不会造成死锁。同样的代码在Golang中会造成死锁:lock.Lock()//dosomethinga()//dosomethingb()//dosomethinglock.Unlock()}只能重构为如下(命名不规范请忽略,只是demo)varlocksync.Mutexfunca(){lock.Lock()a1()lock.Unlock()}funca1(){//dosomething}funcb(){lock.Lock()b1()lock.Unlock()}funcb1(){//dosomething}funccall(){lock.Lock()//dosomethinga1()//dosomethingb1()//dosomethinglock.Unlock()}Golang的核心开发者认为可重入锁是一个糟糕的设计,所以没有提供,参见Recursive(akareentrant)mutexes是个坏主意。所以我们在使用锁的时候,需要多注意嵌套和递归调用。锁等待超时机制Golang的sync.Cond只有Wait,没有Java中Condition那样的超时等待方法await(longtime,TimeUnitunit)。这样就无法实现LinkBlockingDeque的pollFirst(longtimeout,TimeUnitunit)等方法。有人提了一个issue,但是被拒绝了sync:addWaitTimeoutmethodtoCond.因此,一个等待超时的Cond只能通过channel机制来模拟。有关完整的源代码,请参阅go-commons-pool/concurrent/cond.go。typeTimeoutCondstruct{Lsync.Lockersignalchanint}funcNewTimeoutCond(lsync.Locker)*TimeoutCond{cond:=TimeoutCond{L:l,signal:make(chanint,0)}return&cond}/**returnremainwaittime,andisinterrupt*/func(this*TimeoutCond)WaitWithTimeout(timeouttime.Duration)(time.Duration,bool){//waitshouldunlockmutex,ifnotwillcausedeadlockthis.L.Unlock()deferthis.L.Lock()begin:=time.Now().Nanosecond()select{case_,ok:=<-this.signal:end:=time.Now().Nanosecond()returntime.Duration(end-begin),!okcase<-time.After(timeout):return0,false}}Map机制的问题这个问题严格来说不是多线程的问题。Golang的map虽然不是线程安全的,但是通过mutex的封装很容易实现。key的问题就是我们前面提到的map用来维护pool中的所有对象,key是用户自定义的对象,value是一个PooledObject。而Golang对map的key的约束是:go-spec#Map_typeskey类型的操作数必须完整定义比较操作符==和!=;因此键类型不能是函数、映射或切片。如果键类型是接口类型,则必须为动态键值定义这些比较运算符;失败将导致运行时恐慌。也就是说,key不能包含不可比较的值,比如slice、map、function。而我们的key是用户自定义的对象,没办法约束。所以借鉴了Java的IdentityHashMap的思想,将key转换为对象的指针地址。其实map中保存的是key对象的指针地址。typeSyncIdentityMapstruct{sync.RWMutexmmap[uintptr]interface{}}func(this*SyncIdentityMap)Get(keyinterface{})interface{}{this.RLock()keyPtr:=genKey(key)value:=this.m[keyPtr]this.RUnlock()returnvalue}funcgenKey(keyinterface{})uintptr{keyValue:=reflect.ValueOf(key)returnkeyValue.Pointer()}同时这样做的缺点是Pool中存放的对象必须是指针,不是价值对象。Pool中不能保存string、int等对象。其他题外话关于多线程Golang的test-race参数很有用。通过这个参数,发现了几个数据竞争错误。请参阅提交修复数据竞争测试错误。GoCommonsPool后续工作继续完善测试用例。已完成约一半的测试用例,覆盖率达88%。“翻译”的时候,主要代码写起来比较快,但是测试用例就麻烦多了,多线程情况下的调试也比较复杂。一般基础库的测试用例代码是核心逻辑代码的2-3倍。做下一个benchmark。核心算法应该没有问题,已经验证过了。但是用channel模拟超时的机制可能会有瓶颈。这块要考虑定时器的复用机制。参见Terry-Mao/goim。完成以上两项后,就可以准备发布正式版了。您可以通过此池改进redigo。【本文为专栏作家“王元明”原创稿件,转载请通过作者微信公众号jolestar-blog获得授权】点此查看该作者更多好文