Home

Published

- 5 min read

sync.Cond

img of sync.Cond

Background

When we have multiple tasks to do, some tasks may have dependence on other tasks, in other words, there could be some order we need to adhere to.

sync.Cond provides a mechanism for us to coordinate goroutines and achieve certain order.

ELI5 Why we need it

Imagine in life we have certain milestones:

  1. Getting our first job.
  2. Buying a house.
  3. Getting Married
  4. Retirement
  5. etc

Milestones like getting a job, or getting married could happen anytime, but milestones like retirement could require us to get married and own a house first.

Usually, people would reevaluate their life situation every once in a while. Perhaps at the start of each year to see if they completed a milestone and are ready to work towards another milestone.

Having sync.Cond is like having a personal manager. Every time you have completed a certain milestone(condition) your personal manager will ask you to reevaluate your life immediately and you can move on ASAP.

Hence, instead of waiting till the start of every year, your change of goals becomes near real-time.

How to use it

First, we define our milestones as an Event and a thread-safe Tracker:

type Event struct {
	Name          EventName   // event identifier
	Start         func()      // function to execute
	Prerequisites []EventName // prerequisites before we can start this event
}

type Tracker struct {
	// For simplicity, we will use 2 maps here to track statuses
	// In actual world we will probably have more statuses and we need enum
	completedEvents map[EventName]bool
	startedEvents   map[EventName]bool

	mu sync.RWMutex
}
// There are some helper functions for Tracker that I'll omit here. If you're interested, feel free to scroll to the bottom

Next, we define our milestones along with their prerequisites:

var events = []Event{
	{
		Name:          BuyHouse,
		Start:         func() { 
			time.Sleep(1*time.Second) // mimic taking time to complete the milestone
			fmt.Println("BoughtHouse!") 
		},
		Prerequisites: []EventName{GetJob},
	},
	{
		Name:          GetJob,
		Start:         func() { 
			time.Sleep(1*time.Second)
			fmt.Println("SecuredJob!")
		},
		Prerequisites: []EventName{CompleteDegree},
	},
	{
		Name:          CompleteDegree,
		Start:         func() { 
			time.Sleep(1*time.Second)
			fmt.Println("CompleteDegree!") 
		},
		Prerequisites: []EventName{},
	},
	{
		Name:          GetMarried,
		Start:         func() { 
			time.Sleep(1*time.Second)
			fmt.Println("IAmMarried!") 
		},
		Prerequisites: []EventName{},
	},
	{
		Name:          Retirement,
		Start:         func() { 
			time.Sleep(1*time.Second)
			fmt.Println("FIRE!!") 
		},
		Prerequisites: []EventName{GetMarried, BuyHouse},
	},
}

Now, let us see what happens when we live with a fixed schedule instead of a personal manager. We mimic this by polling every second to check if we can start our next milestone.


func liveLifeWithScheduler() {
	var (
		startTs  = time.Now()
		wg       = errgroup.Group{}
		tracker  = &Tracker{
			completedEvents: make(map[EventName]bool),
			startedEvents: make(map[EventName]bool),
		}
	)

	for tracker.GetNumStartedEvent() < len(events) {
		// polling every second to check which event is ready
		for _, event := range events {
			// event has started or prerequisites not completed; we can ignore
			if tracker.hasEventStarted(event.Name) || 
			!tracker.hasCompletedPrerequisites(event.Prerequisites) {
				continue
			}

			
			e := event  // create copy for goroutine
			wg.Go(func() error {
				e.Start() // start event
				tracker.completeEvent(e.Name) // update event status in tracker
				return nil
			})
			tracker.startEvent(event.Name) // update event status in tracker
		}
		time.Sleep(1*time.Second)
	}

	wg.Wait()
	fmt.Println(time.Since(startTs).Seconds())
}

// Output
// CompleteDegree!
// IAmMarried!
// SecuredJob!
// BoughtHouse!
// FIRE!!
// 6.007259125s <- time taken

VS with sync.Cond:

func liveLifeWithCond() {
	var (
		startTs  = time.Now()
		cond     = sync.NewCond(&sync.Mutex{})
		wg       = errgroup.Group{}

		tracker = &Tracker{
			completedEvents: make(map[EventName]bool),
		}
	)

	for _, e := range events {
		event := e
		wg.Go(func() error {
			cond.L.Lock() // obtain lock

			// blocked until prerequisites is done
			for !tracker.hasCompletedPrerequisites(event.Prerequisites) {
				cond.Wait() // releases lock and wait for signal/broadcast by other goroutines
			}
			cond.L.Unlock() // release lock for other goroutines to run concurrently
			event.Start()   // start event 
			tracker.completeEvent(event.Name) // update event status in tracker
			cond.Broadcast() // wake up other goroutines sleeping in cond.Wait()

			return nil
		})
	}
	wg.Wait()
	fmt.Println(time.Since(startTs).Seconds())
}

// Output
// CompleteDegree!
// IAmMarried!
// SecuredJob!
// BoughtHouse!
// FIRE!!
// 4.004059958s <-  time taken

Awesome, less time is wasted on polling and events get to start as soon as prerequisite events are completed.

Rabbit Hole

In reality, there probably aren’t many scenarios where sync.Cond would be a perfect fit.

In the official Golang documents, it is stated that most use cases are better off using channels.

I do agree because I took some time to squeeze out this contrived scenario above. It is somewhat related to what I needed to implement at work, albeit we didn’t rely on sync.Cond.

Based on what I have read online so far, sync.Cond doesn’t have a lot of love in the community. But hey, I believe my above scenario fits pretty well.

If you have a better way to implement the above scenario please let me know in the comments section down below. I would love to learn/hear from you!

Alright, that is all for today; I had lots of fun, I hope you had fun too. Let’s get one post smarter every day, and see you tomorrow!

P.S For some reason my Reddit account got suspended/banned… While I am trying to reactivate it, do subscribe to email/rss feed for daily updates.

Feel free to show some love in the new comments section too :)

Cheers

https://pkg.go.dev/sync#Cond

Helper functions

func (t *Tracker) hasCompletedPrerequisites(eventNames []EventName) bool {
	t.mu.RLock()
	defer t.mu.RUnlock()

	for _, event := range eventNames {
		if completed := t.completedEvents[event]; !completed {
			return false
		}
	}
	return true
}

func (t *Tracker) completeEvent(eventName EventName) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.completedEvents[eventName] = true
}

func (t *Tracker) hasEventStarted(eventName EventName) bool {
	t.mu.RLock()
	defer t.mu.RUnlock()
	return t.startedEvents[eventName]
}

func (t *Tracker) startEvent(eventName EventName) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.startedEvents[eventName] = true
}

func (t *Tracker) GetNumStartedEvent() int {
	t.mu.RLock()
	defer t.mu.RUnlock()
	return len(t.startedEvents)
}

Subscribe

* indicates required

Intuit Mailchimp