Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Go  Module 15 – Concurrency patterns in Go

Worker pools, semaphores and bounded parallelism

Bound Go goroutines with a worker pool or a channel semaphore, let a full queue push back on producers, and size a pool for work that computes or waits.

  • Intermediate
  • 20 minutes
  • Examples run with Go 1.27.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: A worker pool keeps a fixed number of jobs in flight · A buffered channel of empty structs is a counting semaphore. With a pass: 2 in-depth parts and 3 interview answers, about 12 minutes.

What you will learn

  • Run a fixed pool of workers over a jobs channel and shut it down in order
  • Limit goroutines with a buffered channel used as a counting semaphore
  • Size a pool for CPU-bound and I/O-bound work from measurements
  • Choose between waiting, refusing and giving up when a bounded queue is full

Before you start

What you’ll learn · Before you start

What you will learn

  • Run a fixed pool of workers over a jobs channel and shut it down in order
  • Limit goroutines with a buffered channel used as a counting semaphore
  • Size a pool for CPU-bound and I/O-bound work from measurements
  • Choose between waiting, refusing and giving up when a bounded queue is full

Before you start

On this page

Starting one goroutine per image is one line of Go, and it sends every read at once. Against a store that serves at most 8 reads at a time, pool.go below sent 200 reads together and the store refused 192 of them, while a pool of 8 workers made the same 200 reads in about half a second with none refused.

Worker pools and semaphores give you bounded parallelism: at most N jobs run at once, however many wait. You will run a pool and stop it in order, and bound goroutines with a buffered channel. The output shown was recorded with Go 1.27.1.

A worker pool keeps a fixed number of jobs in flight

A worker pool: A fixed number of goroutines that take jobs from one shared channel, so that at most that many jobs run at once, however many are waiting. is a fixed number of goroutines that range over one jobs channel. Each job goes to whichever worker is free, so never more than N run at once, and results come back on a second channel. pool.go reads the 200 images both ways, from a store that refuses a read while 8 are in flight:

pool.go: one goroutine per image, then a pool of 8 Go · pool.go
// pool.go: 200 images to read from a store that serves at most 8 reads at a time and refuses a read beyond that, as
// a storage service with a concurrency limit does. One goroutine per image sends all 200 reads at once; a pool of 8
// workers ranging over one jobs channel never has more than 8 in flight, and the producer waits while all 8 are busy.
package main

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

var errBusy = errors.New("store busy")

// store serves at most limit reads at once, each taking 20 ms, and refuses a read while limit are in flight.
type store struct {
	limit        int64
	active, most atomic.Int64
}

func (s *store) read(id int) (int, error) {
	n := s.active.Add(1)
	if n > s.limit {
		s.active.Add(-1)
		return 0, errBusy
	}
	for m := s.most.Load(); n > m && !s.most.CompareAndSwap(m, n); m = s.most.Load() {
	}
	defer s.active.Add(-1)
	time.Sleep(20 * time.Millisecond)
	return 1000 + id, nil
}

// perImage starts one goroutine for each image, all at once.
func perImage(s *store, ids []int) (done, refused int) {
	var mu sync.Mutex
	var wg sync.WaitGroup
	for _, id := range ids {
		wg.Go(func() {
			_, err := s.read(id)
			mu.Lock()
			defer mu.Unlock()
			if err != nil {
				refused++
			} else {
				done++
			}
		})
	}
	wg.Wait()
	return done, refused
}

// pool starts n workers that range over one jobs channel. It shuts down in order: the producer closes jobs, each
// worker's loop ends, and a closer closes results once wg.Wait says that every worker has returned.
func pool(s *store, ids []int, n int) (done, refused int, took, waited time.Duration) {
	start := time.Now()
	jobs := make(chan int)
	results := make(chan error)
	var wg sync.WaitGroup
	for range n {
		wg.Go(func() {
			for id := range jobs {
				_, err := s.read(id)
				results <- err
			}
		})
	}
	go func() {
		for _, id := range ids {
			t := time.Now()
			jobs <- id // waits while all n workers are busy
			waited += time.Since(t)
		}
		close(jobs) // written after waited, so main reads waited only once results is closed
	}()
	go func() {
		wg.Wait()
		close(results)
	}()
	for err := range results {
		if err != nil {
			refused++
		} else {
			done++
		}
	}
	return done, refused, time.Since(start), waited
}

