在我的last post中,我提出了一些技术,可用于限制GO中的同时操作。这样的座椅确实是一种非常整洁的工具,可以控制我们的资源。但是,当我们将节流机与对象池混合时,我们可以将这个想法扩展到更好的资源控制工具。
go中的对象池
即使Golang具有非常有效的内存管理系统,在某些情况下,我们想自己管理内存。可能有很多原因。人们可能希望确保分配的内存量不会超过一些预定义的限制。可能也可能是,由于CPU的巨大成本,频繁的分配和记忆交易开始引起问题。这些因素在具有严格的记忆预算(例如数据库)的更复杂的系统中尤为重要。事实证明,我有机会与一个称为immudb的数据库合作一年多了。
在immudb
中,交易中有一些larger scratchpad objects在加载数据库时是preallocated。一旦作者开始准备交易,这种 scratchpad 对象是taken from the pool是returned back to it,一旦提交完成。
听起来很熟悉吗?看起来几乎与我在previous post中介绍的代币同时操作的数量相同。但是,我们可以获取有用的对象,而不是从池中获取令牌。 immudb
使用该机制来保持同时交易的数量under control。没有这样的限制,使用大量资源很容易 force 数据库(例如,如果数据库耗尽了内存,则可以称为DoS攻击)。
IMMUDB中的实现是based on a mutex,而在此博客文章中,我将重点介绍基于渠道的实现,该实现将与我们在基于令牌的节流机上看到的相似。
池塘刺
让我们现在进行一些锻炼,创建这样的节流池。
我决定在实施中使用generics。与其创建可在interface{}
类型(或其他混凝土)上使用的池,我们可以将该基础类型的选择留给池的用户。这样,我们可以避免大量的转换和类型的断言排除不必要的铸造成本。
与代币一样,基于池的油门的核心将再次成为一个渠道:
type poolLimiter[T any] struct {
ch chan T
}
如果基于令牌的节流器,该通道的初始化很简单,因为令牌类型是空的,没有任何信息。如果有一个池,则必须正确初始化池中的对象。因此,我决定在池中创建对象时添加专用的初始化函数:
func NewPoolLimiter[T any](poolSize int, gen func() T) PoolLimiter[T] {
ret := &poolLimiter[T]{
ch: make(chan T, poolSize),
}
// Preallocate the pool of objects
for i := 0; i < poolSize; i++ {
ret.ch <- gen()
}
return ret
}
现在是池的本质 - 获取和释放资源。让我们为不同的用例写几个实现,从最简单的用例开始。
阻止收购
最简单的撰写方法是封锁一个将等到池中至少有一个对象的封锁:
func (p *poolLimiter[T]) Acquire() T {
return <-p.ch
}
这里没有什么真正喜欢的 - 频道为我们提供了所需的功能。但是您也可能已经注意到,与the previous token-based implementations相反,我决定不从该方法返回发布功能。
相反
func (p *poolLimiter[T]) Release(item T) {
p.ch <- item
}
为什么会更改接口?我做了一些基准测试,这样实现几乎是快速的两倍(41 ns/op vs 72 ns/op)。其他功能值的开销在这里非常重要。因此,我还更改了原始代币的油门的界面(该代码甚至简化了通常是一个好兆头)。
作为旁注,到目前为止编写的代码看起来太琐碎了,不是吗?好吧,它只是证明了Golang的内置原语的设计确实很好。
非阻止收购
让我们现在转到更复杂的事情 - 获取如果池为空,将返回错误:
func (p *poolLimiter[T]) AcquireNoWait() (T, error) {
var item T
select {
case item = <-p.ch:
return item, nil
default:
return item, ErrResourceExhausted
}
}
这里没有什么真正花哨的 - 内置的select就是我们所需要的。
在此方法的开头创建并初始化了返回的项目,这可能有些令人惊讶。如果游泳池为空,则需要这一点。在这种情况下,我们仍然必须返回T
类型的某些实例以及错误。创建一个在开头初始化的默认值可以解决问题。
上下文感知获得
最复杂的(但仍然适合几行代码)是上下文感知:
func (p *poolLimiter[T]) AcquireCtx(ctx context.Context) (T, error) {
var item T
if ctx.Err() == nil {
select {
case item = <-p.ch:
return item, nil
case <-ctx.Done():
break
}
}
return item, ctx.Err()
}
该代码几乎与非阻止收购相同。如果池是空的,我们必须等待一些释放资源,则用select内置的select捕获上下文取消,可以使该功能放弃并返回错误。附加的if ctx.Err() == nil
可确保如果上下文已被取消,我们甚至都不会尝试获取资源。
锻炼 - 从游泳池象征
我们可以尝试将新写的池与较旧的令牌限制器进行比较。因此,我创建了一个简单的包装器struct
,封装了池,但是公开了令牌限制器接口:
type poolLimiterToTokenLimiter struct {
l PoolLimiter[struct{}]
}
func NewTokenLimiterFromPoolLimiter(maxTokens int) CancellableTokenLimiter {
return &poolLimiterToTokenLimiter{
l: NewPoolLimiter(maxTokens, func() struct{} { return struct{}{} }),
}
}
func (l *poolLimiterToTokenLimiter) Acquire() {
l.l.Acquire()
}
func (l *poolLimiterToTokenLimiter) AcquireNoWait() error {
_, err := l.l.AcquireNoWait()
return err
}
func (l *poolLimiterToTokenLimiter) AcquireCtx(ctx context.Context) error {
_, err := l.l.AcquireCtx(ctx)
return err
}
func (l *poolLimiterToTokenLimiter) Release() {
l.l.Release(struct{}{})
}
这种实现比专用的令牌限制器实现速度约为30%(48.54 NS/OP vs 36.90 ns/op)。我的猜测是,这主要是由间接呼叫和其他返回值引起的。我认为,GO编译器的未来版本将变得较小,该版本将能够嵌入功能并更好地检测死亡代码。
下一个
在这里仍未完全探索节流的话题,因此请继续关注更多更新。
和以前,您可以在Github repository中找到具有所有测试和基准测试的完整源代码。