Concurrency in Go: goroutines and channels in practice
A hands-on look at Go's concurrency model — goroutines and channels — and what changes when you come from PHP's process model.
This was the topic I had been looking forward to ever since I started learning Go: concurrency. In PHP, running things in parallel means either spawning multiple processes or wiring up a queue system. I wanted to see how Go handles this problem at the language level.
I have been working with goroutines and channels for a few weeks now. Closing the gap between theoretical understanding and practical use takes time, but my mental model is starting to settle.
What is a goroutine?
A goroutine is Go’s lightweight unit of concurrent execution. It is not an OS thread — as Effective Go puts it, goroutines are multiplexed onto multiple OS threads, so if one blocks — while waiting for I/O, say — the others keep running. Launching one requires only the go keyword:
package main
import (
"fmt"
"time"
)
func selamlama(isim string) {
fmt.Printf("Merhaba, %s!\n", isim)
}
func main() {
go selamlama("Ali")
go selamlama("Ayşe")
go selamlama("Mehmet")
// Ana goroutine bitmeden diğerleri çalışsın:
time.Sleep(100 * time.Millisecond)
}
This works, but waiting with time.Sleep is bad practice. The right way to wait for goroutines to finish is sync.WaitGroup:
package main
import (
"fmt"
"sync"
)
func isGor(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("İş %d tamamlandı\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go isGor(i, &wg)
}
wg.Wait()
fmt.Println("Tüm işler bitti.")
}
defer wg.Done() decrements the WaitGroup counter when the goroutine finishes. wg.Wait() blocks until the counter reaches zero.
Goroutine leaks — a common mistake
Coming from PHP, it is easy to assume goroutines “close automatically.” They do not. If a goroutine is blocked waiting to read from or write to a channel that will never be ready, it keeps running forever — even if you are not aware of it. This is called a goroutine leak.
// Tehlikeli: goroutine asla bitmez
go func() {
result := <-ch // ch hiç kapatılmazsa burada takılır
fmt.Println(result)
}()
The practical way to prevent this is to use context.Context or to close the channel properly. I fell into this trap several times in the first few weeks; monitoring the goroutine count with runtime.NumGoroutine() — it returns the number of goroutines that currently exist — was what helped me spot the problem.
Channels: communication between goroutines
A channel is a Go-specific construct for passing data between goroutines. The philosophy here is “share memory by communicating” rather than “communicate by sharing memory.” Effective Go compresses it into a single line: “Do not communicate by sharing memory; instead, share memory by communicating.”
package main
import "fmt"
func topla(sayilar []int, sonuc chan<- int) {
toplam := 0
for _, n := range sayilar {
toplam += n
}
sonuc <- toplam // Channel'a gönder
}
func main() {
sayilar := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
sonuc := make(chan int)
// İki goroutine, listeyi ikiye bölerek topluyor
go topla(sayilar[:5], sonuc)
go topla(sayilar[5:], sonuc)
// İki sonucu al
a, b := <-sonuc, <-sonuc
fmt.Println("Toplam:", a+b) // 55
}
Channels are unbuffered by default. In the words of the language specification, if the capacity is zero or absent the channel is unbuffered and communication succeeds only when both a sender and a receiver are ready. This guarantees synchronization.
A buffered channel can hold a fixed number of values without a receiver:
ch := make(chan int, 3) // 3 değer tutabilir
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // Bu satır bloklardı — kanal dolu
A buffered channel behaves like a queue. It is useful in producer-consumer scenarios, but choosing the wrong buffer size can cause hidden bottlenecks. My usual approach is to start with an unbuffered channel and only add a buffer if I see a real performance problem.
select: listening on multiple channels
The select statement lets you listen on multiple channels simultaneously. It reads from whichever one is ready first:
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "bir"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "iki"
}()
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println("ch1'den:", msg)
case msg := <-ch2:
fmt.Println("ch2'den:", msg)
}
}
}
You can add a default case to a select block. The specification states it directly: if no communication can proceed and a default case is present, that case is chosen; with no default case, the select blocks until at least one of the communications can proceed. This is handy for implementing a “read if available, otherwise skip” pattern.
Comparing with PHP
Running things in parallel in PHP requires pcntl_fork(), extensions like Swoole, or an external queue system. None of these are at the language level. In Go, goroutines and channels are built into the language itself — nothing beyond the standard library is needed.
This difference is most noticeable here: writing concurrent code in Go feels unfamiliar at first but is entirely ordinary. In PHP, concurrency feels like something reserved for special situations. When a browser calls a PHP file, each request runs in its own isolated world and never touches another request’s state; that isolation is both a convenience and a constraint. In Go, multiple goroutines can access the same memory — that is power, but it demands care.
I am still in the middle of learning all this. Race conditions, goroutine leaks, properly closing channels — these all require attention. But the mental model is becoming clearer: goroutines are cheap, communication goes through channels, and shared state should be minimized. When I keep those three principles in mind, where the code needs to go usually becomes obvious on its own.
There is also the important distinction between concurrency and parallelism. Goroutines in Go are concurrent; whether they actually run in physical parallel depends on the number of CPU cores and the GOMAXPROCS setting — the value that sets how many CPUs may be executing simultaneously. Coming from PHP, this difference is not obvious — in PHP, “doing multiple things at once” essentially means forking processes. In Go, concurrency is a design decision baked into the language; scheduling work efficiently and actually executing it in parallel are separate concerns. The canonical statement of the distinction is Rob Pike’s talk “Concurrency is not parallelism”: concurrency is the composition of independently executing processes, parallelism the simultaneous execution of computations.
Sources
Questions on this topic
Should I run several dependent operations with errgroup so the first error cancels the rest?
Move to `errgroup.WithContext` with a request-level timeout on top and thread ctx into every HTTP and DB call; an ignored ctx means no cancellation at all.
How do I propagate context cancellation correctly through nested service calls?
Cancellation propagates only if you pass `r.Context()` into every downstream call, leave no `context.Background()` in the chain, and use `WithoutCancel`.
How do I detect and prevent goroutine leaks in a long-running Go service?
Export NumGoroutine() as a metric, take a pprof goroutine dump at peak, make the spawn site behind the parked stacks honor ctx.Done(), then add goleak.
How do I cut GC pressure in Go with sync.Pool and escape analysis under load?
The enemy behind the pauses is allocation rate: kill the top allocator with pprof, pool the hot path with sync.Pool, and tune GOGC last, not first.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.