// about rounds a time to the nearest 100 ms: 25 rounds of 20 ms reads gather a little scheduling delay.
func about(d time.Duration) string { return "about " + d.Round(100*time.Millisecond).String() }

func main() {
	ids := make([]int, 200)
	for i := range ids {
		ids[i] = i
	}
	done, refused := perImage(&store{limit: 8}, ids)
	fmt.Printf("one goroutine per image: %3d reads done, %3d refused\n", done, refused)
	s := &store{limit: 8}
	done, refused, took, waited := pool(s, ids, 8)
	fmt.Printf("a pool of 8 workers:     %3d reads done, %3d refused\n", done, refused)
	fmt.Printf("the pool kept at most %d reads in flight and took %s\n", s.most.Load(), about(took))
	fmt.Printf("the producer waited %s in all for a free worker\n", about(waited))
}

Output

one goroutine per image:   8 reads done, 192 refused
a pool of 8 workers:     200 reads done,   0 refused
the pool kept at most 8 reads in flight and took about 500ms
the producer waited about 500ms in all for a free worker

This output changes from run to run: the reads take 20 ms of real time each, so the total times printed can differ by a round or so from run to run.

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run pool.go

One goroutine per image put all 200 reads in flight at once, and 192 were refused. The pool never had more than 8 in flight; its 25 rounds of 8 reads at 20 ms took about half a second, a time set by the store’s limit.

The producer spent nearly all of that time waiting in jobs <- id for a free worker. That wait is backpressure: A busy stage making the stage before it wait, as a full channel blocks its sender, so that extra work waits at its source instead of piling up as goroutines or memory.: the pool slows the producer to its own pace instead of letting goroutines pile up. Step through the pool and the order in which it stops:

Interactive · step by step A pool of 8 workers reading one jobs channel Step through pool.go and watch the producer wait while every worker is busy, then the closes that end the pool.
A pool of 8 workers reading one jobs channel, step 8 of 8. Showing producer (closed jobs), jobs (closed and empty), worker 1 (reading image 8), worker 2 (reading image 1), worker 8 (reading image 7), results (closed after wg.Wait()), main (200 done, 0 refused). In focus: main.producerclosed jobsjobsclosed and emptyworker 1reading image 8worker 2reading image 1worker 8reading image 7resultsclosed after wg.Wait()main200 done, 0 refused

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Eight workers wait. pool starts 8 goroutines, and each one ranges over the same jobs channel. None has a job yet, so all 8 wait to receive.
  2. The first id. The producer sends image 0. A channel gives each value to exactly one receiver, so worker 1 takes it and starts its 20 ms read.
  3. Eight reads in flight. Images 1 to 7 go to the other workers. Eight reads now run at once, exactly the store's limit, so the store refuses none of them.
  4. The producer waits. Image 8 has no free worker to take it, and jobs has no buffer, so the producer's send waits. That wait is backpressure.
  5. A worker comes back. Worker 1 finishes, sends nil on results for a read that worked, and main counts it. Worker 1 then receives image 8, and the producer moves on.
  6. The producer closes jobs. After image 199 the producer closes jobs. Each worker still gets every id already sent; once jobs is empty, its range loop ends and it returns.
  7. Close results once. A closer goroutine waits in wg.Wait() until all 8 workers have returned, and only then closes results, so no worker can send after the close.
  8. main is done. main's range over results ends after 200 results, with 200 reads done, none refused and never more than 8 at a time.

A producer that never closes jobs

Leave out close(jobs) and each worker’s range loop waits for ever after the last id, so wg.Wait() never returns, results is never closed and the runtime stops the program with all goroutines are asleep - deadlock!. Close jobs in the goroutine that sends on it, after its last send.

Each close ends the next loop: the producer closes jobs after its last send, and results is closed only after wg.Wait() has seen every worker return, so no worker can send on it later.

Key idea

A worker pool bounds the work in flight by its number of workers, makes the producer wait while all are busy, and closes jobs first and results after wg.Wait().

Checkpoint

