Understanding Goroutines and Channels in Golang with Intuitive Visuals
December 20, 2024

Understanding Goroutines and Channels in Golang with Intuitive Visuals

⚠️How does this series go?

1. Run each example: Don’t just look at the code. Enter it, run it, and watch its behavior.
2. Experiment and break things: Remove sleep and see what happens, change channel buffer size, modify goroutine count.
Breaking things teaches you how they work
3. Reasons for behavior: Before running the modified code, try to predict the results. When you see unexpected behavior, stop and think about why. Challenge interpretation.
4. Establish a mental model: Each visualization represents a concept. Try drawing your own diagram of the modified code.

This is Part 1 our “Mastering Go Concurrency” Series we will cover:

  • How goroutine works and its life cycle
  • Channel communication between Goroutines
  • Buffered channels and their use cases
  • Practical examples and visualizations

We’ll start with the basics and gradually develop an intuition for how to use them effectively.

This is going to be a little long, quite a long one, so be prepared.

We’ll be hands-on with the entire process.


The basics of Goroutines

Let’s start with a simple program that downloads multiple files.

package main

import (
    "fmt"
    "time"
)

func downloadFile(filename string) {
    fmt.Printf("Starting download: %s\n", filename)
    // Simulate file download with sleep
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)
}

func main() {
    fmt.Println("Starting downloads...")

    startTime := time.Now()

    downloadFile("file1.txt")
    downloadFile("file2.txt")
    downloadFile("file3.txt")

    elapsedTime := time.Since(startTime)

    fmt.Printf("All downloads completed! Time elapsed: %s\n", elapsedTime)
}
Enter full screen mode

Exit full screen mode

The program takes a total of 6 seconds because each 2-second download must complete before the next download can begin. Let’s imagine this:

We can lower this time, let’s modify our program to use follow a routine:

Notice: go keyword before function call

package main

import (
    "fmt"
    "time"
)

func downloadFile(filename string) {
    fmt.Printf("Starting download: %s\n", filename)
    // Simulate file download with sleep
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)
}

func main() {
    fmt.Println("Starting downloads...")

    // Launch downloads concurrently
    go downloadFile("file1.txt")
    go downloadFile("file2.txt")
    go downloadFile("file3.txt")

    fmt.Println("All downloads completed!")
}
Enter full screen mode

Exit full screen mode

Wait what? Nothing printed? Why?

Let’s imagine this to understand what might happen.

From the above visualization, we understand that the main function exists before the goroutine completes. One observation is that the life cycle of all goroutines depends on the main function.

notes: main The function itself is a goroutine 😉

To solve this problem, we need a way to make the main goroutine wait for other goroutines to complete. There are several ways to do this:

  1. Wait a few seconds (hacky way)
  2. use WaitGroup (Correct method, next step)
  3. use channel (We’ll cover this below)

let us wait a few seconds so that the go routine completes.

package main

import (
    "fmt"
    "time"
)

func downloadFile(filename string) {
    fmt.Printf("Starting download: %s\n", filename)
    // Simulate file download with sleep
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)
}

func main() {
    fmt.Println("Starting downloads...")

    startTime := time.Now() // Record start time

    go downloadFile("file1.txt")
    go downloadFile("file2.txt")
    go downloadFile("file3.txt")

    // Wait for goroutines to finish
    time.Sleep(3 * time.Second)

    elapsedTime := time.Since(startTime)

    fmt.Printf("All downloads completed! Time elapsed: %s\n", elapsedTime)
}
Enter full screen mode

Exit full screen mode

The problem is, we may not know how long it will take coroutine May be required. In some cases, we each have a constant time, but in real scenarios, we know that the download time will vary.


Coming sync.WaitGroup

one sync.WaitGroup The concurrency control mechanism in Go is used to wait for a collection of goroutines to complete execution.

Let’s see it in action and visualize it:

package main

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

func downloadFile(filename string, wg *sync.WaitGroup) {
    // Tell WaitGroup we're done when this function exits
    defer wg.Done()

    fmt.Printf("Starting download: %s\n", filename)
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)
}

func main() {
    fmt.Println("Starting downloads...")

    var wg sync.WaitGroup

    // Tell WaitGroup we're about to launch 3 goroutines
    wg.Add(3)

    go downloadFile("file1.txt", &wg)
    go downloadFile("file2.txt", &wg)
    go downloadFile("file3.txt", &wg)

    // Wait for all downloads to complete
    wg.Wait()

    fmt.Println("All downloads completed!")
}
Enter full screen mode

Exit full screen mode

Let’s imagine this and understand how it works sync.WaitGroup:

Counter mechanism:

  • WaitGroup Maintain an internal counter
  • wg.Add(n) Counter increments n
  • wg.Done() Decrement the counter 1
  • wg.Wait() Block until the counter is reached 0

Synchronization process:

  • Main coroutine call Add(3) Before starting the goroutine
  • Every goroutine will call Done() when it’s done
  • The main Goroutine is blocked on Wait() until the counter hits 0
  • When the counter reaches 0the program continues and exits cleanly
