Goroutines

[by: redacted]

Use WaitGroups when you just need to wait for multiple goroutines to finish without passing data between them.

Use channels when you need to pass data or coordinate communication between goroutines safely.


A goroutine is a lightweight thread managed by the Go runtime. It works as a function or task that runs independently and can execute concurrently with other goroutines. Unlike traditional threads, goroutines are more efficient and easier to manage dur to low memory overhead.

When you start a function as a goroutine, it runs concurrently with other goroutines, including the main program.

package main

import (
	"fmt"
	"time"
)

func printMessage(msg string) {
	for i := 0; i < 5; i++ {
		fmt.Println(msg)
		time.Sleep(500 * time.Millisecond)
	}
}

func main() {
	go printMessage("goroutine") // runs concurrently
	printMessage("main") // run synchronously
}

Here, in this code

  • printMessage runs as a goroutine
  • main() continues executing immediately after starting the goroutine
  • Both main function and goroutine run concurrently.

Goroutine starts with only 2KB of stack memory, and the stack can grow as needed. This makes them much lighter than system threads, which typically consumes more memory.

Goroutines are non-blocking; when it’s created, the code continues executing without waiting for the goroutine to finish. However, if the main function exits before a goroutine completes, the goroutine will be terminated, as Go doesn’t keep the program alive for orphaned goroutines.

But here if we remove printMessage("main") then the goroutine won’t work at all… and that is where WaitGroups come in.


WaitGroups

A WaitGroup allows you to wait for multiple goroutines to finish before the main function or another piece of code continues execution. It’s ideal when you don’t need to exchange data between goroutines but only need synchronization.

Using sync.WaitGroup to wait for goroutines

package main

import (
	"fmt"
	"sync"
)

func printMessage(msg string, wg *sync.WaitGroup) {
	defer wg.Done() // notify that the goroutine is done
	fmt.Println(msg)
}

func main() {
	var wg sync.WaitGroup

	wg.Add(1) // tell the waitgroup that 1 goroutine will run
	go printMessage("goroutine", &wg)

	wg.Wait() // wait for all goroutines to finish
	fmt.Println("All goroutines finished")
}
  • sync.WaitGroup ensures that the main program waits for the goroutine to complete.
  • wg.Add(1) registers the goroutine, and wg.Done() signals that the goroutine is complete.
  • wg.Wait() blocks the main function until all registered goroutines finish.

Use case of WaitGroups

Downloading Multiple Files at a Time

When you need to ensure that all concurrent downloads or tasks are completed before moving to the next step.

package main

import (
	"fmt"
	"sync"
	"time"
)

func downloadFile(fileID int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Downloading file %d...\n", fileID)
	time.Sleep(2 * time.Second) // simulating file download
	fmt.Printf("File %d downloaded.\n", fileID)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go downloadFile(i, &wg)
	}

	wg.Wait() // wait for all downloads to complete
	fmt.Println("All files downloaded.")
}

Parallel API Calls / Data Fetching

Fetch data from multiple APIs or services concurrently, and wait for all the responses before aggregating the data.

Example: Querying multiple microservices and waiting for their responses before continuing.

Batch Processing with Worker Pools

Distribute tasks (like processing multiple images) across multiple workers running as goroutines and wait for all of them to complete.

Example:

  • Processing multiple image transformations in parallel.
  • Executing database queries across shards.

Channels

Goroutines run independently, so they don’t share memory by default. Channels allow you to safely communicate between goroutines. They allow you to send and receive values between goroutines without explicit locking mechanisms (like mutexes)

Using channels to send a message from goroutine

package main

import "fmt"

func sendMessage(ch chan string) {
	ch <- "goroutine" // send message to channel
}

func main() {
	ch := make(chan string) // create channel

	go sendMessage(ch) // start goroutine

	msg := <- ch // receive message from channel
	fmt.Println(msg)
}
  • Channel ch is used to pass the message between the goroutine and the main function
  • Channels ensure safe communication between concurrent processes.

Use case of Channels

Sending Results Between Goroutines

One goroutine produces data, and another consumes it using a channel

Example: Summing a list of numbers using multiple workers.

package main

import "fmt"

func sum(numbers []int, ch chan int) {
	total := 0
	for _, num := range numbers {
		total += num
	}
	ch <- total // send result to channel
}

func main() {
	numbers := []int{1, 2, 3, 4, 5, 6}
	ch := make(chan int)

	go sum(numbers[:len(numbers)/2], ch)
	go sum(numbers[len(numbers)/2:], ch)

	sum1, sum2 := <-ch, <-ch // receive results from both goroutines
	fmt.Println("Total sum:", sum1+sum2)
}
  • Useful for dividing data processing across multiple goroutines and aggregating the results.

Goroutine Coordination with select

We can use select to listen on multiple channels at the same time, making decision based on which channel receives data first.

Example: Implementing a timeout or monitoring multiple sources of input.

package main

import (
    "fmt"
    "time"
)

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

    go func() {
        time.Sleep(2 * time.Second)
        ch <- "goroutine finished"
    }()

    select {
    case msg := <-ch:
        fmt.Println(msg)
    case <-time.After(1 * time.Second):
        fmt.Println("timeout occurred")
    }
}
  • Time-sensitive tasks where you want to take action if a goroutine does not respond within a certain time.

Buffer Channels for Task Queues

A buffered channel allows sending multiple values without blocking, acting like a queue.

package main

import "fmt"

func main() {
	ch := make(chan int, 3) // buffered channel with capacity of 3

	ch <- 1
	ch <- 2
	ch <- 3

	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}
  • Task queues where producers can queue tasks even if consumers are temporarily unavailable.

Comparison of WaitGroup vs Channels

AspectWaitGroupChannel
PurposeSynchronization of goroutinesCommunication between goroutines
When to UseWhen no data exchange is neededWhen data needs to be passed
BlockingWait() blocks until tasks finishChannel sends/receives block if unbuffered
Best Use CaseWaiting for multiple tasksProducer-consumer, pipelines