Goroutines and Channels

Article · 14:00

A goroutine is a function running concurrently, managed by the Go runtime rather than the operating system, and starting one costs a couple of kilobytes - so a program can have hundreds of thousands. WaitGroups, channels as synchronisation, select, and the four deadlocks.

Goroutines and Channels

A goroutine is a function running concurrently, managed by Go's runtime rather than the operating system. Starting one costs a couple of kilobytes, so a program can have hundreds of thousands - which is why Go code blocks freely instead of contorting itself around callbacks.

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	start := time.Now()

	// A WaitGroup counts outstanding work. Add before starting, Done when
	// finished, Wait until the count reaches zero.
	var wg sync.WaitGroup
	for i := 1; i <= 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			time.Sleep(100 * time.Millisecond)   // pretend this is I/O
			fmt.Printf("  worker %d finished\n", i)
		}()
	}
	wg.Wait()

	fmt.Printf("four 100ms jobs in about 100ms: %t\n", time.Since(start) < 350*time.Millisecond)
}

Without the Wait, main returns and the program exits - taking every goroutine with it, finished or not. Nothing waits for a goroutine automatically.

Channels: typed pipes between goroutines

package main

import "fmt"

func main() {
	// Unbuffered: a send blocks until a receive is ready, and vice versa.
	// That is a synchronisation point, not just a queue.
	messages := make(chan string)
	go func() { messages <- "from the goroutine" }()
	fmt.Println(<-messages)

	// Buffered: a send blocks only when the buffer is full.
	buffered := make(chan int, 3)
	buffered <- 1
	buffered <- 2
	fmt.Println(len(buffered), cap(buffered))
	fmt.Println(<-buffered, <-buffered)

	// Closing says "no more values". Ranging over a channel ends when it closes.
	work := make(chan int, 5)
	go func() {
		defer close(work)                    // the sender closes, never the receiver
		for i := 1; i <= 5; i++ {
			work <- i * i
		}
	}()

	total := 0
	for value := range work {
		total += value
	}
	fmt.Println("total", total)

	// Receiving from a closed channel gives the zero value immediately; the
	// comma-ok form tells you which.
	if v, ok := <-work; !ok {
		fmt.Printf("closed, got the zero value %d\n", v)
	}
}

The deadlocks you will actually hit

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	// 1. Sending on an unbuffered channel with no receiver ready.
	//    ch := make(chan int); ch <- 1     // fatal error: all goroutines are asleep
	//    The fix is a receiver, or a buffer.
	ch := make(chan int, 1)
	ch <- 1
	fmt.Println("buffered send did not block:", <-ch)

	// 2. Ranging over a channel nobody closes - the loop waits forever.
	//    The fix is close() in the sender, usually with defer.
	done := make(chan int, 3)
	go func() {
		defer close(done)
		for i := 0; i < 3; i++ {
			done <- i
		}
	}()
	for range done {
	}
	fmt.Println("range ended because the sender closed")

	// 3. Forgetting wg.Done, so Wait never returns. defer is the fix.
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()                       // even if this panics
		time.Sleep(10 * time.Millisecond)
	}()
	wg.Wait()
	fmt.Println("Wait returned because Done was deferred")

	// 4. Sending to a channel whose reader has gone away. Select with a
	//    default, or a done channel, is the escape.
	orphan := make(chan int)
	select {
	case orphan <- 1:
		fmt.Println("sent")
	default:
		fmt.Println("nobody listening, and we did not block")
	}
}

select: waiting on several channels

package main

import (
	"fmt"
	"time"
)

func main() {
	fast := make(chan string)
	slow := make(chan string)

	go func() { time.Sleep(20 * time.Millisecond); fast <- "fast" }()
	go func() { time.Sleep(80 * time.Millisecond); slow <- "slow" }()

	// select takes whichever is ready first.
	for received := 0; received < 2; received++ {
		select {
		case msg := <-fast:
			fmt.Println("got", msg)
		case msg := <-slow:
			fmt.Println("got", msg)
		}
	}

	// A timeout is just another case.
	quiet := make(chan string)
	select {
	case msg := <-quiet:
		fmt.Println(msg)
	case <-time.After(50 * time.Millisecond):
		fmt.Println("gave up after 50ms")
	}

	// A default makes the whole select non-blocking.
	select {
	case msg := <-quiet:
		fmt.Println(msg)
	default:
		fmt.Println("nothing ready, carrying on")
	}
}

Channels are for handing over, mutexes are for protecting

Both are correct tools. The rule of thumb: use a channel when a value moves from one goroutine to another; use a sync.Mutex when several goroutines share one piece of state.

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Increment() {
	c.mu.Lock()
	defer c.mu.Unlock()          // unlocked even if the body panics
	c.n++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

func main() {
	counter := &Counter{}

	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for n := 0; n < 100; n++ {
				counter.Increment()
			}
		}()
	}
	wg.Wait()

	fmt.Println("counter:", counter.Value(), "- exactly 10000, every time")

	// Without the mutex this is a data race: run it under `go run -race` and
	// the detector will say so. Use -race in CI; it catches what tests miss.
}

What to take away

  • A goroutine is cheap; nothing waits for one unless you make it.
  • sync.WaitGroup: Add before starting, defer Done, Wait at the end.
  • An unbuffered channel is a synchronisation point, not merely a queue.
  • The sender closes a channel, never the receiver; range ends when it closes.
  • select waits on several channels; time.After is a timeout, default is non-blocking.
  • Channels hand values over, mutexes protect shared state - and -race finds the rest.