Your outcome
Stop reading code.
Start producing it.
Write ten small concurrency programs that finish cleanly and pass the race detector. Each reveal contains complete working code and the short explanation you should expect from a strong intermediate Go candidate.
Working method
Write → run → race-check → reveal
-racego run ./your-solution
go run -race ./your-solution
# Verify every revealed solution
go test ./examples/concurrency-challenges/...
Do not ask all ten. For a 45–60 minute interview, choose two simple tasks and one medium task, then probe why the code terminates, who owns channel closure, and what protects each shared value.
Ground rules: Go memory model · WaitGroup documentation · race detector guide
Round one · retrieval
Five simple coding tasks
Aim for a working answer in 5–8 minutes each. These tasks check whether the basic synchronization tool is available from memory.
Run jobs, then announce completion
Implement runJobs so every job runs in its own goroutine and the final message prints only after all jobs finish.
func runJobs(jobs []string)
- Print
finished: <job>once per job. - Use a
sync.WaitGroup; do not use sleeps. - The job lines may appear in any order.
Check: all jobs finished is always the last line.
Reveal working code + explanation
package main
import (
"fmt"
"sync"
)
func runJobs(jobs []string) {
var wg sync.WaitGroup
for _, job := range jobs {
job := job
wg.Go(func() {
fmt.Println("finished:", job)
})
}
wg.Wait()
fmt.Println("all jobs finished")
}
func main() {
runJobs([]string{"index", "email", "backup"})
}
wg.Go registers and starts each task; Wait joins them. It guarantees completion before the final print, not worker ordering. The explicit loop-variable copy also makes the goroutine’s input ownership obvious.
Return one square through a channel
Implement square. It must calculate in a goroutine and return a receive-only channel immediately.
func square(n int) <-chan int
- Send exactly one result.
- Close the channel from the producing goroutine.
fmt.Println(<-square(7))must print49.
Check: output is 49.
Reveal working code + explanation
package main
import "fmt"
func square(n int) <-chan int {
result := make(chan int, 1)
go func() {
result <- n * n
close(result)
}()
return result
}
func main() {
fmt.Println(<-square(7))
}
The producer owns the channel and therefore closes it. A one-slot buffer lets the producer finish even if the caller does not receive immediately; the receive-only return type prevents callers from sending.
Produce values and stop a range
Implement countTo so callers can range over 1…n without deadlocking.
func countTo(n int) <-chan int
- Produce values in a goroutine.
- Close the channel after the final send.
- Summing
countTo(5)must return15.
Check: the program prints 15 and exits.
Reveal working code + explanation
package main
import "fmt"
func countTo(n int) <-chan int {
numbers := make(chan int)
go func() {
defer close(numbers)
for number := 1; number <= n; number++ {
numbers <- number
}
}()
return numbers
}
func main() {
sum := 0
for number := range countTo(5) {
sum += number
}
fmt.Println(sum)
}
A channel range stops only after the channel is closed and drained. The producer is the goroutine that knows no more values will be sent, so it owns the close.
Protect a score map
Implement a ScoreStore whose Set and Get methods are safe when called by multiple goroutines.
type ScoreStore struct {
// add fields
}
func NewScoreStore() *ScoreStore
func (s *ScoreStore) Set(name string, score int)
func (s *ScoreStore) Get(name string) (int, bool)
- Use an ordinary map and
sync.Mutex. - Protect reads as well as writes.
- Use pointer receivers so methods share the same lock.
Check: concurrent writes followed by Get("ana") print 91 true; -race stays quiet.
Reveal working code + explanation
package main
import (
"fmt"
"sync"
)
type ScoreStore struct {
mu sync.Mutex
scores map[string]int
}
func NewScoreStore() *ScoreStore {
return &ScoreStore{scores: make(map[string]int)}
}
func (s *ScoreStore) Set(name string, score int) {
s.mu.Lock()
defer s.mu.Unlock()
s.scores[name] = score
}
func (s *ScoreStore) Get(name string) (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
score, ok := s.scores[name]
return score, ok
}
func main() {
store := NewScoreStore()
var wg sync.WaitGroup
for name, score := range map[string]int{
"ana": 91,
"ben": 84,
"cy": 88,
} {
name, score := name, score
wg.Go(func() {
store.Set(name, score)
})
}
wg.Wait()
score, ok := store.Get("ana")
fmt.Println(score, ok)
}
The mutex defines one access rule for the map: every read and write takes the same lock. The post-Wait read is ordered after all registered tasks, but Get still locks because the type promises safe access at any time.
Build a map without a mutex
Implement concurrentLengths with one goroutine per word, but let only the caller mutate the result map.
func concurrentLengths(words []string) map[string]int
- Workers send a word and its length through a channel.
- The caller receives exactly one result per input.
- Do not use a mutex or close the channel.
Check: for go, channel, mutex, print 2 7 5.
Reveal working code + explanation
package main
import "fmt"
type wordLength struct {
word string
length int
}
func concurrentLengths(words []string) map[string]int {
results := make(chan wordLength, len(words))
for _, word := range words {
word := word
go func() {
results <- wordLength{word: word, length: len(word)}
}()
}
lengths := make(map[string]int, len(words))
for range words {
result := <-results
lengths[result.word] = result.length
}
return lengths
}
func main() {
lengths := concurrentLengths([]string{"go", "channel", "mutex"})
fmt.Println(lengths["go"], lengths["channel"], lengths["mutex"])
}
The workers share no map; they transfer immutable result values to one owner. The receiver knows the exact number of sends, so channel closure is unnecessary. The buffer ensures every worker can finish even before collection starts.
Round two · transfer
Five medium–tough coding tasks
Aim for 12–20 minutes each. The difficulty is no longer syntax; it is coordinating ownership, completion, ordering, and bounded work.
Parallel sum by chunks
Implement parallelSum. Split the input into at most workers chunks, sum chunks concurrently, then combine their partial sums.
func parallelSum(numbers []int, workers int) int
- Handle an empty input and non-positive worker count.
- Use a channel for partial sums and a
WaitGroupfor worker completion. - Close the partial-sum channel only after all senders finish.
Check: parallelSum([]int{1,2,3,4,5,6,7,8}, 3) returns 36.
Reveal working code + explanation
package main
import (
"fmt"
"sync"
)
func parallelSum(numbers []int, workers int) int {
if len(numbers) == 0 {
return 0
}
if workers < 1 {
workers = 1
}
if workers > len(numbers) {
workers = len(numbers)
}
partials := make(chan int, workers)
chunkSize := (len(numbers) + workers - 1) / workers
var wg sync.WaitGroup
for start := 0; start < len(numbers); start += chunkSize {
end := min(start+chunkSize, len(numbers))
chunk := numbers[start:end]
wg.Go(func() {
sum := 0
for _, number := range chunk {
sum += number
}
partials <- sum
})
}
wg.Wait()
close(partials)
total := 0
for partial := range partials {
total += partial
}
return total
}
func main() {
fmt.Println(parallelSum([]int{1, 2, 3, 4, 5, 6, 7, 8}, 3))
}
Each worker owns one read-only slice window and one local sum. The buffer is large enough for every partial, so waiting before ranging cannot deadlock. The coordinator closes only after every sender returns.
Parallel word frequency
Count words in several strings concurrently without allowing workers to mutate one shared map.
func parallelWordCount(texts []string) map[string]int
- One worker builds one local
map[string]intper text. - Send local maps to one merging goroutine.
- Use a
WaitGroupand close after all sends.
Check: the sample prints counts 3 2 1 for go channels mutex.
Reveal working code + explanation
package main
import (
"fmt"
"strings"
"sync"
)
func parallelWordCount(texts []string) map[string]int {
partials := make(chan map[string]int, len(texts))
var wg sync.WaitGroup
for _, text := range texts {
text := text
wg.Go(func() {
local := make(map[string]int)
for _, word := range strings.Fields(text) {
local[word]++
}
partials <- local
})
}
wg.Wait()
close(partials)
total := make(map[string]int)
for partial := range partials {
for word, count := range partial {
total[word] += count
}
}
return total
}
func main() {
counts := parallelWordCount([]string{
"go channels go",
"mutex channels",
"go waitgroup",
})
fmt.Println(counts["go"], counts["channels"], counts["mutex"])
}
This is reduce-by-ownership: workers mutate only private maps, then the caller serially merges them. It avoids a hot shared lock and makes the race-free boundary visible in the types and channel flow.
Worker pool with ordered output
Implement a fixed-size worker pool that squares inputs concurrently while returning results in input order.
func runPool(input []int, workerCount int) []int
- Use job and result channels; do not start one goroutine per input.
- At most
workerCountworkers may process jobs. - Workers may finish out of order; the returned slice must not.
- Close jobs after production and results after every worker exits.
Check: input [2 3 4 5] returns [4 9 16 25].
Reveal working code + explanation
package main
import (
"fmt"
"sync"
)
type Job struct {
ID int
Value int
}
type Result struct {
ID int
Value int
}
func runPool(input []int, workerCount int) []int {
if workerCount < 1 {
workerCount = 1
}
jobs := make(chan Job)
results := make(chan Result)
var workers sync.WaitGroup
for range workerCount {
workers.Go(func() {
for job := range jobs {
results <- Result{
ID: job.ID,
Value: job.Value * job.Value,
}
}
})
}
go func() {
defer close(jobs)
for id, value := range input {
jobs <- Job{ID: id, Value: value}
}
}()
go func() {
workers.Wait()
close(results)
}()
output := make([]int, len(input))
for result := range results {
output[result.ID] = result.Value
}
return output
}
func main() {
fmt.Println(runPool([]int{2, 3, 4, 5}, 3))
}
IDs separate completion order from output order. The producer owns jobs; a coordinator that observes all senders owns results. The caller drains results concurrently with the workers, avoiding a circular wait.
Bound concurrency with a semaphore
Implement mapLimit: transform every input concurrently, but never run more than limit transformations at once.
func mapLimit(
input []int,
limit int,
transform func(int) int,
) []int
- Use a buffered channel as a counting semaphore.
- Return results in input order.
- Wait for every transformation; treat limits below one as one.
Check: doubling [1 2 3 4 5] with limit 2 returns [2 4 6 8 10].
Reveal working code + explanation
package main
import (
"fmt"
"sync"
)
func mapLimit(input []int, limit int, transform func(int) int) []int {
if limit < 1 {
limit = 1
}
output := make([]int, len(input))
slots := make(chan struct{}, limit)
var wg sync.WaitGroup
for index, value := range input {
index, value := index, value
wg.Go(func() {
slots <- struct{}{}
defer func() { <-slots }()
output[index] = transform(value)
})
}
wg.Wait()
return output
}
func main() {
doubled := mapLimit([]int{1, 2, 3, 4, 5}, 2, func(n int) int {
return n * 2
})
fmt.Println(doubled)
}
Each token occupies one buffer slot, so capacity is the maximum active count. Each goroutine writes a distinct slice element, and Wait completes before the caller reads the slice. This bounds active work, though it still creates one goroutine per input.
Protect a multi-key ledger invariant
Implement a concurrent ledger. A transfer must never lose money, create money, or expose a half-completed move.
type Ledger struct {
// add fields
}
func NewLedger(initial map[string]int) *Ledger
func (l *Ledger) Transfer(from, to string, amount int) bool
func (l *Ledger) Snapshot() map[string]int
- Protect the whole check-and-update invariant with one mutex.
- Reject non-positive or unaffordable transfers.
- Copy input maps and snapshot maps; do not leak mutable aliases.
- After concurrent transfers, the total balance must remain constant.
Check: 100 transfers each way from two 100-unit accounts print 100 100 200; -race stays quiet.
Reveal working code + explanation
package main
import (
"fmt"
"sync"
)
type Ledger struct {
mu sync.Mutex
balances map[string]int
}
func NewLedger(initial map[string]int) *Ledger {
balances := make(map[string]int, len(initial))
for account, balance := range initial {
balances[account] = balance
}
return &Ledger{balances: balances}
}
func (l *Ledger) Transfer(from, to string, amount int) bool {
if amount <= 0 {
return false
}
l.mu.Lock()
defer l.mu.Unlock()
if l.balances[from] < amount {
return false
}
l.balances[from] -= amount
l.balances[to] += amount
return true
}
func (l *Ledger) Snapshot() map[string]int {
l.mu.Lock()
defer l.mu.Unlock()
copyOfBalances := make(map[string]int, len(l.balances))
for account, balance := range l.balances {
copyOfBalances[account] = balance
}
return copyOfBalances
}
func main() {
ledger := NewLedger(map[string]int{"a": 100, "b": 100})
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
ledger.Transfer("a", "b", 1)
})
wg.Go(func() {
ledger.Transfer("b", "a", 1)
})
}
wg.Wait()
balances := ledger.Snapshot()
fmt.Println(balances["a"], balances["b"], balances["a"]+balances["b"])
}
The lock protects an invariant, not merely a map operation: checking funds and changing both balances form one critical section. Defensive copies prevent callers from bypassing that lock through a shared map reference.
Interview debrief
Four questions after any solution
Lifetime
What starts each goroutine, what can block it, what makes it stop, and who observes completion?
Ownership
Who is allowed to mutate each map or slice? Which goroutine knows when a channel can close?
Ordering
Which channel operation, lock, or Wait makes a write visible before a read?
Failure
What happens on empty input, invalid worker counts, early return, panic, or a slow consumer?
You now have a ten-task coding bank. A strong answer is not merely race-free: the candidate can explain termination, ownership, ordering, and the trade-off behind the chosen primitive.
Primary reading
Read the contracts behind the code
Primary source
Read the Go memory model from “Advice” through “Locks.” It explains exactly why the channel, mutex, and WaitGroup boundaries in these tasks make results observable.
Quick reference
Keep the concurrency patterns sheet beside you. Revisit Lesson 0002 if any primitive still feels difficult to produce from memory.