# 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.

- Published: 2019-11-03
- Updated: 2026-09-07
- Category: Languages
- Tags: Go
- Reading time: 4 min read
- Source: https://www.muhammetsafak.com.tr/en/blog/concurrency-in-go-goroutines-and-channels-in-practice/
- Language: en-US
- Author: Muhammet Şafak

---
This was the topic I had been looking forward to [ever since I started learning Go](/en/blog/first-step-toward-polyglot-why-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](https://go.dev/doc/effective_go#goroutines), 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:

```go
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`:

```go
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**.

```go
// 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()`](https://pkg.go.dev/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](https://go.dev/doc/effective_go#sharing): "Do not communicate by sharing memory; instead, share memory by communicating."

```go
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](https://go.dev/ref/spec#Channel_types), 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:

```go
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:

```go
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](https://go.dev/ref/spec#Select_statements): 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`](https://pkg.go.dev/runtime#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"](https://go.dev/blog/waza-talk): concurrency is the *composition* of independently executing processes, parallelism the simultaneous *execution* of computations.

---

### Sources

- [Effective Go: Goroutines](https://go.dev/doc/effective_go#goroutines)
- [Effective Go: Share by communicating](https://go.dev/doc/effective_go#sharing)
- [The Go Programming Language Specification: Channel types](https://go.dev/ref/spec#Channel_types)
- [The Go Programming Language Specification: Select statements](https://go.dev/ref/spec#Select_statements)
- [Go Packages: runtime — GOMAXPROCS](https://pkg.go.dev/runtime#GOMAXPROCS)
- [Go Packages: runtime — NumGoroutine](https://pkg.go.dev/runtime#NumGoroutine)
- [Rob Pike: Concurrency is not parallelism](https://go.dev/blog/waza-talk)