Common pitfalls to avoid

// DON'T do this - Add after launching goroutine
go downloadFile("file1.txt", &wg)
wg.Add(1)  // Wrong order!

// DON'T do this - Wrong count
wg.Add(2)  // Wrong number!
go downloadFile("file1.txt", &wg)
go downloadFile("file2.txt", &wg)
go downloadFile("file3.txt", &wg)

// DON'T do this - Forgetting Done()
func downloadFile(filename string, wg *sync.WaitGroup) {
    // Missing Done() - WaitGroup will never reach zero!
    fmt.Printf("Downloading: %s\n", filename)
}
Enter full screen mode

Exit full screen mode


channel

In this way we have a good understanding of how goroutines work. No, how do two Go routines communicate? This is where channels come into play.

channel Concurrency primitives in Go are used for communication between goroutines. They provide a way for goroutines to safely share data.

Treat channels as pipes: One goroutine can send data to the channel, and another goroutine can receive the data.

Here are some properties:

  1. Channels are blocking by nature.
  2. one Send to channel Operation ch <- value block Until other goroutines receive messages from the channel.
  3. one receive from channel Operation <-ch block Until other goroutines are sent to the channel.
package main

import "fmt"

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

    // Send value to channel (this will block main)
    ch <- "hello"  // This line will cause deadlock!

    // Receive value from channel
    msg := <-ch
    fmt.Println(msg)
}
Enter full screen mode

Exit full screen mode

why ch <- "hello" Causing a deadlock? Since the channel is blocking by nature and we are passing "hello" It blocks the main goroutine until there is a receiver, and since there is no receiver it gets stuck.

Let’s solve this problem by adding goroutine

package main

import "fmt"

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

    // sender in separate goroutine
    go func() {
        ch <- "hello"  // will no longer block the main goroutine
    }()

    // Receive in main goroutine
    msg := <-ch  // This blocks until message is received
    fmt.Println(msg)
}
Enter full screen mode

Exit full screen mode

Let’s imagine this:

This time the news is Sent from a different goroutine so main won’t block When sent to a channel, it moves to msg := <-ch It blocks the main goroutine until a message is received.


Use channels to fix main without waiting for other issues

Now let’s use channels to fix the archive downloader issue (main doesn’t wait for others to finish).

package main

import (
    "fmt"
    "time"
)

func downloadFile(filename string, done chan bool) {
    fmt.Printf("Starting download: %s\n", filename)
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)

    done <- true // Signal completion
}

func main() {
    fmt.Println("Starting downloads...")

    startTime := time.Now() // Record start time

    // Create a channel to track goroutine completion
    done := make(chan bool)

    go downloadFile("file1.txt", done)
    go downloadFile("file2.txt", done)
    go downloadFile("file3.txt", done)

    // Wait for all goroutines to signal completion
    for i := 0; i < 3; i++ {
        <-done // Receive a signal for each completed goroutine
    }

    elapsedTime := time.Since(startTime)
    fmt.Printf("All downloads completed! Time elapsed: %s\n", elapsedTime)
}
Enter full screen mode

Exit full screen mode

Visualize it:

Let’s do a walkthrough to understand better:

Program starts:

The main Goroutine establishes the completion channel
Start three download goroutines
Each goroutine will get a reference to the same channel

Download execution:

  1. All three downloads run simultaneously
  2. Each takes 2 seconds
  3. They may be completed in any order

channel loop:

  1. The main coroutine enters the loop: for i := 0; i < 3; i++
  2. each <-done Blocks until a value is received
  3. This loop ensures that we wait for all three completion signals

cyclical behavior:

  1. Iteration 1:Block until first download completes
  2. Iteration 2: Block until the second download is complete
  3. Iteration 3: Block until the final download is complete

The order of completion doesn’t matter!

Observations:
⭐ There is only one reception (<-done) per send (done <- true)
⭐ The main coroutine coordinates everything through loops


How do two goroutines communicate?

We have seen how two goroutines communicate. when? All the time. Let’s not forget that the main function is also a goroutine.

package main

import (
    "fmt"
    "time"
)

func sender(ch chan string, done chan bool) {
    for i := 1; i <= 3; i++ {
        ch <- fmt.Sprintf("message %d", i)
        time.Sleep(100 * time.Millisecond)
    }
    close(ch) // Close the channel when done sending
    done <- true
}

func receiver(ch chan string, done chan bool) {
        // runs until the channel is closed
    for msg := range ch {
        fmt.Println("Received:", msg)
    }
    done <- true
}

func main() {
    ch := make(chan string)
    senderDone := make(chan bool)
    receiverDone := make(chan bool)

    go sender(ch, senderDone)
    go receiver(ch, receiverDone)

    // Wait for both sender and receiver to complete
    <-senderDone
    <-receiverDone

    fmt.Println("All operations completed!")
}
Enter full screen mode

Exit full screen mode

Let’s imagine this and test run it:

