Go interviewer course · Lesson 0003

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.

5 simple tasks 5 medium–tough tasks Go · 1.25+ Tools · goroutine, channel, map, mutex, WaitGroup

Working method

Write → run → race-check → reveal

1Copy only the task signature
2Make the output check pass
3Run again with -race
4Reveal and compare ownership
go run ./your-solution
go run -race ./your-solution

# Verify every revealed solution
go test ./examples/concurrency-challenges/...
Using this in an interview

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.

01

Run jobs, then announce completion

Simple 5 minutes

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.

Runnable source · WaitGroup.Go docs

02

Return one square through a channel

Simple 5 minutes

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 print 49.

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.

Runnable source · channel type specification

03

Produce values and stop a range

Simple 6 minutes

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 return 15.

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.

Runnable source · close specification

04

Protect a score map

Simple 8 minutes

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.

Runnable source · Go maps in action — concurrency

05

Build a map without a mutex

Simple 8 minutes

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.

Runnable source · memory model — channel communication

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.

06

Parallel sum by chunks

Medium 12 minutes

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 WaitGroup for 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.

Runnable source · WaitGroup docs

07

Parallel word frequency

Medium 15 minutes

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]int per text.
  • Send local maps to one merging goroutine.
  • Use a WaitGroup and 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.

Runnable source · Go maps in action — concurrency

08

Worker pool with ordered output

Medium–tough 20 minutes

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 workerCount workers 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.

Runnable source · Go concurrency patterns — pipelines

09

Bound concurrency with a semaphore

Medium–tough 15 minutes

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.

Runnable source · memory model — buffered channels

10

Protect a multi-key ledger invariant

Tough 20 minutes

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.

Runnable source · Mutex documentation

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?

Your tangible win

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.