welcome at SebWalak.com

Metronome's Cacophony (8/14) - concurrency in Go (GoLang) - Mutex

Asynchronous approach (ctd)

Mutex

Term stands for mutual exclusion. In programming it is a property limiting resource access to one thread (or even one consumer - due to lack of reentrancy, later about that) at a time. Sounds familiar? In previous section the atomic functions were helping us achieve just that in context of simple variables.

Atomic functions allow you to write, read or compare variables values in a mutually exclusive fashion. Mutexes can do more than that. You can make any section of code to be mutually exclusive. We do this by acquiring a lock before such section, and then releasing it once we are done. It is as if a guard was hired to fend off any other actors from our code as it is executing.

Consider an example without a mutex:

package main

import (
   "log"
   "time"
   "sync"
)

const (
   bathroom = "***bathroom***"
   kitchen  = "kitchen"
)

var (
   visitorsGroup sync.WaitGroup
)

func visitRoom(person, room string) {
   log.Println(person, "is entering", room)
   time.Sleep(time.Second)
   log.Println(person, "is leaving", room)
   visitorsGroup.Done()
}

func main() {
   visitorsGroup.Add(4)
   go visitRoom("Dan", kitchen)
   go visitRoom("Dave", bathroom)
   go visitRoom("Elton", kitchen)
   go visitRoom("Elvis", bathroom)

   visitorsGroup.Wait()
   log.Println("program ended")
}

Output:

2018/05/01 12:24:05 Elvis is entering ***bathroom***
2018/05/01 12:24:05 Dan is entering kitchen
2018/05/01 12:24:05 Dave is entering ***bathroom***
2018/05/01 12:24:05 Elton is entering kitchen
2018/05/01 12:24:06 Dan is leaving kitchen
2018/05/01 12:24:06 Elvis is leaving ***bathroom***
2018/05/01 12:24:06 Dave is leaving ***bathroom***
2018/05/01 12:24:06 Elton is leaving kitchen
2018/05/01 12:24:06 program ended

Process finished with exit code 0

The output shows that Dan and Elton entered the kitchen while Elvis and Dave entered the bathroom simultaneously. While most kitchens can accommodate more than one person at the same time, the nature of a bathroom (with certain presumptions) would imply mutually exclusive property. The resources in this case are the rooms, and the threads are the people using them.

Let’s introduce mutex.

package main

import (
   "log"
   "time"
   "sync"
)

const (
   bathroom = "***bathroom***"
   kitchen  = "kitchen"
)

var (
   visitorsGroup sync.WaitGroup
   bathroomMutex sync.Mutex
)

func visitRoom(person, room string) {
   if room == bathroom {
      bathroomMutex.Lock()
      
      //defer will be executed when function leaves, not the local block
      defer bathroomMutex.Unlock()
   }
   log.Println(person, "is entering", room)
   time.Sleep(time.Second)
   log.Println(person, "is leaving", room)
   visitorsGroup.Done()
}

func main() {
   visitorsGroup.Add(4)
   go visitRoom("Dan", kitchen)
   go visitRoom("Dave", bathroom)
   go visitRoom("Elton", kitchen)
   go visitRoom("Elvis", bathroom)

   visitorsGroup.Wait()
   log.Println("program ended")
}
2018/05/01 12:33:41 Elvis is entering ***bathroom***
2018/05/01 12:33:41 Dan is entering kitchen
2018/05/01 12:33:41 Elton is entering kitchen
2018/05/01 12:33:42 Elton is leaving kitchen
2018/05/01 12:33:42 Dan is leaving kitchen
2018/05/01 12:33:42 Elvis is leaving ***bathroom***
2018/05/01 12:33:42 Dave is entering ***bathroom***
2018/05/01 12:33:43 Dave is leaving ***bathroom***
2018/05/01 12:33:43 program ended

Process finished with exit code 0

Now the bathroom will be used by one person at a time, while kitchen is shared between 3 people. Once Elvis is no more in the (bath)room, Dave enters.

It is a powerful, but dangerous construct.

It is worth noting that all operations that require synchronised access should be as quick as possible, so that the locked shared resource can be released for others. People with several kids and one bathroom will know very well how important it is. Do whatever requires privacy while locked inside, brush your hair outside.

In programming, enclosing too long operations as mutex will result in performance stutters in your application. We describe it as a situation with high resource or thread contention.

