Synchronization in Go

By Syncfusion®, Inc. · Last updated

Coordinate goroutines safely: why synchronization is needed, sync.WaitGroup, sync.Mutex and RWMutex, sync/atomic, and how to detect and prevent race conditions.

What Is Synchronization?

Synchronization = making goroutines work together safely: waiting for each other, and taking turns with shared data.

Shared data = a variable that more than one goroutine reads or changes.

Simple lesson: synchronization keeps goroutines in step, so shared data stays correct.

A Real-World Example

Think of the class attendance register on the teacher's desk.

  • Two monitors both want to mark students present.
  • If both write in the same box at the same moment, the marks get messy.
  • So the rule is: only the monitor holding the pen may write.
  • The other monitor waits for the pen.

Now the mapping:

  • The register = shared data.
  • The pen = a mutex (a lock).
  • Waiting for the pen = Lock() blocking until it is free.
  • Handing the pen back = Unlock().

You have met two ways to coordinate goroutines:

  • sync.WaitGroup in Goroutines
  • channels in Channels

This lesson adds mutexes and atomic operations for shared data.

What You Will Learn

In this lesson, you will learn:

  1. Why synchronization is needed, and how to coordinate goroutines.
  2. sync.WaitGroup and its Add(), Done() and Wait().
  3. sync.Mutex and sync.RWMutex with Lock() and Unlock().
  4. Atomic operations with sync/atomic.
  5. What race conditions are, how to detect them, and how to prevent them.

Synchronization Fundamentals

Why Synchronization Is Needed

count++ looks like one step, but it is really three:

  1. read the current value
  2. add one
  3. write the new value back

If two goroutines do this at the same time, one update can be lost.

Step through it. Then switch on the mutex.

Compare the two tabs:

  • No mutex: both read 0, both write 1. The final count is 1, not 2.
  • With mutex: B waits until A is done. The final count is 2.

Coordinating Goroutines

There are two different needs:

  • Wait for goroutines to finish → sync.WaitGroup or channels.
  • Take turns with shared data → sync.Mutex, sync.RWMutex or sync/atomic.

Simple lesson: use WaitGroups to wait, and locks or atomics to share data safely.

Wait Groups

sync.WaitGroup

WaitGroup = a counter of goroutines still working.

Add(), Done() and Wait()

CallWho calls itWhat it does
wg.Add(n)the starter, before gocounter + n
wg.Done()each goroutine, at the endcounter - 1
wg.Wait()the waiterblocks until the counter is 0

Wait for every house to report

package main

import (
	"fmt"
	"sync"
)

func main() {
	houses := []string{"Red", "Blue", "Green", "Yellow"}
	points := make([]int, len(houses))
	var wg sync.WaitGroup

	wg.Add(len(houses))
	for i, h := range houses {
		go func() {
			defer wg.Done()
			points[i] = len(h) * 10
		}()
	}
	wg.Wait()
	fmt.Println(points)
}
[30 40 50 60]
  • wg.Add(len(houses)) adds all four at once.
  • defer wg.Done() runs even if the goroutine returns early.

Simple lesson: Add before starting, Done when finished, Wait to block until all are done.

Mutexes

sync.Mutex

Mutex (mutual exclusion) = a lock that lets only one goroutine at a time into a piece of code.

Critical section = the code between Lock() and Unlock().

Lock() and Unlock()

Press Play to watch the lock.

A mutex protects the counter

A safe counter type

package main

import (
	"fmt"
	"sync"
)

type VoteBox struct {
	mu    sync.Mutex
	votes map[string]int
}

func (b *VoteBox) Add(name string) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.votes[name]++
}

func (b *VoteBox) Count(name string) int {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.votes[name]
}

func main() {
	box := &VoteBox{votes: make(map[string]int)}
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if i%3 == 0 {
				box.Add("Ravi")
			} else {
				box.Add("Asha")
			}
		}()
	}
	wg.Wait()
	fmt.Println("Asha:", box.Count("Asha"), "Ravi:", box.Count("Ravi"))
}
Asha: 66 Ravi: 34
  • The mutex sits next to the data it protects, inside the struct.
  • defer b.mu.Unlock() makes sure the lock is always released.
  • Maps are not safe for many goroutines writing at once, so the lock is required. See Maps.
  • A struct with a mutex must be passed by pointer. A copy would have its own, separate lock.

sync.RWMutex

RWMutex = a lock with two modes:

  • RLock() / RUnlock() = many readers at the same time.
  • Lock() / Unlock() = one writer, and no readers while it writes.

Many readers, one writer

package main

import (
	"fmt"
	"sync"
)

type Timetable struct {
	mu      sync.RWMutex
	periods map[string]string
}

