f in x
Goroutines and Channels in Go: Native Concurrency Without External Libraries
> cd .. / HUB_EDITORIALE
Sviluppo di siti web

Goroutines and Channels in Go: Native Concurrency Without External Libraries

[2026-06-10] Author: Ing. Calogero Bono

Is your Go backend handling requests sequentially? You're using only 20% of the engine. Go's native concurrency — goroutines and channels — is its most underestimated superpower. No external libraries, no message queues required. Just clean communication between concurrent pieces.

We, at Meteora Web, chose Go for several backend projects precisely for this reason: writing clean concurrent code without a messaging framework. In this guide we'll dive into Go's CSP (Communicating Sequential Processes) model: goroutines as lightweight actors, channels as pipelines. Ready to stop using Go like it's PHP?

Goroutines: Cheap, Lightweight, and Easy to Launch

A goroutine is a lightweight thread managed by the Go runtime. It starts with about 4KB of stack (vs. MB for an OS thread). You can launch hundreds of thousands without breaking a sweat.

package main

import (
	"fmt"
	"time"
)

func printMsg(msg string) {
	for i := 0; i < 3; i++ {
		fmt.Println(msg, i)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go printMsg("goroutine") // launched in background
	printMsg("main")         // runs on the main goroutine
	time.Sleep(1 * time.Second)
}

Problem: the main function doesn't wait. When it ends, all goroutines die. You need WaitGroups.

Sponsored Protocol

sync.WaitGroup: Wait for Them to Finish

var wg sync.WaitGroup

func work(id int) {
	defer wg.Done()
	fmt.Printf("Work %d started\n", id)
	time.Sleep(200 * time.Millisecond)
	fmt.Printf("Work %d finished\n", id)
}

func main() {
	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go work(i)
	}
	wg.Wait()
	fmt.Println("All done")
}

Common mistake: calling wg.Add inside the goroutine instead of before it. Always do it before the go statement. We see this often in code reviews: guaranteed deadlock.

Channels: Pipes That Carry Data Between Goroutines

A channel is a typed pipe (chan Type) that blocks on read until data arrives, and blocks on write until the buffer is free (buffered) or a reader is ready (unbuffered).

ch := make(chan string)    // unbuffered
chBuff := make(chan int, 5) // buffered, capacity 5

Unbuffered: Pure Synchronous

A writer sends → blocks until a reader receives. Perfect for notifications and synchronization.

func main() {
	ch := make(chan string)

	go func() {
		ch <- "Hello from goroutine"
	}()

	msg := <-ch
	fmt.Println(msg)
}

If you write without ever reading — deadlock. Go runtime catches it and panics.

Sponsored Protocol

Buffered: Internal Queue

You can send until the buffer is full, then it blocks. Useful for bursty loads.

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // blocking: buffer full
fmt.Println(<-ch) // 1

When to use? When the producer is faster than the consumer for short bursts. But beware: a large buffer can hide backpressure issues.

Closing a Channel

The sender closes with close(ch). The receiver can iterate with for v := range ch or check with the comma form v, ok := <-ch.

func producer(ch chan<- int) {
	for i := 0; i < 5; i++ {
		ch <- i
	}
	close(ch)
}

func main() {
	ch := make(chan int)
	go producer(ch)
	for v := range ch {
		fmt.Println(v)
	}
}

Golden rule: only the sender closes. Never the receiver. And don't write to a closed channel — panic.

Select: The Channel Multiplexer

Used to wait on multiple channel operations simultaneously. Classic use case: timeouts or priority.

select {
case msg1 := <-ch1:
	fmt.Println("From ch1:", msg1)
case msg2 := <-ch2:
	fmt.Println("From ch2:", msg2)
case <-time.After(1 * time.Second):
	fmt.Println("Timeout!")
}

If multiple cases are ready, Go picks one randomly (fairness). You can add a default case to never block.

Sponsored Protocol

Real-World Concurrency Patterns

Worker Pool

Launch N goroutines that pull work from a channel. The main function produces jobs and closes the channel when done.

func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		results <- j * 2 // simulate work
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	// launch 3 workers
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	// send 5 jobs
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)

	// collect results
	for r := 1; r <= 5; r++ {
		fmt.Println(<-results)
	}
}

Ideal for batch processing, scraping, file processing.

Fan-In / Fan-Out

  • Fan-Out: one producer distributes work to multiple goroutines (e.g., worker pool).
  • Fan-In: multiple goroutines send into the same channel.
func fanIn(ch1, ch2 <-chan string) <-chan string {
	c := make(chan string)
	go func() {
		for {
			select {
			case v := <-ch1:
				c <- v
			case v := <-ch2:
				c <- v
			}
		}
	}()
	return c
}

Perfect for aggregating results from multiple sources (APIs, databases, files).

Sponsored Protocol

Pipeline with Channels

Each step is a goroutine that receives from one channel and sends to another. Clean chaining.

func gen(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}
		close(out)
	}()
	return out
}

func sq(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * n
		}
		close(out)
	}()
	return out
}

func main() {
	c := gen(2, 3, 4)
	out := sq(c)
	for n := range out {
		fmt.Println(n) // 4, 9, 16
	}
}

Handling Cancellation and Timeouts with context

You cannot kill a goroutine from outside. You must cooperate: pass a context.Context and check ctx.Done().

func work(ctx context.Context, id int, out chan<- string) {
	for {
		select {
		case <-ctx.Done():
			out <- fmt.Sprintf("worker %d: stopping due to %v", id, ctx.Err())
			return
		default:
			// do work...
		}
	}
}

Use context.WithTimeout for time limits, context.WithCancel for explicit cancellation.

Production Pitfalls We See

  • Goroutine leak: a goroutine stuck waiting on a channel that is never closed. Solution: always add a timeout or ctx.Done().
  • Race condition: accessing shared variables without a mutex. Run tests with -race.
  • Deadlock on unbuffered channel: single producer, but consumer not ready yet — deadlock.
  • Copying WaitGroup: always pass a pointer (*sync.WaitGroup), never the value.

In summary — what to do now

  1. Write a goroutine today. Take a loop in your code and turn it into goroutines with a WaitGroup.
  2. Replace shared variables with channels. If two goroutines exchange data, use a channel instead of a shared struct + mutex.
  3. Add a timeout to every channel read that could block forever. select + time.After.
  4. Test with go test -race. The race detector catches unsynchronized accesses. Make it part of your CI.
  5. Read the net/http source code. See how Go uses goroutines for every connection. It's the best tutorial.

Concurrency in Go is a language within the language. Once you master goroutines and channels, you stop thinking about threads and locks. You start thinking in pipelines and actors. That's when your backend scales without paying AWS four times as much.

Ing. Calogero Bono

> AUTHOR_EXTRACTED

Ing. Calogero Bono

Ingegnere Informatico, co-fondatore di Meteora Web. Esperto in architetture software, sicurezza informatica e sviluppo sistemi scalabili.
[ Read Full Dossier ]

> METEORA_WEB // DIGITAL AGENCY

We build the digital presence your business deserves.

Websites, social media, online advertising, e-commerce and high-performance hosting, engineered with method by computer engineers in Sciacca, for all of Italy.

> MW_JOURNAL

> READ_ALL()