Also, be extra vigilant when using other mutex synchronised operations from within mutex synchronised operations. By doing so you you may summon deadlocks, which will make your application grind to a halt. That situation occurs when two tasks wait for each other to finish, but none of them can, waiting forever. Another piece of advise is to release your locks in the reverse order to the one you acquired them in.

Java analogy: Mutex is just another term for a lock. Java comes with a variety of locks, have a look at java.util.concurrent.locks package. Be careful because Go’s locks are not reentrant. If you are really missing reentrant locks, have a look at this discussion to learn why reentrant locking can defeat the very purpose of locking in the first place.

Solution with Mutex

Again, due to the size of this solution I will split it functionally

//because this struct and demo code resides in the same package it may be tempting
// to access struct's field directly - don't! unless you synchronise access to them
type SharedState struct {
   sync.Mutex
   volume               int
   firstMeasurementDone bool
   terminationRequested bool
   measuringFinished    bool
}

func NewSharedState() *SharedState {
   return &SharedState{
      volume: -1,
   }
}

// convenience method reducing repetition around locking/unlocking from 2 to 1 line
func (s *SharedState) lockNow() *sync.Mutex {
   s.Lock()
   return &s.Mutex
}

func (s *SharedState) KeepMeasuring() bool {
   defer s.lockNow().Unlock() // .lockNow() invoked straightaway, .Unlock() will defer
   return !s.terminationRequested
}

func (s *SharedState) RequestTermination() {
   defer s.lockNow().Unlock()
   s.terminationRequested = true
}

func (s *SharedState) VolumeMeasuredAtLeastOnce() bool {
   defer s.lockNow().Unlock()
   return s.volume != -1
}

func (s *SharedState) NewVolumeMeasurement(volume int) {
   defer s.lockNow().Unlock()
   s.volume = volume
}

func (s *SharedState) MeasuringFinished() {
   defer s.lockNow().Unlock()
   s.measuringFinished = true
}

func (s *SharedState) HasMeasuringFinished() bool {
   defer s.lockNow().Unlock()
   return s.measuringFinished
}

func (s *SharedState) LatestVolume() int {
   defer s.lockNow().Unlock()
   return s.volume
}

SharedState is a struct which holds all state shared between our Goroutines. Its methods are responsible for synchronisation of access to all shared state.

It gives an important advantage that the state just cannot be accessed in any other way. When multiple people are working on the same code and some are unfamiliar with the inner workings, this reduces the risk of direct state access without synchronisation. Obviously, for this concept to work the struct would have to have unexported fields and sit in a separate package (not in this example ).

Also, encapsulating this responsibility in one place means that our timing loop (below) reads better and we do not throw another responsibility in the mix.

func(bpm param.Bpm, performer metronome.BeatPerformer) {

   sharedState := NewSharedState()
   
   // continually and sequentially measure volume
   go func() {
      for sharedState.KeepMeasuring() {
         sharedState.NewVolumeMeasurement(volumeMeter())
      }
      sharedState.MeasuringFinished()
   }()
 
   // wait for first measurement to come through
   for !sharedState.VolumeMeasuredAtLeastOnce() {
   }
 
   ticker := time.NewTicker(bpm.Interval())
   defer ticker.Stop()
 
   for beatCount := 0; beatCount < numberOfBeats; beatCount ++ {
      performer(beatCount, sharedState.LatestVolume())
      <-ticker.C
   }
 
   sharedState.RequestTermination()
 
   for !sharedState.HasMeasuringFinished() {
   }
}

Note: It is fairly literal translation of previous implementation with atomic package. I could have used separate mutexes to block execution before the first volume measurement arrives and another, to wait for measuring Goroutine termination.

Let’s have a look at the execution diagram:

Asynchronous - mutex synchronising access to shared state

Asynchronous - mutex synchronising access to shared state

Conclusion

Mutexes give you fine-level control over how to synchronise shared access. While writing Java, I often treated mutex as a low-level building block for more sophisticated synchronisation mechanisms.

Initially, while learning Go, I thought I will transfer my mutex experience. As the learning progressed I have understood that this is not the first port of call for building concurrent applications in Go.

Channels combined with Goroutines are the preferred option. It isn’t however a drop-in replacement for any of the other concurrency tools that I have shown you so far. It requires a fresh look at your problem and - sometimes - significantly different architecture.

Thumbnail