Search code examples
goasynchronousgoroutine

Designing an automatic background job attached to a struct


Is there a way to program an automatic cancellation of a goroutine associated with a struct when the struct is no longer needed?

I implemented a simple cache for string values (potentially duplicates). Values are stored for a short period of time, so every entry has an expiration time and cache requires a background job that would remove expired values every N minutes.

So I came up with this solution:

type StrCache struct {
    // ... Values storage
    expiration time.Duration
}

func NewStrCache(expiration time.Duration) *StrCache {
    cache := StrCache{}
    cache.expiration = expiration
    go cache.backgroundCleanup()
    return &cache
}

func (s *StrCache) TryAdd(value string) (alreadyAdded bool) {
    // ... Logic of adding a new value 
}

func (s *StrCache) backgroundCleanup() {
    time.Sleep(s.expiration)
    // ... Removing expired values logic
    go s.backgroundCleanup()
}

This suites my use case since StrCache should live until app exits. But I don't like it's potentially leaky design: if at some point I would need to replace one instance of StrCache by another, the first instance is not going to be cleaned up by GC because a reference to it is held by a self-replicating backgroundCleanup goroutine.

I also don't want to introduce a separate method that would signal to stop to background goroutine, since it requires additional actions by package user and introduces a chance to make a mistake.

Is there a way to automatically cancel the backgroundCleanup when struct is no longer used? Or should I opt out into other design and only spawn cleanup goroutines in TryAdd method? I wanted to avoid it because TryAdd uses a mutex for all operations on StrCache and holding it locked for longer is undesirable.


Solution

  • I suggest that you consider a different approach for the background goroutine.

    On a high level, you should use a context.Context in your goroutine which respects context cancellation.

    func (s *StrCache) backgroundCleanup(ctx context.Context) {
        for {
            select {
            case <-time.After(s.expiration):
                // ... Removing expired values logic
                break
            case <-ctx.Done():
                return
            }
        }
    }
    

    This pattern will loop endlessly, performing the internal cleanup of expired values. The addition of the context value allows you to quit this background goroutine.

    [...] if at some point I would need to replace one instance of StrCache by another [...]

    ... then I think you should cancel the context of the previous StrCache.

    I also don't want to introduce a separate method that would signal to stop to background goroutine, since it requires additional actions by package user and introduces a chance to make a mistake.

    Yes, that is an extra thing users of this type must now consider. IMO that is fine since users of any library should respect concurrency related aspects.

    There are plenty of std lib types, which are similar, in that they explicitly document that the usage of those types comes with the need to stop things / free resources (time.Timer is an example).

    and a cancel function (see context.WithCancel).