Skip to content
Muhammet Şafak
tr
Languages 5 min read

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 — the Go runtime multiplexes thousands of goroutines onto a small number of threads. 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() 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.”

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: the sender blocks until the receiver reads. 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; if no channel is ready, it falls through to default instead of blocking. 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. 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.

Tags: #Go
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind