Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

A goroutine is a function running concurrently with others in the same program. A channel is a typed pipe that lets goroutines pass values and wait for each other. select waits on several channel operations at once and runs whichever one can proceed. The three pieces are simple to start with, but each has rules that are easy to misremember, and most concurrency bugs come from those misremembered rules. This article builds a working mental model from the Go Programming Language Specification and the Go Memory Model, and marks where folklore goes beyond what those documents promise.

How goroutines work

A go statement starts a function call and returns immediately, without waiting for the call to finish. The new goroutine and the caller then run concurrently in the same address space. When the function returns, its goroutine exits. The caller does not wait for it unless the program makes it wait, through a channel, a sync.WaitGroup, or a similar mechanism.

Two rules follow directly from that. First, when main returns, the program exits, even if other goroutines are still running. Second, a goroutine is not a thread you can reason about with thread semantics. The Effective Go documentation describes goroutines as independently executing functions multiplexed onto operating-system threads, with the runtime scheduling work so that other goroutines keep moving when one blocks. Goroutines are cheap compared with threads, but they are not free, and a program that starts millions of them without a plan will still run out of memory.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Concurrency is not parallelism

Concurrency is a way of structuring a program as independently progressing tasks. Parallelism is tasks actually executing at the same instant on multiple processors. Go’s FAQ makes this distinction explicitly. A program can be concurrent on one CPU core, and a concurrent design does not make a sequential computation faster by itself. Parallel speedup depends on whether the work divides cleanly and whether the useful computation outweighs coordination overhead.

How channels work

A channel carries values of one declared type. Its two operations are send (ch <- v) and receive (v := <-ch). Whether an operation blocks depends on the channel’s capacity, which is set at creation with make.

Property Unbuffered (make(chan T)) Buffered (make(chan T, n), n > 0)
Send completes when A receiver is ready and takes the value There is free space in the buffer
Receive completes when A sender is ready and provides a value The buffer holds a value
Synchronization Each completed send/receive is a rendezvous between the two goroutines A completed send does not mean the value has been received yet
Typical use Handoffs, completion signals, and coordination Limited decoupling, such as a result that must not block its sender
Failure if misused Sending with no receiver blocks forever, and the runtime reports a deadlock if every goroutine is blocked Once the buffer is full, senders block just as they would on an unbuffered channel

Two examples make the difference concrete.

ch := make(chan int, 1)
ch <- 1          // does not block: the buffer has room
fmt.Println(<-ch) // prints 1

ch2 := make(chan int)
ch2 <- 1         // blocks forever: nothing is receiving

A buffer changes when operations can proceed. It does not decide who owns the data or when the channel is closed. Those decisions still need an explicit design.

A completion signal

The simplest useful pattern is a goroutine that reports completion on an unbuffered channel. The receive in main both waits and receives the value.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
package main

import "fmt"

func worker(done chan<- string) {
    fmt.Println("working")
    done <- "finished"
}

func main() {
    done := make(chan string)
    go worker(done)
    msg := <-done
    fmt.Println(msg)
}

How select works

A select statement looks like a switch, but every case is a channel communication. The Go Programming Language Specification defines its behavior in these steps:

  1. On entry, Go evaluates each case’s channel operand and any send value, exactly once, in source order. Side effects in those expressions happen even if that case is never chosen.
  2. If one or more communications can proceed, exactly one of them is chosen by uniform pseudo-random choice. Textual order gives no priority.
  3. If none can proceed and there is a default case, the default body runs immediately.
  4. If none can proceed and there is no default, the statement blocks until some case can proceed.

Here is a select that receives from two channels and does not wait:

select {
case msg := <-messages:
    fmt.Println("message:", msg)
case err := <-errs:
    fmt.Println("error:", err)
default:
    fmt.Println("nothing ready")
}

Without the default case, the same statement would block until a message or an error arrived. Adding default turns the wait into a single non-blocking check. Putting that check in a loop without a pause makes the loop spin and burn CPU, so a polling loop needs a timer or a real blocking wait.

