节流游泳池
#go #howto #code

在我的last post中,我提出了一些技术,可用于限制GO中的同时操作。这样的座椅确实是一种非常整洁的工具,可以控制我们的资源。但是,当我们将节流机与对象池混合时,我们可以将这个想法扩展到更好的资源控制工具。

go中的对象池

即使Golang具有非常有效的内存管理系统,在某些情况下,我们想自己管理内存。可能有很多原因。人们可能希望确保分配的内存量不会超过一些预定义的限制。可能也可能是,由于CPU的巨大成本,频繁的分配和记忆交易开始引起问题。这些因素在具有严格的记忆预算(例如数据库)的更复杂的系统中尤为重要。事实证明,我有机会与一个称为immudb的数据库合作一年多了。

immudb中,交易中有一些larger scratchpad objects在加载数据库时是preallocated。一旦作者开始准备交易,这种 scratchpad 对象是taken from the poolreturned 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中找到具有所有测试和基准测试的完整源代码。