Check: A worker pool and its shutdown order

  1. Question 1 of 2 A pool of 6 workers reads 120 images, and each read takes 50 ms. The store has no limit of its own. About how long does the whole run take?

    Type a number.

    A hint

    Count the rounds of 6 reads.

    Show the answer: Question 1 of 2

    Answer: 1000 ms (anything from 950 to 1050 counts)

    The pool runs 6 reads at a time, so 120 reads take 120 ÷ 6 = 20 rounds of 50 ms, about 1,000 ms. pool.go's run has the same shape: 200 reads, 8 at a time, 20 ms each, 25 rounds and about half a second.

  2. Question 2 of 2 The producer in pool.go sends all 200 ids but never calls close(jobs). What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: All 200 reads are done, then every worker waits for ever in its range loop, results is never closed, and the runtime reports a deadlock

    Each close in a pool ends the next loop: close(jobs) ends the workers' loops, wg.Wait() then returns, and close(results) ends main's loop. Without the first close nothing after it can happen, and with every goroutine waiting, the runtime stops the program with all goroutines are asleep - deadlock!

A buffered channel of empty structs is a counting semaphore

When code starts one goroutine per job and only the number running at once must be bounded, a buffered channel works as a counting semaphore: A count of free slots that bounds how many users may hold a resource at once. In Go a buffered channel works as one: a send takes a slot, a receive gives it back, and a send waits while every slot is taken.: its capacity is the number of slots, a send takes a slot and waits while all are taken, and a receive gives one back. The Go memory model names this idiom, and struct{} values take no memory. sem.go bounds the 200 reads with 8 slots, taken in two places:

sem.go: the slot taken inside each goroutine, then before each go Go · sem.go
// sem.go: the same 200 reads, bounded by a buffered channel of struct{} used as a counting semaphore: a send takes
// one of its 8 slots and blocks while all 8 are taken, a receive gives a slot back. Taking the slot inside each
// goroutine bounds the reads; taking it before the go statement bounds the goroutines too.
package main

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

// read stands for one read from the store of pool.go: 20 ms of waiting.
func read(id int) int {
	time.Sleep(20 * time.Millisecond)
	return 1000 + id
}

// peak counts what runs now and the most that ever ran at once.
type peak struct{ now, most atomic.Int64 }

func (p *peak) up() {
	n := p.now.Add(1)
	for m := p.most.Load(); n > m && !p.most.CompareAndSwap(m, n); m = p.most.Load() {
	}
}

func (p *peak) down() { p.now.Add(-1) }

// slotInside starts every goroutine at once; each one waits for a slot before it reads.
func slotInside(ids []int) (reads, goroutines int64) {
	sem := make(chan struct{}, 8)
	var r, g peak
	var wg sync.WaitGroup
	for _, id := range ids {
		wg.Go(func() {
			g.up()
			sem <- struct{}{} // take a slot: blocks while all 8 are taken
			r.up()
			read(id)
			r.down()
			g.down()
			<-sem // give the slot back
		})
	}
	wg.Wait()
	return r.most.Load(), g.most.Load()
}

// slotBefore takes the slot in the loop, before the go statement, so a goroutine starts only when it can read.
func slotBefore(ids []int) (reads, goroutines int64) {
	sem := make(chan struct{}, 8)
	var r, g peak
	var wg sync.WaitGroup
	for _, id := range ids {
		sem <- struct{}{} // the loop itself waits here while all 8 are taken
		wg.Go(func() {
			g.up()
			r.up()
			read(id)
			r.down()
			g.down()
			<-sem
		})
	}
	wg.Wait()
	return r.most.Load(), g.most.Load()
}

func main() {
	ids := make([]int, 200)
	for i := range ids {
		ids[i] = i
	}
	r, g := slotInside(ids)
	fmt.Printf("slot taken inside each goroutine: %d reads at once, %3d goroutines alive at once\n", r, g)
	r, g = slotBefore(ids)
	fmt.Printf("slot taken before each go:        %d reads at once, %3d goroutines alive at once\n", r, g)
}

Output

slot taken inside each goroutine: 8 reads at once, 200 goroutines alive at once
slot taken before each go:        8 reads at once,   8 goroutines alive at once

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run sem.go

Both kept the reads at 8, but with the slot taken inside each goroutine all 200 goroutines existed at once, 192 of them waiting for a slot; taken before the go statement, the loop itself waited and never more than 8 existed. Effective Go gives the same advice for a server that starts a goroutine per request: gate the creation of goroutines.

