How to Implement a Thread-Safe Counter in Go

Implement a thread-safe counter in Go by wrapping an integer with a sync.Mutex to lock access during read and write operations.

The counter that lied to you

You are building a web server. You want to track how many requests are currently being processed so you can display a dashboard metric. You write a simple integer variable. You increment it when a request starts. You decrement it when the request ends. You run the server locally and hit the endpoint a few times. The count goes up and down correctly.

You run a load test with 1000 concurrent requests. The count jumps to 42, drops to negative five, and settles at a number that makes no sense. The variable got corrupted. Two goroutines tried to update the same memory location at the exact same time, and the hardware let them stomp on each other.

This is a data race. It happens whenever multiple goroutines read and write the same variable without coordination. Go does not protect you from this by default. The compiler will happily build the code. The runtime will happily execute it. The result is garbage.

Shared memory needs rules

Concurrency is not magic. It is hardware scheduling. When a goroutine runs val++, the CPU does not do that in one step. It loads the value from memory, adds one, and stores the result back. If the scheduler pauses the goroutine between the load and the store, another goroutine can load the old value, add one, and store it. The first goroutine then stores its result, overwriting the second one. One increment is lost.

You need a mechanism to ensure that only one goroutine touches the variable at a time. In Go, you have three main tools for this: mutexes, atomic operations, and channels. Each one solves the problem differently, and each has a different performance profile.

A mutex is the most general tool. It acts like a key to a bathroom. Only the goroutine holding the key can enter. Everyone else waits outside until the key is returned. This serializes access. It turns parallel chaos into sequential order.

The mutex solution

Here is the standard way to protect a shared variable using a mutex. The sync.Mutex type provides Lock and Unlock methods. You lock before reading or writing, and unlock when you are done.

package main

import (
	"sync"
)

// Counter tracks a value safely across multiple goroutines.
type Counter struct {
	mu  sync.Mutex // protects val from concurrent access
	val int        // the actual count
}

// Increment adds one to the counter.
func (c *Counter) Increment() {
	c.mu.Lock()       // block other goroutines until we hold the lock
	defer c.mu.Unlock() // ensure the lock is released even if a panic occurs
	c.val++           // safe to modify because we hold the lock
}

// Value returns the current count.
func (c *Counter) Value() int {
	c.mu.Lock()       // block other goroutines until we hold the lock
	defer c.mu.Unlock() // ensure the lock is released
	return c.val      // safe to read because we hold the lock
}

The receiver name is (c *Counter), following the convention of using a short name that matches the type. You never use this or self in Go. The defer statement is critical here. If the code inside the method panics, the Unlock still runs. Without defer, a panic would leave the mutex locked forever, causing every other goroutine to block indefinitely.

A mutex turns parallel chaos into sequential order.

What happens under the hood

When a goroutine calls Lock, it checks an internal flag. If the flag is free, the goroutine takes the lock and continues. If the flag is taken, the goroutine blocks. It yields the CPU to the scheduler. The scheduler picks another goroutine to run.

When the first goroutine finishes its work, defer c.mu.Unlock() runs. The flag is cleared. The scheduler wakes up one of the waiting goroutines. That goroutine takes the lock and proceeds.

This blocking has a cost. Context switches are expensive. If you have a counter that gets incremented millions of times per second, the mutex overhead can become a bottleneck. The goroutines spend more time waiting for the lock than doing work.

For simple counters, there is a faster way. Atomic operations use special CPU instructions that guarantee the read-modify-write happens in one indivisible step. No blocking. No context switches.

The atomic alternative

The sync/atomic package provides functions like AddInt64 that perform atomic updates. These functions map directly to hardware instructions. They are faster than mutexes because they do not involve the Go scheduler.

package main

import (
	"sync/atomic"
)

// AtomicCounter tracks a value using atomic instructions.
type AtomicCounter struct {
	val int64 // must be int64 for atomic operations on all platforms
}

