Put go in front of a function call and it runs concurrently with the rest of your program, scheduled by the Go runtime instead of the OS. Starting one is the easy part. The hard part is getting two goroutines to hand off a result without stepping on each other’s state, and that’s the job channels do.
What is a goroutine?
A goroutine is a function running independently of the one that started it, managed by Go’s runtime rather than the operating system. An OS thread reserves a megabyte or more of stack and costs the kernel real time to schedule. A goroutine starts with a stack of about 2KB that grows as needed, and the runtime multiplexes many goroutines onto a handful of OS threads. Starting thousands of them is normal.
| |
Run this a few times and the order of “hi, alice” and “hi, bob” changes. The scheduler decides. The time.Sleep only keeps main alive long enough for the goroutines to print: delete it and the program can exit before either one runs, because main returning ends the process no matter what else is still in flight. The sleep is a hack, not synchronization. A channel is what replaces it.
How do channels connect goroutines?
A channel is a typed pipe: one goroutine sends a value in, another receives it out, and the runtime handles the handoff. Picture a single-lane conveyor belt between two workers. The sender puts one item on it and can’t put a second until the first is taken off. That’s an unbuffered channel, and that blocking handoff is the whole synchronization mechanism.
| |
make(chan string) creates an unbuffered channel of strings. The send (ch <- "payload ready") blocks until something receives, and the receive (<-ch) blocks until something sends. Neither side moves until both are ready, which is the handoff the time.Sleep above was faking.
Give the channel a capacity and the belt holds more than one item before it jams:
| |
A buffered channel’s send blocks only when the buffer is full; its receive blocks only when the buffer is empty. That loosens the coupling between sender and receiver, which helps absorb bursts. It doesn’t remove the need for coordination. It only moves the point where blocking starts.
How do you close a channel and range over it?
The sender closes a channel to say “nothing more is coming”:
| |
for v := range ch keeps receiving until the channel is closed and drained, then exits on its own. No sentinel value, no manual count. To tell a closed, empty channel apart from one that delivered a zero value, use the two-value form: v, ok := <-ch sets ok to false only when the channel is closed and has nothing left.
close has three ways to panic, and the compiler catches none of them:
- Sending on a closed channel panics.
- Closing an already-closed channel panics.
- Closing a
nilchannel panics.
So the sender closes, never the receiver. A receiver has no way to know whether another goroutine still intends to send.
How do you wait for multiple goroutines to finish?
range works when a channel signals the end of the work. When you’ve started several goroutines and only need to know they’ve all finished, use sync.WaitGroup:
| |
Add(1) increments the counter before each goroutine starts, Done() decrements it (deferred, so it runs even if the function panics), and Wait() blocks until the counter hits zero. Move Add inside the goroutine and you’ve written a race: main can reach Wait(), see a counter of zero, and return before any worker has incremented it.
How does select work with multiple channels?
select waits on several channel operations at once and runs whichever is ready first. Timeouts and cancellation in Go are built on it.
| |
If more than one case is ready, select picks one at random, not in the order written, so case order gives you no priority. With no default and no case ready, select blocks; an empty select{} blocks forever. Add a default case and the whole statement becomes non-blocking: if nothing is ready right now, default runs instead.
A worker pool combines most of this. A fixed number of goroutines pull jobs off one channel and push results onto another, so concurrent work stays capped however many jobs arrive.
| |
Three workers, nine jobs. close(jobs) after the last send lets each worker’s range loop exit once the queue drains. The signatures use jobs <-chan int (receive-only) and results chan<- int (send-only). The arrow shows direction and the compiler enforces it, so a worker that accidentally sends on jobs fails to build instead of misbehaving at runtime.
Why do goroutines leak or deadlock?
Goroutine leaks. A goroutine blocked forever on a send or receive that will never happen doesn’t crash. It sits there, holding its stack and everything it closed over, for the life of the program. The usual cause is a goroutine sending a result to a channel nobody reads anymore, because the receiver already gave up after a timeout. Give the goroutine a way out, usually a context.Context it also selects on, so it can abandon the send.
Deadlock. fatal error: all goroutines are asleep - deadlock! means every goroutine, main included, is blocked on a channel or lock with nothing left to unblock it. The smallest example is an unbuffered send in main with no other goroutine running to receive it.
Races that channels don’t prevent. A channel synchronizes the handoff of a value. It does not protect a variable you keep touching outside the channel. Two goroutines incrementing the same captured counter still race, channel or no channel, exactly like the threading case in what a race condition is and how to fix one. Go ships a detector for this: build or test with -race and the runtime reports concurrent accesses to the same memory where at least one is a write.
| |
It only catches races that actually happen during the run, so it finds more under realistic concurrent load than in a quiet unit test. That load is also where goroutine-heavy tests go flaky, the same kind of unreliability covered in unit vs integration vs end-to-end tests. Expect several times the memory and CPU with -race on. Run it in CI, not in the binary you ship.
Should you use a channel or a mutex?
Both make concurrent access safe, from opposite ends. A sync.Mutex guards shared state in place: lock, read or write, unlock, and the data never moves. A channel moves ownership of a value from one goroutine to another, so only one goroutine touches it at a time by construction. Go’s own guidance: use channels to coordinate or hand off work between goroutines, and a mutex to protect one piece of shared data that several goroutines update in place, like a cache or a counter. If one function needs both, it’s probably doing two jobs that would read better split apart.
If you came from JavaScript, the model is different. One thread runs your code and await only yields at points you can see in the source; JavaScript async/await and the event loop shows where those suspension points are. Go gives you real parallelism, so the choice of primitive is on you.
For your next batch job (resizing a folder of images, or calling an API once per row of a CSV), start from the worker pool above. Pick the worker count, close the jobs channel when you’re done sending, and add a context.Context to the select the first time you need to cancel.