Semaphores and worker pools have two relatives in the golang.org/x/sync module. errgroup.Group with SetLimit(n) starts at most n goroutines at once and keeps the first error any of them returns. semaphore.Weighted lets one job take several units, so a large image can count as four reads:

Option What it bounds Goroutines alive When the bound is reached
worker pool jobs in flight N, all run long the producer’s send waits
channel semaphore, slot before go goroutines and jobs at most N the loop’s send waits
errgroup.Group with SetLimit(n) goroutines of the group at most n Go waits, TryGo reports false
semaphore.Weighted the units in use as many as started Acquire waits, or its context ends

Pool or semaphore

Use a pool when each worker keeps something between jobs, such as a connection; use a semaphore taken before go when each job is a self-contained call.

How large N should be depends on what the jobs do. Jobs that only compute gain little from more workers than GOMAXPROCS: The number of goroutines that can run Go code at the same moment, the number of Ps in the scheduler. It defaults to the number of logical CPUs and, on Linux since Go 1.25, to a lower cgroup CPU limit. More in “Goroutines and the Go scheduler”, while jobs that wait on another service can keep many more busy. A full queue in front of the workers leaves a producer three answers: wait for room, be refused at once, or wait until a deadline.

Checkpoint

Check: A buffered channel as a counting semaphore

  1. Question 1 of 2 sem := make(chan struct{}, 3). Four goroutines each run sem <- struct{}{}, then work, then <-sem. What happens to the fourth?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its send waits until one of the first three receives, giving its slot back

    The channel's capacity is the number of slots: a send takes one, a receive gives one back, and a send with every slot taken waits. The Go memory model describes exactly this use of a buffered channel.

  2. Question 2 of 2 A job runner gets 100,000 jobs, each needing a 1 MB buffer while it runs, and must run at most 16 at once. Where should it take the semaphore slot?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In the loop, before the go statement, and allocate the buffer inside the goroutine

    Taking the slot before go bounds the goroutines as well as the work, which matters when jobs are many or hold memory. Effective Go gives the same fix for a server that starts a goroutine for every request.

Sizing a pool from measurements and Little's law Pro: opens with a Pro, Premium or Ultimate pass

How many workers a pool should have depends on what each job spends its time doing. This part measures a pool on jobs that compute and on jobs that wait, then turns the measurements into a size with Little’s law.

In this part:

  • Jobs that compute stop getting faster at GOMAXPROCS, measured
  • Jobs that wait: Little’s law turns a latency into a pool size
  • A service that serves 16 at a time caps the pool’s throughput

2 runnable examples (Go) · about 6 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

A full queue, a clean shutdown and the x/sync types, run Pro: opens with a Pro, Premium or Ultimate pass

A bounded pool must decide what happens to work that arrives faster than it can be done, and how to stop without losing a job or sending on a closed channel. This part runs three answers to a full queue, a shutdown with a deadline, and the same bound kept by golang.org/x/sync.

In this part:

  • Three answers to a full queue: wait, refuse, or give up at a deadline
  • A shutdown that loses no job and never sends on a closed queue
  • errgroup.SetLimit and semaphore.Weighted keep the same bound

1 diagram · 4 runnable examples (Go, Go (pool.go), Go (pool_test.go), MOD, Python) · about 7 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise: thumbnails, at most n at a time

practice/resize.go has ResizeAll to write: call resize for every photo, at most n at a time, and return the thumbnails in the photos’ order. practice/resize_test.go checks the order, the bound and that the calls really overlap, inside synctest bubbles, so a 10 ms resize costs no real time:

practice/resize.go and practice/resize_test.go

Go (resize.go) · practice/resize.go

// Package practice is the practice of the lesson on worker pools: ResizeAll makes a thumbnail of every photo, with
// at most n resizes running at the same time.
package practice

// Thumb is the thumbnail of one photo.
type Thumb struct {
	Path  string
	Bytes int
}

// ResizeAll calls resize once for every path, with at most n calls running at the same time, and returns the
// thumbnails in the order of paths. It returns once every call has returned, and leaves no goroutine behind.
func ResizeAll(paths []string, n int, resize func(path string) Thumb) []Thumb {
	return nil // replace this with at most n resizes at a time
}