A nil channel never becomes ready in a select. Setting a channel variable to nil is a common way to disable one case, for example after a producer has finished and you no longer want to read from its channel.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Facts to keep in mind

  • Channels communicate and synchronize, but they do not prevent data races on their own. The Go Memory Model says programs that modify data accessed simultaneously by multiple goroutines must serialize that access. Serialization can come from channel ownership, a mutex, or an atomic operation, but it has to be designed in.
  • Effective Go’s slogan has a limit. Its concurrency section says “Do not communicate by sharing memory; instead, share memory by communicating.” The same section notes that this approach can be taken too far, and that a mutex is often the clearer choice for simple shared state such as a reference count.
  • Select does not prioritize the first ready case. If two cases are ready, either can be chosen. Code that needs priority must check the higher-priority channel first in a separate step.
  • A timeout only stops the caller from waiting. It does not stop the goroutine doing the work. That goroutine must be told to stop, or it must be able to finish without blocking.

Timeouts without leaked senders

The usual timeout pattern runs the work in a goroutine and selects between its result and a timer. The result channel needs a buffer of one. Without it, a late result would block the goroutine forever, because the caller has already moved on.

package main

import (
    "fmt"
    "time"
)

func query() string {
    time.Sleep(2 * time.Second)
    return "result"
}

func main() {
    c := make(chan string, 1)
    go func() { c <- query() }()

    select {
    case res := <-c:
        fmt.Println(res)
    case <-time.After(1 * time.Second):
        fmt.Println("timed out")
    }
}

In this program, main prints “timed out” after one second. The query goroutine keeps running for the rest of its two seconds and then sends into the buffered channel, which has room, so it exits cleanly. The work was not cancelled. Only the caller stopped waiting.

To cancel the work itself, pass a context.Context made with context.WithTimeout or context.WithCancel, and have the worker check ctx.Done() in its own select. Cancellation is cooperative: a worker that never checks the context keeps running.

Bounding concurrency

Starting one goroutine per task is simple, but when the number of tasks is large, the number of waiting goroutines grows with it. Gating the work inside each goroutine does not limit how many goroutines exist. Effective Go’s examples favor a fixed set of workers that read from a shared channel, which caps the number of goroutines doing the work.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
func process(jobs []int) {
    const workers = 4
    jobCh := make(chan int)
    var wg sync.WaitGroup

    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := range jobCh {
                fmt.Println(j * j)
            }
        }()
    }

    for _, j := range jobs {
        jobCh <- j
    }
    close(jobCh)
    wg.Wait()
}

This example assumes imports of fmt and sync. Output order is not deterministic, because the workers run in parallel. If the results must be in input order, collect them with an index.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Common mistakes

  • “Select takes the first ready case.” It chooses uniformly among ready cases.
  • “Default waits a little.” It runs at once when no case can proceed.
  • “Channels remove the need for locks.” They remove the need for locks only when ownership of the data is actually transferred between goroutines.
  • “The goroutine stops when the timeout fires.” It keeps running until it returns or is told to stop.
  • “Concurrent code is faster.” It is faster only when independent work can run in parallel and coordination costs stay small.
  • Loop variables captured by goroutines. Before Go 1.22, a loop variable was shared across iterations, so goroutines launched in a loop could all see the same value. Since Go 1.22, each iteration gets its own variable. Check the Go version your project targets before relying on either behavior.

When to use select

Use a blocking select when a goroutine must react to whichever of several events happens first, such as a data channel, an error channel, and a cancellation signal. Use a select with default when the goroutine has other work to do and only wants to check whether an event has already arrived. Use a timer case when a wait must end after a fixed time. Avoid selects whose only purpose is to pick one of several channels that are always ready, because a plain receive is clearer.

Where to go next

The Go Programming Language Specification is the reference for channel and select behavior. The Go Memory Model defines which reads of shared data are guaranteed to observe writes, and it is the place to check any claim about ordering. Run concurrent code under the race detector (go test -race or go run -race) to find unsynchronized access that the specification forbids. Effective Go’s concurrency section covers the design patterns shown here, and the official Go learning page lists books and training if you want a structured course.

Performance claims about goroutine overhead or speedup depend on the Go version, the hardware, and the workload. Measure your own program before deciding that a concurrent design is worth its complexity.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.