Irbisa · Cheatsheet

Goroutines & Channels

Channels, select, worker pools, goroutine leaks, errgroup

Middle Developer · 25 questions

  1. 01

    What is a goroutine, and how does it differ from an OS thread?

    A goroutine is a function running concurrently under the Go runtime's own scheduler: it starts with a small stack that grows on demand, and the runtime multiplexes many goroutines onto a few OS threads, so starting one is cheap and needs no system call.

  2. 02

    What is the difference between concurrency and parallelism?

    Concurrency is structuring a program as tasks that make progress independently; parallelism is executing several of them at the same instant on different cores.

  3. 03

    What happens to goroutines that are still running when main returns, and how do you make main wait for them?

    When main returns the process exits and the runtime cuts off every other goroutine mid-flight without running its deferred calls; to wait for them use a sync.WaitGroup (or a channel).

    func main() {	var wg sync.WaitGroup	for i := range 3 {		wg.Add(1) // before `go`, in the parent		go func() {			defer wg.Done()…
  4. 04

    How do you get a result out of a goroutine, given that go f() throws its return values away?

    Hand the result back through something the caller waits on: a channel, a pre-sized slice plus a sync.WaitGroup, or an errgroup.Group when errors matter too.

    func squares(nums []int) []int {	results := make([]int, len(nums))	var wg sync.WaitGroup	for i, n := range nums {		wg.Add(1)		go func() {…
  5. 05

    What is the difference between buffered and unbuffered channels, and when does a send or a receive block on each?

    An unbuffered channel (make(chan T)) is a handoff: a send blocks until another goroutine receives, and a receive until someone sends.

    func main() {	unbuf := make(chan int)	go func() { unbuf <- 1 }() // waits until main receives	fmt.Println(<-unbuf)       // 1 	buf := make(chan int, 2)…
  6. 06

    What happens when you send to, receive from or close a closed channel, and what happens with a nil channel?

    On a closed channel a receive returns the remaining buffered values and then the zero value forever, while a send or a second close panics.

    func main() {	ch := make(chan int, 2)	ch <- 1	ch <- 2	close(ch)…
  7. 07

    How does select work: what happens when several cases are ready, what is default for, and how do you add a timeout?

    select waits until one of its channel operations can proceed and runs that case, choosing at random if several are ready; default makes it non-blocking, and a timeout is just one more case <-time.After(d) or case <-ctx.Done().

    func receive(ctx context.Context, ch <-chan Msg) (Msg, error) {	select {	case m := <-ch:		return m, nil	case <-time.After(time.Second):		return Msg{}, errors.New("timeout")…
  8. 08

    What does this program print, and why does the runtime report a deadlock here but not in a hung production service?

    func main() {	ch := make(chan int, 3)	for i := 1; i <= 3; i++ {		ch <- i	}	for v := range ch {		fmt.Println(v)	}	fmt.Println("done")}

    It prints 1, 2, 3, then dies with fatal error: all goroutines are asleep - deadlock!: range waits for a fourth value because nobody closed ch, and no other goroutine could send one.

  9. 09

    Who should close a channel, and what do the directional types <-chan T and chan<- T add?

    The sender closes (the goroutine that knows no more values are coming) and receivers never do.

    // The stage owns its output: it creates it, sends on it and closes it.func square(in <-chan int) <-chan int {	out := make(chan int)	go func() {		defer close(out) // the only sender closes, once		for v := range in {…
  10. 10

    This program is meant to print 1000. What does it actually print, and how do you fix it?

    func main() {	var count int	var wg sync.WaitGroup	for i := 0; i < 1000; i++ {		wg.Add(1)		go func() {			defer wg.Done()			count++		}()	}	wg.Wait()	fmt.Println(count)}

    A number below 1000 that changes from run to run: count++ is a load, an add and a store, so two goroutines can read the same value and one increment is lost.

  11. 11

    Find the bug: fetchAll returns empty strings for pages that downloaded fine.

    func fetchAll(urls []string) []string {	var wg sync.WaitGroup	bodies := make([]string, len(urls))	for i, u := range urls {		go func() {			wg.Add(1)			defer wg.Done()			bodies[i] = fetch(u)		}()	}	wg.Wait()	return bodies}

    wg.Add(1) runs inside the new goroutine, so wg.Wait() can see a zero counter before any goroutine has started and return at once; fetchAll then hands back a slice the workers are still filling.

  12. 12

    How is a channel implemented in the runtime, and what happens to a goroutine that blocks on a send?

    A channel is a pointer to a runtime hchan struct: a lock, an optional ring buffer, and two queues of parked goroutines, sendq and recvq.

  13. 13

    When do you use a channel and when a mutex, and why is "share memory by communicating" not always the answer?

    Use a channel when ownership of data moves between goroutines or when you must wait on several events at once; use a mutex when goroutines share state with a short critical section.

  14. 14

    What is a goroutine leak, what typically causes one, and how do you detect it?

    A goroutine leak is a goroutine that never returns because it waits for something that will never happen; it keeps its stack and everything it references alive, so memory and the goroutine count grow with traffic.

    // wrong: on timeout the handler returns and the worker parks in its send foreverfunc handle(w http.ResponseWriter, r *http.Request) {	out := make(chan Result) // right: make(chan Result, 1)	go func() { out <- compute(r.Context()) }() 	select {…
  15. 15

    What is the difference between time.After, time.NewTimer, time.NewTicker and time.AfterFunc, and what changed for timers in Go 1.23?

    time.After(d) returns a channel that fires once; NewTimer does the same but can be stopped and reset; NewTicker fires repeatedly; AfterFunc runs a function in its own goroutine instead.

    func relay(ctx context.Context, in <-chan Msg) error {	const idle = 5 * time.Minute	t := time.NewTimer(idle) // one timer for the whole loop	defer t.Stop() 	for {…
  16. 16

    How do you implement a worker pool in Go, collect the results and shut the workers down cleanly?

    Start N goroutines that range over one jobs channel and send to one results channel; the producer closes jobs when it is done, and a separate goroutine closes results after wg.Wait(), which ends the caller's range over results.

    func process(ctx context.Context, jobs []Job, n int) []Result {	in := make(chan Job)	out := make(chan Result) 	var wg sync.WaitGroup	for range n {…
  17. 17

    How do you limit how many goroutines run at once, and how does that differ from a worker pool?

    Use a buffered channel as a counting semaphore (sem := make(chan struct{}, 8), send before starting the work, receive when it ends) or errgroup.Group with SetLimit(8).

    func uploadAll(ctx context.Context, items []Item) error {	sem := make(chan struct{}, 8)	var wg sync.WaitGroup	for _, it := range items {		select {		case sem <- struct{}{}: // acquire before go: the loop waits here…
  18. 18

    How do you merge N channels into one (fan-in), and what happens if one of the inputs never closes?

    Start one goroutine per input that copies its values to a shared output, plus one goroutine that closes the output once a WaitGroup says every copier is done.

    func Merge[T any](ctx context.Context, ins ...<-chan T) <-chan T {	out := make(chan T)	var wg sync.WaitGroup	for _, in := range ins {		wg.Go(func() {			for {…
  19. 19

    Write two goroutines that print the numbers 1 to N alternately: one prints the odd numbers, the other the even ones.

    Give each goroutine its own unbuffered channel as a turn token: it waits for its token, prints, and passes the token to the other.

    func player(from, n int, mine <-chan struct{}, next chan<- struct{}) {	for i := from; i <= n; i += 2 {		<-mine // wait for our turn		fmt.Println(i)		if i < n { // nobody is left to take the turn after the last number			next <- struct{}{}…
  20. 20

    How do you run N tasks in parallel, cancel the rest on the first error and wait for all of them?

    Use errgroup.WithContext from golang.org/x/sync/errgroup: every task started with g.Go uses the shared context, the first non-nil error cancels it, and g.Wait() waits for every task and returns that first error.

    func fetchAll(ctx context.Context, urls []string) ([]Page, error) {	g, ctx := errgroup.WithContext(ctx)	g.SetLimit(16) // once, before the first Go	pages := make([]Page, len(urls)) 	for i, u := range urls {…
  21. 21

    How would you implement a goroutine-safe rate limiter, and when would you use golang.org/x/time/rate instead?

    Use a token bucket: tokens refill at a fixed rate up to a burst size, each request takes one, and an empty bucket means wait or reject.

    type Bucket struct {	mu     sync.Mutex	tokens float64	burst  float64	rate   float64 // tokens per second	last   time.Time…
  22. 22

    How would you write a goroutine-safe in-memory cache with a TTL, without starting a goroutine per key?

    Keep a map of entries with an expiry time behind a sync.RWMutex, treat expired entries as missing on read, and delete them with one janitor goroutine per cache that sweeps on a ticker and stops on Close.

    type entry struct {	val     string	expires time.Time} type Cache struct {…
  23. 23

    How do you build a pipeline of channel stages, and how do the stages shut down when the consumer stops reading early?

    Each stage takes an input channel, starts a goroutine that reads it, and returns an output channel it closes when done.

    func gen(ctx context.Context, nums ...int) <-chan int {	out := make(chan int)	go func() {		defer close(out)		for _, n := range nums {			select {…
  24. 24

    What is backpressure in a channel pipeline, what does a buffer actually buy you, and what do you do when the consumer is permanently slower?

    Backpressure is the slow consumer slowing the producer down: a blocking send on a full channel makes the producer wait, so work never piles up faster than it is processed.

  25. 25

    Parallel workers made a stage faster but scrambled the output order. How do you restore input order without holding the whole stream in memory?

    Fix each item's position before the work fans out: a single dispatcher gives every item its own one-slot result channel and pushes those channels onto a FIFO queue in input order; the consumer reads the queue and waits on each result in turn.

    type job struct {	row Row	res chan Result // cap 1: the worker never waits for a slow consumer} func enrich(rows []Row, n int) <-chan Result {…