Go (resize_test.go) · practice/resize_test.go

package practice

import (
	"fmt"
	"sync"
	"testing"
	"testing/synctest"
	"time"
)

func photos(count int) []string {
	var paths []string
	for i := range count {
		paths = append(paths, fmt.Sprintf("photo-%02d.jpg", i))
	}
	return paths
}

// counter is a resize of 10 ms of the bubble's clock that records how many calls ran at the same time.
type counter struct {
	mu           sync.Mutex
	active, most int
}

func (c *counter) resize(path string) Thumb {
	c.mu.Lock()
	c.active++
	c.most = max(c.most, c.active)
	c.mu.Unlock()
	time.Sleep(10 * time.Millisecond)
	c.mu.Lock()
	c.active--
	c.mu.Unlock()
	return Thumb{Path: path, Bytes: len(path)}
}

// TestOrderKept gives the first photos the slowest resizes, so the calls finish in the opposite order.
func TestOrderKept(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		paths := photos(8)
		index := map[string]int{}
		for i, p := range paths {
			index[p] = i
		}
		got := ResizeAll(paths, 4, func(path string) Thumb {
			time.Sleep(time.Duration(len(paths)-index[path]) * time.Millisecond) // photo 0: 8 ms, photo 7: 1 ms
			return Thumb{Path: path, Bytes: len(path)}
		})
		if len(got) != len(paths) {
			t.Fatalf("got %d thumbnails; want %d", len(got), len(paths))
		}
		for i, th := range got {
			if th.Path != paths[i] {
				t.Fatalf("thumbnail %d is %s; want %s", i, th.Path, paths[i])
			}
		}
	})
}

// TestAtMostN checks the bound and that the resizes really overlap: 10 photos, 3 at a time, 10 ms each, take
// 4 rounds, 40 ms of the bubble's clock.
func TestAtMostN(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		var c counter
		start := time.Now()
		got := ResizeAll(photos(10), 3, c.resize)
		if took := time.Since(start); len(got) != 10 || c.most != 3 || took != 40*time.Millisecond {
			t.Fatalf("%d thumbnails, at most %d resizes at once, in %v; want 10, 3 and 40ms", len(got), c.most, took)
		}
	})
}

// TestOneAtATime: with n = 1 the resizes run one after another, 5 photos in 50 ms.
func TestOneAtATime(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		var c counter
		start := time.Now()
		got := ResizeAll(photos(5), 1, c.resize)
		if took := time.Since(start); len(got) != 5 || c.most != 1 || took != 50*time.Millisecond {
			t.Fatalf("%d thumbnails, at most %d resizes at once, in %v; want 5, 1 and 50ms", len(got), c.most, took)
		}
	})
}

The starter code compiles, and every test fails:

The starter ResizeAll against its tests Python · practice_session.py
"""The starter ResizeAll of practice/resize.go against its tests: all three fail until the pool is written."""

import session

session.run(
    """
cd practice && go test .
""",
    expect_failure=["cd practice && go test ."],
)

Output

$ cd practice && go test .
--- FAIL: TestOrderKept (…s)
    resize_test.go:50: got 0 thumbnails; want 8
--- FAIL: TestAtMostN (…s)
    resize_test.go:68: 0 thumbnails, at most 0 resizes at once, in 0s; want 10, 3 and 40ms
--- FAIL: TestOneAtATime (…s)
    resize_test.go:80: 0 thumbnails, at most 0 resizes at once, in 0s; want 5, 1 and 50ms
FAIL
FAIL	example.com/practice	…s
FAIL
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

Make go test . pass in the practice folder, then run go test -race . too. A first hint: take a slot of a buffered channel before each go statement, and write each thumbnail at its own index of a slice made with make([]Thumb, len(paths)).

The self-check below covers the pool and the semaphore.

Practice that adapts to you

Practise this lesson

