Skip to content
C Codeloom
Go

Goroutines and Channels in Go: Concurrency Basics

Learn Go concurrency from the ground up: launching goroutines, communicating via channels, using select, range, and building a worker pool pattern.

·5 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • How to launch goroutines with the go keyword
  • The difference between unbuffered and buffered channels
  • How to use select for multi-channel coordination
  • How to iterate values with range over a channel
  • How to build a simple worker pool

Prerequisites

  • Comfortable with Go basics from [Go install and first program](/blog/go-install-and-first-program)
  • Familiar with [Go functions](/blog/go-functions)

Concurrency in Go is centered on two primitives: goroutines and channels. Goroutines are lightweight, independently scheduled functions; channels are typed pipes that let goroutines exchange values safely. Together they replace the heavyweight thread-and-lock model of many other languages with a more composable style summarized by the slogan: “Do not communicate by sharing memory; share memory by communicating.”

Launching a goroutine

A goroutine is started by placing the go keyword in front of a function call. The runtime schedules it onto an OS thread; thousands or millions of goroutines can coexist because each starts with a tiny stack that grows as needed.

package main

import (
	"fmt"
	"time"
)

func greet(name string) {
	fmt.Println("hello,", name)
}

func main() {
	go greet("Ada")
	go greet("Linus")
	time.Sleep(100 * time.Millisecond) // wait so main does not exit first
	fmt.Println("done")
}

The time.Sleep is a placeholder. In real code you synchronize with channels or a sync.WaitGroup instead of sleeping.

Channels: the typed pipe

A channel is created with make(chan T). Sending uses ch <- v; receiving uses v := <-ch. An unbuffered channel synchronizes the sender and receiver: a send blocks until a receive is ready and vice versa.

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		ch <- 42 // blocks until main receives
	}()

	v := <-ch
	fmt.Println("got", v)
}

This rendezvous semantics is one of the cleanest tools for handoff between goroutines.

Buffered channels

A buffered channel decouples sender and receiver up to its capacity. Sends block only when the buffer is full; receives block only when it is empty.

ch := make(chan string, 2)
ch <- "first"
ch <- "second"
// ch <- "third" would block until something is received

fmt.Println(<-ch) // first
fmt.Println(<-ch) // second

Buffered channels are useful for smoothing bursts or implementing simple semaphores. They are not a substitute for proper synchronization; if you find yourself sizing buffers to “avoid deadlocks,” your design probably needs rethinking.

Closing and ranging

The producer closes a channel with close(ch) to signal “no more values.” Receivers can iterate with range, which exits when the channel is closed and drained.

package main

import "fmt"

func produce(out chan<- int) {
	for i := 1; i <= 3; i++ {
		out <- i
	}
	close(out)
}

func main() {
	ch := make(chan int)
	go produce(ch)

	for v := range ch {
		fmt.Println(v)
	}
}

Only the sender should close a channel, and never send on a closed channel; doing so panics. Receivers can also use the comma-ok form to detect closure: v, ok := <-ch.

Directional channels

Function parameters can restrict direction: chan<- T for send-only and <-chan T for receive-only. This makes intent explicit and prevents accidental misuse.

func writer(out chan<- int) { out <- 1 }
func reader(in <-chan int)  { fmt.Println(<-in) }

The select statement

select lets a goroutine wait on multiple channel operations. Whichever case is ready first runs; if several are ready, one is chosen at random.

package main

import (
	"fmt"
	"time"
)

func main() {
	a := make(chan string)
	b := make(chan string)

	go func() { time.Sleep(50 * time.Millisecond); a <- "from a" }()
	go func() { time.Sleep(20 * time.Millisecond); b <- "from b" }()

	for i := 0; i < 2; i++ {
		select {
		case msg := <-a:
			fmt.Println(msg)
		case msg := <-b:
			fmt.Println(msg)
		case <-time.After(100 * time.Millisecond):
			fmt.Println("timeout")
		}
	}
}

A default case turns select into a non-blocking poll. select is the building block for cancellation, timeouts, and fan-in patterns.

Worker pool pattern

A common, practical pattern: a fixed number of worker goroutines pull jobs from a shared channel and push results to another channel. This bounds parallelism and keeps memory predictable.

package main

import (
	"fmt"
	"sync"
)

type Job struct{ ID, Input int }
type Result struct{ JobID, Output int }

func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		results <- Result{JobID: j.ID, Output: j.Input * j.Input}
		_ = id
	}
}

func main() {
	jobs := make(chan Job, 10)
	results := make(chan Result, 10)

	var wg sync.WaitGroup
	for w := 1; w <= 3; w++ {
		wg.Add(1)
		go worker(w, jobs, results, &wg)
	}

	for i := 1; i <= 5; i++ {
		jobs <- Job{ID: i, Input: i}
	}
	close(jobs)

	go func() {
		wg.Wait()
		close(results)
	}()

	for r := range results {
		fmt.Printf("job %d -> %d\n", r.JobID, r.Output)
	}
}

Note the structure: the producer closes jobs, workers exit when range ends, and a separate goroutine closes results after all workers finish. This makes the for r := range results loop terminate cleanly.

Patterns to remember

  • Use unbuffered channels by default; reach for a buffer only when you can justify the size.
  • Close channels from the sender side, never the receiver.
  • Combine select with context.Context for cancellation in real programs.
  • Avoid sharing slices or maps across goroutines without synchronization; see Go slices and maps.
  • For numeric loops driven by Go control flow, prefer channels over shared counters guarded by mutexes when communicating across goroutines.

Wrap up

Goroutines give you cheap concurrency; channels give you safe communication. Start with simple producer-consumer pipelines, learn select for coordination, and use bounded worker pools to keep load predictable. Once these basics feel natural, patterns like fan-in, fan-out, and pipelines fall out almost for free, and you will have a toolkit that scales from a small CLI to a high-throughput server.