Trial run:



Program starts (t=0ms)

  • The main goroutine initializes three channels:
    • ch: Used for messages.
    • senderDone:Sends a completion signal to the sender.
    • receiverDone: Indicates receiver completion.
  • The main goroutine starts two goroutines:
  • Main goroutine block, waiting for signals from <-senderDone.



The first message (t=1ms)

  1. this sender transmit "message 1" arrive ch channel.
  2. this receiver Wake up and process messages:
    • print: “Received: Message 1”.
  3. this sender Sleep for 100 milliseconds.



Second message (t=101ms)

  1. this sender wake up and send "message 2" arrive ch channel.
  2. this receiver Process the message:
    • print: “Received: Message 2”.
  3. this sender Sleep for another 100 milliseconds.



The third message (t=201ms)

  1. this sender wake up and send "message 3" arrive ch channel.
  2. this receiver Process the message:
    • print: “Received: Message 3”.
  3. this sender Sleep for the last time.



Channel closed (t=301ms)

  1. this sender Finish sleeping and shut down ch channel.
  2. this sender send a true Towards senderDone Channel indication completed.
  3. this receiver detected ch The channel is closed.
  4. this receiver Exit its for-range ring shape.



Completed (t=302-303ms)

  1. The main goroutine receives from senderDone And stop waiting.
  2. The main coroutine starts waiting for signals from receiverDone.
  3. this receiver Send completion signal to receiverDone channel.
  4. The main goroutine receives the signal and prints:
    • “All operations completed!”.
  5. The program exits.


buffer channel

Why do we need buffered channels?
Unbuffered channels block the sender and receiver until the other is ready. When high-frequency communication is required, unbuffered channels can become a bottleneck because two goroutines must pause to exchange data.

Buffer channel properties:

  1. FIFO (first in, first out, similar to queue)
  2. Fixed size, set at creation time
  3. Block sender when buffer is full
  4. Block receiver when buffer is empty

We see it in action:

package main

import (
    "fmt"
    "time"
)

func main() {
    // Create a buffered channel with capacity of 2
    ch := make(chan string, 2)

    // Send two messages (won't block because buffer has space)
    ch <- "first"
    fmt.Println("Sent first message")
    ch <- "second"
    fmt.Println("Sent second message")

    // Try to send a third message (this would block!)
    // ch <- "third"  // Uncomment to see blocking behavior

    // Receive messages
    fmt.Println(<-ch)  // "first"
    fmt.Println(<-ch)  // "second"
}
Enter full screen mode

Exit full screen mode

Output (before uncommenting ch<-"third")

Why doesn’t it block the main goroutine?

  1. buffer channel Allow sending to its capacity without blocking Sender.

  2. The channel has a capacity of 2, which means it can hold two values ​​in its buffer before blocking.

  3. The buffer is already filled with “first” and “second”. Since there are no concurrent receivers to consume the values, the send operation blocks indefinitely.

  4. Because the main goroutine is also responsible for sending, and there are no other active goroutines receiving values ​​from the channel, the program enters a deadlock when trying to send the third message.

Uncommenting the third message will cause a deadlock because the capacity is now full and the third message will block until the buffer is released.


When to use buffered vs. unbuffered channels

aspect buffer channel unbuffered channel
Purpose Used to decouple transmitter and receiver timing. For instant synchronization between transmitter and receiver.
When to use – When the sender can continue without waiting for the receiver. – When sender and receiver must be synchronized directly.
– When buffering improves performance or throughput. – When you want to force message processing immediately.
prevent behavior Blocks only when the buffer is full. The sender blocks until the receiver is ready and vice versa.
Performance Performance can be improved by reducing synchronization. Latency may be introduced due to synchronization.
Example use cases – Logging with rate limit handling. – Simple signaling between goroutines.
– Temporarily queued batches of messages. – Deliver data without delays or buffering.
complex Buffer sizing needs to be carefully adjusted to avoid overflow. Easier to use; no adjustments required.
On sale Higher memory usage due to buffers. Lower memory usage; no buffers involved.
Concurrent mode Asynchronous communication between sender and receiver. Synchronous communication; tight coupling.
Error-prone scenarios If buffer sizes are not managed properly, deadlocks can occur. If no goroutine is ready to receive or send, a deadlock occurs.


focus

use buffered channel if:

  1. You need to decouple sender and receiver time.
  2. Batching or queuing messages can improve performance.
  3. When the buffer is full, the application can tolerate delays in processing messages.

use No buffering channel if:

  1. Synchronization between Goroutines is crucial.
  2. You want simplicity and instant delivery of your material.
  3. The interaction between sender and receiver must occur instantaneously.

These basics lay the foundation for more advanced concepts. In the following articles, we will explore:

Next article:

  1. Concurrent mode
  2. Mutual exclusion and memory synchronization

Stay tuned as we continue to deepen our understanding of Go’s powerful concurrency capabilities!

2024-12-20 00:35:36

Leave a Reply

Your email address will not be published. Required fields are marked *