Go Locking Pitfall

Posted on Jul 17, 2022

Can you see (and explain) the issue with the following (slightly contrived) code?

package main

import (
	"fmt"
	"sync"
)

type Example struct {
	m sync.RWMutex
	a int
}

func (e *Example) ReadA() int {
	defer e.m.RUnlock()
	e.m.RLock()
	return e.a
}

func (e *Example) WorkWithA() int {
	defer e.m.RUnlock()
	e.m.RLock()

	val := e.ReadA()
	val += 1
	return val
}

func (e *Example) UpdateA(newA int) {
	defer e.m.Unlock()
	e.m.Lock()
	e.a = newA
}

func main() {
	ex := &Example{}
	ex.UpdateA(1)
	fmt.Println(ex.ReadA())
}

Runnable playground

Nothing right? At least that’s what I thought. It seems to run without issues.

But if you add locks it probably means you want to run things concurrently, e.g. one go routine calling WorkWithA() and another one calling UpdateA(). And that should work fine, because things are properly locked.

But boiled down to the following code, it does not seem to run that well.

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var m sync.RWMutex

	// First RLock(), the one in WorkWithA()
	m.RLock()

	// An async routine that has to get a proper write lock first
	go func() {
		defer m.Unlock()
		m.Lock()
		// call e.UpdateA(..)
	}()
	// give routine time to start/run
	time.Sleep(time.Second * 2)

	// Get the second lock, the one in ReadA()
	m.RLock()
	// ReadA() returns its valie
	m.RUnlock()

	// WorkWithA() would do its thing with the returned value and RUnlock() when done

	m.RUnlock()
	fmt.Println("Done")
}
Runnable playground

This simulates a possible locking order with an async task attempting to do a write (with associated Lock()) while WorkWithA() is active.

The order of locks is

  • RLock() (first one in WorkWithA())
  • Lock() (through a concurrent UpdateA()) which would block/wait because there’s an active RLock()
  • Rlock() (in Read()) but that shouldn’t block because we already have an RLock(), and the Lock() has to wait
  • Release both RLock()s, when ReadA() completes and then when WorkWithA() completes

You would assume the Lock() will block until both RLock()s have been released and it does, but it will also make the second RLock() block!

fatal error: all goroutines are asleep - deadlock!

The latter case was totally unexpected to me. I thought acquiring two RLocks() in succession would be fine, but if a Lock() is attempted in between the calls, it will make the second RLock() block because the Lock() will be the next in line to acquire the lock, once the first RLock() completes, which will never happen in this case (or any case)

Learnings

Doing two RLock()s in succession, e.g. by calling a function that RLock()s after the first RLock() can block if an async routine attempts A Lock() in between.