func (t *Timetable) Get(day string) string {
	t.mu.RLock()
	defer t.mu.RUnlock()
	return t.periods[day]
}

func (t *Timetable) Set(day, subject string) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.periods[day] = subject
}

func main() {
	tt := &Timetable{periods: map[string]string{"Mon": "Maths"}}
	tt.Set("Tue", "Science")
	var wg sync.WaitGroup
	for _, d := range []string{"Mon", "Tue", "Mon"} {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_ = tt.Get(d)
		}()
	}
	wg.Wait()
	fmt.Println(tt.Get("Mon"), tt.Get("Tue"))
}
Maths Science
  • Use RWMutex when reads are much more common than writes.

Mutex vs RWMutex

Simple lesson: a mutex lets one goroutine at a time touch shared data, and an RWMutex lets many readers share.

Atomic Operations

sync/atomic

Atomic operation = a small change, like adding 1, that happens as one unbreakable step.

  • No goroutine can see it half done.
  • Faster than a mutex for simple counters.

An atomic counter

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var visitors atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < 500; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			visitors.Add(1)
		}()
	}
	wg.Wait()
	fmt.Println("Library visitors:", visitors.Load())
}
Library visitors: 500
  • atomic.Int64 has methods: Add, Load, Store, CompareAndSwap.
  • Older code uses functions instead: atomic.AddInt64(&n, 1).
  • Use atomics only for single numbers or flags. For anything bigger, use a mutex.

Simple lesson: use sync/atomic for a simple shared counter, and a mutex for anything more.

Race Conditions

What Are Race Conditions?

Data race = two goroutines use the same variable at the same time, and at least one of them writes.

Race condition = a bug where the result depends on which goroutine happens to run first.

An unsafe counter (do not copy this)

package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			count++
		}()
	}
	wg.Wait()
	fmt.Println(count)
}
  • On a real computer with many cores, this often prints a number below 1000, and a different one each run.
  • In this lab, goroutines take turns on one thread, so you may see 1000. The code is still wrong.

Detection

Go has a built-in race detector. Run your program or tests with -race:

go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000012118 by goroutine 8:
  main.main.func1()
      /school/main.go:14 +0x...
Previous write at 0x00c000012118 by goroutine 7:
  ...
==================
  • It shows where the two goroutines touched the same memory.
  • It needs the go tool on your computer, so it cannot run in this lab.
  • Run your tests with -race often.

Prevention

  • Do not share: give each goroutine its own data, then combine results. See Goroutines.
  • Send instead of share: pass values through channels. See Channels.
  • Lock: protect shared data with a sync.Mutex.
  • Atomic: use sync/atomic for single counters.
  • Check: run with -race.

Common lock mistakes:

  • Forgetting Unlock() freezes every other goroutine. Use defer mu.Unlock().
  • Locking the same mutex twice in one goroutine waits forever.
  • Copying a struct that holds a mutex makes a second, unrelated lock.

Easy way to remember

  • Wait for work → WaitGroup
  • Hand data over → channels
  • Share data → Mutex (RWMutex for many readers)
  • Share one number → atomic
  • Check → go run -race

Simple lesson: a data race happens when goroutines share data without rules, and locks, channels or atomics prevent it.

TL;DR

  1. Synchronization = goroutines waiting for each other and taking turns.
  2. WaitGroup = Add(n) before go, defer Done(), Wait().
  3. Mutex = Lock() ... defer Unlock(). One goroutine at a time.

    • Critical section = the code between them.
  4. RWMutex = RLock for many readers, Lock for one writer.
  5. Atomic = atomic.Int64: Add, Load. For single numbers.
  6. Data race = shared variable, at the same time, at least one write.

    • Detect = go run -race, go test -race.
    • Prevent = do not share, use channels, lock, or atomics.

Common mistakes

MistakeWrong ideaCorrect ideaWhat goes wrong
count++ from many goroutines++ is one stepIt is read, add, writeLost updates.
Forgetting Unlock()The lock frees itselfUnlock, ideally with deferOther goroutines wait forever.
Copying a struct with a mutexThe lock is sharedThe copy has its own lockNo protection at all.
Locking around a slow network callLocks are cheapKeep critical sections shortEverything slows down.
wg.Add inside the goroutineIt counts in timeAdd before goWait may return too early.
"It printed 1000, so it is safe"One correct run proves itRaces show up only sometimesBugs in production. Use -race.

Check Yourself

Exercises

What to Remember

  • Shared data needs rules when goroutines change it.
  • WaitGroup waits. Mutex takes turns. RWMutex lets readers share. atomic changes one number safely.
  • Always defer mu.Unlock(), and keep critical sections short.
  • A data race may hide on one run and show on the next. Use go run -race.

Next: Reflection. You will learn how a Go program can look at its own values and types while it runs.