// Increment adds one to the counter atomically.
func (c *AtomicCounter) Increment() {
	// atomic.AddInt64 performs a read-modify-write in one CPU instruction
	// this avoids lock contention and context switches
	atomic.AddInt64(&c.val, 1)
}

// Value returns the current count atomically.
func (c *AtomicCounter) Value() int64 {
	// atomic.LoadInt64 ensures we read the latest value
	// without a mutex, the CPU cache might return a stale value
	return atomic.LoadInt64(&c.val)
}

You must use int64 for atomic operations. The atomic package requires 64-bit alignment, and int64 guarantees that on all supported architectures. Using int can cause panics on 32-bit systems if the value is not aligned correctly.

Atomics trade flexibility for speed. You can only do simple operations like add, compare-and-swap, or load/store. You cannot use atomics to protect complex state or multiple variables. If you need to update a counter and a timestamp together, you still need a mutex.

The channel approach

Go's philosophy often favors communicating via channels over sharing memory. You can implement a counter by sending messages to a single goroutine that owns the value. Other goroutines send increment or decrement requests. The owner processes them sequentially.

package main

// ChannelCounter tracks a value using message passing.
type ChannelCounter struct {
	ch chan int // receives increment/decrement values
}

// NewChannelCounter creates a counter and starts the owner goroutine.
func NewChannelCounter() *ChannelCounter {
	ch := make(chan int, 100) // buffer to reduce blocking on sends
	c := &ChannelCounter{ch: ch}
	go c.run() // start the goroutine that owns the counter
	return c
}

// run processes updates from the channel.
func (c *ChannelCounter) run() {
	val := 0
	for delta := range c.ch {
		val += delta // only one goroutine touches val, so no lock needed
	}
}

// Increment sends a request to add one.
func (c *ChannelCounter) Increment() {
	c.ch <- 1 // send to the owner goroutine
}

// Decrement sends a request to subtract one.
func (c *ChannelCounter) Decrement() {
	c.ch <- -1 // send to the owner goroutine
}

The channel serializes access. Only the goroutine inside run modifies val. Other goroutines just send messages. This approach is useful when the counter is part of a larger pipeline. It decouples the producer from the counting logic.

Channels move data; mutexes protect data.

Pitfalls and runtime errors

Mutexes are easy to misuse. The most common bug is a deadlock. This happens when a goroutine tries to lock a mutex it already holds. Go's sync.Mutex is not reentrant. If you call Lock twice in the same goroutine without unlocking, the program stops.

The runtime detects this and panics with fatal error: all goroutines are asleep - deadlock!. The program terminates. You cannot recover from this. You must fix the code.

Another pitfall is forgetting to lock when reading. A mutex protects writes, but it also protects reads. If one goroutine is writing and another is reading without a lock, the reader might see a partially updated value. This is still a data race. You must lock for both reads and writes.

The race detector is your best friend. Run your tests with go test -race. The detector instruments your binary to track memory accesses. If two goroutines access the same variable concurrently and at least one is a write, the detector prints a stack trace and stops the program. It catches races that manual review misses.

The race detector catches what your eyes miss.

When to use which tool

Choosing the right synchronization primitive depends on your access pattern and performance requirements.

Use a mutex when you need to protect complex state or multiple variables that must be updated together. Use a mutex when the critical section involves more than a simple read-modify-write operation. Use a mutex when you are unsure; it is the safest default.

Use atomic operations when you have a single integer counter and need maximum performance under heavy load. Use atomics when profiling shows that mutex contention is a bottleneck. Use atomics only for simple operations like increment, decrement, or compare-and-swap.

Use a channel when the counter is part of a larger pipeline or when you want to decouple the producer from the counter logic. Use a channel when you need to coordinate multiple goroutines around the counter state. Use a channel when the updates are infrequent enough that the overhead of message passing is acceptable.

Pick the tool that matches the frequency and complexity of your access.

Where to go next