7 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 7 Why does each job of a worker pool run exactly once, although 8 workers range over the same channel?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: A receive takes the value out of the channel, so each value goes to one receiver

    A pool needs no bookkeeping to share work: the channel gives each job to one waiting worker, and the next job to whichever worker is free next.

  2. Question 2 of 7 In pool.go, why is close(results) in a goroutine of its own after wg.Wait(), rather than at the end of each worker?

    Choose one answer.

    Show the answer: Question 2 of 7

    Answer: Only after every worker has returned can no worker send on results any more

    A channel with several senders is closed once, after a WaitGroup has seen every sender finish, as for the merge of a fan-in.

  3. Question 3 of 7 A pool of 4 workers takes 100 jobs of 30 ms from an unbuffered jobs channel. About how long does the producer wait in all for a free worker?

    Type a number.

    A hint

    The producer's last send happens when the last round starts.

    Show the answer: Question 3 of 7

    Answer: 720 ms (anything from 680 to 760 counts)

    100 jobs at 4 a time make 25 rounds. The producer's last send waits for the start of round 25, after 24 rounds of 30 ms, so it waits about 720 ms in all, almost the whole run, as pool.go's producer waited nearly all of its half second.

  4. Question 4 of 7 One goroutine per request was fast in a test against a local fake store. Why can it still fail in production, as pool.go's first run did?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: The real store has a limit of its own, and a burst sends more requests at once than it serves

    Unbounded concurrency moves the limit to whatever is downstream: a store, a database pool, an API's rate. A pool keeps the burst in the producer's queue instead.

  5. Question 5 of 7 In a semaphore made from a buffered channel, what does the capacity stand for?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: The most uses that may run at the same time

    Items in the channel are the uses running now, its capacity is the most that may run, a send acquires and a receive releases.

  6. Question 6 of 7 A function takes a slot with sem <- struct{}{} and returns early on an error, before its <-sem. The semaphore has 8 slots. What happens after 8 such errors?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: Every later job waits for a slot for ever, because the 8 slots are never given back

    Release the slot with defer, right after taking it: sem <- struct{}{} then defer func() { <-sem }(), so every return path gives it back.

  7. Question 7 of 7 Each job needs a database connection that is expensive to open, and 8 jobs may run at once. Which shape fits best?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: A pool of 8 workers, each opening one connection and keeping it for all its jobs

    Choose a pool when a worker keeps something between jobs, and a semaphore with the slot taken before go when each job is a self-contained call.

Interview questions

A warm-up, answered in full, then three module-bank questions about pools under load:

  1. Fresher to mid · Warm-up

    How does a buffered channel of struct{} work as a semaphore, and where do you take the slot?

    Its capacity is the number of slots. A send takes a slot and waits while every slot is taken; a receive gives one back, so at most that many goroutines are past the send at once. struct{} values take no memory, so the channel holds only the count. When the jobs are many, take the slot in the loop, before the go statement: the loop then waits, and no more goroutines exist than slots. Taken inside each goroutine, the slot bounds the work, but every goroutine still starts and waits for a slot. Give the slot back with defer, so that an early return cannot keep it.

  2. Mid to senior

    An upload service starts one goroutine per uploaded photo, and each goroutine calls an image API that accepts at most 20 requests at once. A marketing email brings 5,000 uploads in a minute, and most of them fail. Explain why, and redesign the service.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Senior to staff

    A Go service must make 1,500 calls a second to a payments API whose median latency is 80 ms and whose p99 is 400 ms. How many workers do you give the pool, and what happens to that choice when the API's latency doubles during an incident?

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    On every deploy, a service with an HTTP server and a worker pool loses some queued jobs, and once in a while it panics with "send on closed channel". Walk through a shutdown that loses nothing, and say what you do when the deadline is too short to drain the queue.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Worker pools bound the work in flight by their number of workers, and the producer waits while all are busy, which is backpressure.
  • A pool stops in order: the producer closes jobs after its last send, and results is closed after wg.Wait().
  • A buffered channel of struct{} is a counting semaphore; taken before go, its slot bounds the goroutines too, so semaphores give bounded parallelism without a pool.

Terms in this lesson

backpressure
A busy stage making the stage before it wait, as a full channel blocks its sender, so that extra work waits at its source instead of piling up as goroutines or memory.
counting semaphore
A count of free slots that bounds how many users may hold a resource at once. In Go a buffered channel works as one: a send takes a slot, a receive gives it back, and a send waits while every slot is taken.
worker pool
A fixed number of goroutines that take jobs from one shared channel, so that at most that many jobs run at once, however many are waiting.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.