-
Notifications
You must be signed in to change notification settings - Fork 162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
基于SingleFlight思想实现基于时间的线程安全优先级队列 #114
Conversation
Codecov Report
@@ Coverage Diff @@
## dev #114 +/- ##
==========================================
+ Coverage 95.42% 95.81% +0.38%
==========================================
Files 28 29 +1
Lines 1269 1505 +236
==========================================
+ Hits 1211 1442 +231
- Misses 43 48 +5
Partials 15 15
Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here. |
Signed-off-by: longyue0521 <longyueli0521@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
我大概理解这个思路,我试试能不能简化一下这个代码。我觉得可以启用显式地 Start 和 Close 的方法,或者我们在创建的时候直接 Start,在关闭的时候要求用户主动关闭。
这样子的话在 Enqueue 和 Dequeue 里面的代码就简单多了。
我觉得从思路上来说应该要简单很淡。你可以看一下我的实现,我的实现接近 DB pool 的那种搞法,就是用了 req,然后 req 里面维持了一个 channel 来接收唤醒信号。
缺陷就是性能会很差,主要是 make channel 的问题。好处就是不需要 Close 也不需要 Start。
这两个实现暂时先留着,我后面做做性能分析,然后确定最终的方案。
主要是我觉得这个思路没问题,但是代码不知道能不能继续简化。我们可以通过要求用户主动 Start 或者 Close 来丢弃一部分状态变迁的代码,应该会更加清晰一些
@flyhigher139 你可以看看我们两种实现,看看的你的感受,或者也顺便做个benchmark 测试。 |
@flycash 老师的那个实现方案,我大概看了1遍半就差不多了,主要是我会用类似的思路考虑如何实现,逻辑很容易就补齐了 另外,我个人之前对CSP的并发模型理解比较浅,最近打算强化一下,暂时对这两个方案没有倾向性,如果不是这个情况,我估计大概率我会倾向于老师那个方案 |
func (d *DelayQueue[T]) Enqueue(ctx context.Context, t T) error {
// ....
d.startEnqueueProxy()
defer d.closeEnqueueProxy()
// ....
}
func (d *DelayQueue[T]) startEnqueueProxy() {
d.enqueueMutex.Lock()
defer d.enqueueMutex.Unlock()
d.enqueueCallers++
// 这里不但承担着“开启代理协程”的职责
// 还承担着检测代理协程是否退出(因panic而退出),如果是就重启的职责.
// 使用显示的Start方法只能移除“开启代理协程“的职责
// 移除”检测/重启代理协程“的职责,就需要确保代理协程绝不会因panic或其他异常退出.
if atomic.LoadInt64(&d.numOfEnqueueProxyGo) == 0 {
go d.enqueueProxy()
<-d.continueSignalFromEnqueueProxy
}
}
func (d *DelayQueue[T]) closeEnqueueProxy() {
d.enqueueMutex.Lock()
defer d.enqueueMutex.Unlock()
d.enqueueCallers--
// “关闭代理协程”的逻辑比较容易移到Stop/Close方法中,
// 可以用close(closeSignalChan) + wg.Wait()的方式,或者 closeCtxCancelFunc() + wg.Wait()来实现
if d.enqueueCallers == 0 && atomic.LoadInt64(&d.numOfEnqueueProxyGo) == 1 {
d.quitSignalForEnqueueProxy <- struct{}{}
<-d.continueSignalFromEnqueueProxy
}
} enqueueProxy 协程中也包含与Enqueue同步、处理enqueueProxy内部panic等非核心逻辑代码. func (d *DelayQueue[T]) enqueueProxy() {
defer func() {
// 如果有panic的话,吞掉panic
_ = recover()
// 重置状态——enqueueProxy已退出
atomic.CompareAndSwapInt64(&d.numOfEnqueueProxyGo, 1, 0)
// 与Enqueue同步,退出
d.continueSignalFromEnqueueProxy <- struct{}{}
// 上面两行代码可以使用
// d.stopWaitGroup.Done()来替代
}()
// 设置状态——enqueueProxy已启动
atomic.CompareAndSwapInt64(&d.numOfEnqueueProxyGo, 0, 1)
// 与Enqueue同步,开始执行
d.continueSignalFromEnqueueProxy <- struct{}{}
// 上面两行代码可以使用
// d.startWaitGroup.Done()来替代.
// .....
} 那么Start和Stop方法可以这样实现 func (d *DelayQueue[T]) Start() {
d.stopWaitGroup.Add(2)
d.startWaitGroup.Add(2)
go d.enqueueProxy()
go d.dequeueProxy()
d.startWaitGroup.Wait()
}
func (d *DelayQueue[T]) Stop() {
d.stopCtxCancelFunc() // close(d.stopSignalChan)
d.stopWaitGroup.Wait()
} 最后,代理协程异常退出用户是无感知的,异常退出会导致Enqueue/Dequeue上的并发协程要么ctx超时退出,要么永久阻塞(如果没设置超时). |
Signed-off-by: longyue0521 <longyueli0521@gmail.com>
Signed-off-by: longyue0521 <longyueli0521@gmail.com>
Signed-off-by: longyue0521 <longyueli0521@gmail.com>
Signed-off-by: longyue0521 <longyueli0521@gmail.com>
Signed-off-by: longyue0521 <longyueli0521@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
大体上,这种思路确实可以做出来,但是还是很难让人理解清楚。这个调度比之前我们的 TaskPool 的实现还要复杂一点,原因在于里面使用了很多个 channel 和 sync 工具,所以对于用户来说,包括对于我来说,只能做到大概理解这个算法。
queue/delay_queue.go
Outdated
d.stoppedProxiesWaitGroup.Add(proxies) | ||
d.startedProxiesWaitGroup.Add(proxies) | ||
go d.enqueueProxy() | ||
go d.dequeueProxy() | ||
d.startedProxiesWaitGroup.Wait() | ||
atomic.AddInt64(&d.numOfEnqueueProxyGo, 1) | ||
atomic.AddInt64(&d.numOfDequeueProxyGo, 1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
我有点困惑,就是如果我们采用这种方案的话,我的理解是只需要一个 queue 维持一个 goroutine 来处理入队,一个 goroutine 来处理出队。那么并不需要这个 waitGroup,也不需要维持住这些计数。
queue/delay_queue.go
Outdated
if capacity <= 0 { | ||
return nil, fmt.Errorf("%w: capacity必须大于0", errInvalidArgument) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
这个限制我觉得不太好。虽然我之前吐槽过用户应该永远使用有界队列,但是在优先级队列本身允许无界的情况下,那么这个也应该考虑允许它是无界队列。这就限制住了你并不能使用 expiredElement 这个 channel。
之前我大概沿着你的思路去,我是尝试额外引入了一个信号量,用于接收入队的信号的。后来发现还是需要引入一些中间结构,所以我最终觉得这种思路复杂度也太高。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
expireElement
之前承担着“缓存过期数据”及“与调用者协程们通信”两项职责.现在前一个职责由d.qCache
承担,它现在只承担“与调用者协程们通信”的职责,故将其重命名为dequeueElemsChan
并且取消了缓冲区.
Signed-off-by: longyue0521 <longyueli0521@gmail.com>
Signed-off-by: longyue0521 <longyueli0521@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- 我觉得对 proxy 的复杂生命周期控制是不需要的。只需要有一个 Close 的方法,收到这个就信号就直接退出。再之后也不用管用户 Close 了是不是还在用,都不需要;
- 我认为 expireElement 这个东西太影响性能了,尤其是它必须保持和 capacity 一样的大小
我现在的想法是,等待 sync.Cond 支持 Wait 调用,在这之前不支持 ctx 超时控制的阻塞队列。
Signed-off-by: longyue0521 <longyueli0521@gmail.com>
如果图小看不清,可以右击在新标签页中打开图片链接. 程序实体介绍
Enqueue流程
Dequeue流程
|
好的 |
解决 #106
enqueueProxy
和dequeueProxy
两个协程将原内部互斥锁上的并发量从M:N
降为1:1
,减少不必要的锁竞争.enqueueProxy
通知dequeueProxy
协程重新获取队头即可,不要频繁唤醒所有阻塞在Dequeue上的并发调用者协程enqueueProxy
和dequeueProxy
协程无法被复用,当这样调用较多时会有创建/销毁代理协程的开销。enqueueProxy
和dequeueProxy
协程