August 20th, 2024 08:15 UTC · 3 months ago

Golang

Relearning Go: Day 7

Timers & Tickers to Atomic Counters & Mutexes

Timers & Tickers

gobyexample.com/timers
gobyexample.com/tickers

Nothing surprising, but it’s based around channels, and I think that’s cool. We can incorporate it into select for example, and treat it like any other event.

The Duration type used throughout the time package offers no extra type safety: it’s an alias for int64. It’s a count of nanoseconds. It’s also signed, meaning durations can be less than zero moments long. Yep. Not really a duration then, is it?

This does matter. A Timer can be created with a negative duration and it’s okay. I assume it will expire immediately. But a Ticker created with a negative duration will panic. This seems like a painful inconsistency, especially since it’s ossified into the standard library.

Duration could have been an unsigned type, eliminating the possibility of panic in NewTicker and giving sense to NewTimer and almost every other function in time which accepts or returns a duration.

Worker Pools

gobyexample.com/worker-pools

This isn’t a specific feature, rather it’s an elegant pattern that arises naturally from a combination of goroutines and channels. It’s actually the same as any threaded worker pool, but it’s perhaps more concise, and likely to have lower overheads.

WaitGroups

gobyexample.com/waitgroups

A sync.WaitGroup counts workers in and counts them out.

Note that this approach has no straightforward way to propagate errors from workers. For more advanced use cases, consider using the errgroup package.

That’s quite a limitation. Perhaps the wrong example to give? Handling errors is not Go’s forte, and this reinforces that.

This WaitGroup is used to wait for all the goroutines launched here to finish. Note: if a WaitGroup is explicitly passed into functions, it should be done by pointer.

If I pass it by value, do I get a clear compilation error? Or do I get subtle and hard to diagnose bugs? That might not crop up until running in production?

Aside about defer

Starting a worker is done with a wrapper:

go func() {
    defer wg.Done()
    worker(i)
}()

I have come to dislike defer. Even in this minimal example I don’t like it. With defer everything is done at the end of the function. Because defer is the way to arrange clean up, that’s the tool one reaches for instinctively – even if, for example, we’re allocating expensive resources in a loop. Imagine writing defer expensiveThing.Drop() inside a loop – because that’s how we ensure that things are cleaned up – only to find out in production that it crashes with an out of memory error. I suspect that’s an easy mistake. Also, I get that people are wary of C++, but maybe RAII was an idea worth copying?

Rate Limiting

gobyexample.com/rate-limiting

I like how elegant burstyLimiter is here, largely because goroutines are lightweight enough (syntactically as well as light on CPU and memory versus a system thread) to use as a drip feeder, putting “handle request” tokens into a limited size channel.

Atomic Counters & Mutexes

gobyexample.com/atomic-counters
gobyexample.com/mutexes

Atomic variables: cool. Mutexes get me face palming though:

Note that mutexes must not be copied, so if this struct is passed around, it should be done by pointer.

Flashbacks to WaitGroup. Anything that contains a Mutex is now poisoned – you must remember to pass it by pointer else Bad Things Will Happen. These are whole classes of bugs made possible by the language design.

Also, another example of defer being a weird language construct:

c.mu.Lock()
defer c.mu.Unlock()
c.counters[name]++

i.e. we want Unlock() to happen after incrementing the counter, so obviously we write it before. Note too that I didn’t include the whole function body: you don’t and can’t know when Unlock() is going to get called without it.


Today’s over; tomorrow’s reveille will be to the tune of stateful goroutines.