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.
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
selectwithcontext.Contextfor 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.