Cursed Go: RAII

I’ve been thinking about resource management lately, so let’s play with inversion of control for resource management in Go.

As with most of the stuff I write, this is an exploratory, whimsical, and perhaps quirky essay rather than recommendations or good advice for idiomatic Go.


Resources & defer

A resource is basically something that a program acquires, uses, and releases. We must be careful not to break the protocol, e.g. using a resource after it’s been released. Examples are plenty: mutexes, file descriptors, sockets, DB transactions, your own custom resources and, of course, memory.

Memory is a notoriously important resource, and mishandling it can lead to all sorts of nasty issues. Call malloc and never free it: we’ve got ourselves a leak. Call free twice on the same allocation: it’s undefined behaviour!

Thankfully, Go has a Garbage Collector for managing memory on our behalf.

For other resources, there’s defer to absolve us from many of the perils of full manual management. For instance, here’s a contrived snippet showing how that’s usually done for a mutex protecting a counter:

type counter struct {
    value int
    mu    sync.Mutex
}

func (c *counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.value += 1
}

func (c *counter) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock()

    return c.value
}

func main() {
    var (
        c counter
        wg sync.WaitGroup
    )

    for range 100 {
        wg.Go(func() {
            c.Inc()
        })
    }

    wg.Wait()

    fmt.Println(c.Get())
}

Yes, an atomic integer is most likely the better choice for this particular example, but bear with me.

The cool thing about defer is that, as control flow becomes more complicated, with multiple return paths, panics, etc., it ensures that deferred functions get called when the surrounding function returns, including during panic unwinding. That’s nicer and more composable than having to manually track where we are in the function and ensure we correctly undo what’s been done so far.

Resource Acquisition Is Initialisation (RAII)

A defer must be explicitly scheduled by the programmer, and it’s tied to the scope of the surrounding function. The language doesn’t prevent us from forgetting to release resources, barring tailored linters (e.g. bodyclose for HTTP response bodies), tests, and code reviews.

In C++ and Rust, the mechanism is a bit different. There, the resource type itself defines how it should be released: through its destructor in C++ or its Drop implementation in Rust. The cleanup code gets called when the object’s lifetime ends. This goes by the name Resource Acquisition Is Initialisation (RAII).

As an example in Rust:

use std::sync::Mutex;
use std::thread;

struct Counter {
    value: Mutex<i32>,
}

impl Counter {
    fn new() -> Self {
        Self {
            value: Mutex::new(0),
        }
    }

    fn inc(&self) {
        let mut guard = self.value.lock().unwrap();
        *guard += 1;
    }

    fn get(&self) -> i32 {
        let guard = self.value.lock().unwrap();
        *guard
    }
}

fn main() {
    let c = Counter::new();

    thread::scope(|s| {
        for _ in 0..100 {
            s.spawn(|| {
                c.inc();
            });
        }
    });

    println!("{}", c.get());
}

Calling lock() on Mutex<T> locks the underlying mutex and returns a MutexGuard<T> that grants access to the protected T. When guard goes out of scope, its Drop implementation unlocks the mutex.

It’s possible to simplify the example to one-liners since binding to guard isn’t necessary, but that isn’t the point.

I’ve been thinking about whether something like this would be a good idea in Go, so let’s emulate parts of it using functions!

type Locked[T any] struct {
    value T
    mu    sync.Mutex
}

func NewLocked[T any](value T) *Locked[T] {
    return &Locked[T]{value: value}
}

func (l *Locked[T]) Do(f func(*T)) {
    l.mu.Lock()
    defer l.mu.Unlock()

    f(&l.value)
}

func (l *Locked[T]) Get() T {
    l.mu.Lock()
    defer l.mu.Unlock()

    return l.value
}

type Counter struct {
    *Locked[int]
}

func NewCounter() *Counter {
    return &Counter{NewLocked(0)}
}

func (c *Counter) Inc() {
    c.Do(func(v *int) {
        *v += 1
    })
}

func main() {
    var wg sync.WaitGroup

    c := NewCounter()

    for range 100 {
        wg.Go(c.Inc)
    }

    wg.Wait()

    fmt.Println(c.Get())
}

There are different ways to express the API, e.g. without embedding, with free functions instead of methods, etc.

Crucially, we wrapped sync.Mutex in the Locked[T] type, which handles locking and unlocking. The call site doesn’t mention Lock and Unlock anymore; it’s now handled automatically and transparently by Locked[T].

As a positive side effect, this strengthens the connection between the counter and its mutex. It’s harder to access the counter’s value without locking its mutex first. The API is a little harder to misuse.

The API is minimal for the sake of exposition, but we could enrich it as we see fit. E.g., what if the function provided to Do can fail? What if it returns a useful result? Etc.

Pushing this forward and generalising it far more, we come close to Haskell’s idiom Bracket.

Limitations

Although it’s great to have the resource type itself, as opposed to its users, controlling the protocol (that’s the inversion of control I hinted at), this approach has its downsides compared to defer, for example:

  1. The API is less flexible, and it doesn’t play that well if we need different signatures or even multiple ways to release the same resource.
  2. It might give a false sense of safety, since it’s still possible to leak a reference to the resource’s internals and break the protocol.

And there are a few more problems with our particular implementation due to our Go emulation with functions:

  1. It doesn’t compose naturally with the usual control flow offered by the language. Say, returning from the callback passed to Do doesn’t return from the outer function.
  2. It gets verbose quickly as the nesting grows.

Overall, for Go I’d say it’s a cute little trick to be aware of, but I wouldn’t consider it idiomatic.

Tags: go cursed
Share: X (Twitter) Facebook LinkedIn