Concurrency in Go: From Basics to Advanced Concepts

Chanchal Verma - Oct 2 - - Dev Community

Table of Contents

  1. Introduction to Concurrency
  2. Concurrency vs Parallelism
  3. Go-routines: The Building Blocks of Concurrency
  4. Channels: Communication Between Go-routines
  5. Select Statement: Managing Multiple Channels
  6. Synchronization Primitives
  7. Concurrency Patterns
  8. Context Package: Managing Cancellation and Timeouts.
  9. Best Practices and Common Pitfalls**

1.Introduction to Concurrency

Concurrency is the ability to handle multiple tasks simultaneously. In Go, concurrency is a first-class citizen, built into the language's core design. Go's approach to concurrency is based on Communicating Sequential Processes (CSP), a model that emphasizes communication between processes rather than shared memory.

2.Concurrency vs Parallelism:

Go-routines enable concurrency, which is the composition of independently executing processes.
Parallelism (simultaneous execution) may occur if the system has multiple CPU cores and the Go runtime schedules go-routines to run in parallel.

3. Go-routines:
The Building Blocks of Concurrency is Go-routines are lightweight threads managed by the Go runtime. It's a function or method that runs concurrently with other functions or methods. Go-routines are the foundation of Go's concurrency model.

Key Characteristics:

  • Lightweight: Go-routines are much lighter than OS threads. You can easily create thousands of go-routines without significant performance impact.
  • Managed by Go runtime: The Go scheduler handles the distribution of go-routines across available OS threads.
  • Cheap creation: Starting a go-routine is as simple as using the go keyword before a function call.
  • Stack size: Go-routines start with a small stack (around 2KB) that can grow and shrink as needed.

Creating a Go-routine:
To start a go-routine, you simply use the go keyword followed by a function call:

go functionName()
Enter fullscreen mode Exit fullscreen mode

Or with an anonymous function:

go func() {
    // function body
}()
Enter fullscreen mode Exit fullscreen mode

Go-routine Scheduling:

  • The Go runtime uses a M:N scheduler, where M go-routines are scheduled onto N OS threads.
  • This scheduler is non-preemptive, meaning go-routines yield control when they are idle or logically blocked.

Communication and Synchronization:

  • Goroutines typically communicate using channels, adhering to the "Don't communicate by sharing memory; share memory by communicating" principle.
  • For simple synchronization, you can use primitives like sync.WaitGroup or sync.Mutex.

Example with Explanation:

package main

import (
    "fmt"
    "time"
)

func printNumbers() {
    for i := 1; i <= 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Printf("%d ", i)
    }
}

func printLetters() {
    for i := 'a'; i <= 'e'; i++ {
        time.Sleep(150 * time.Millisecond)
        fmt.Printf("%c ", i)
    }
}

func main() {
    go printNumbers()
    go printLetters()
    time.Sleep(2 * time.Second)
    fmt.Println("\nMain function finished")
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • We define two functions: printNumbers and printLetters.
  • In main, we start these functions as goroutines using the go keyword.
  • The main function then sleeps for 2 seconds to allow the goroutines to complete.
  • Without goroutines, these functions would run sequentially. With goroutines, they run concurrently.
  • The output will show numbers and letters interleaved, demonstrating concurrent execution.

Goroutine Lifecycle:

  • A goroutine starts when created with the go keyword.
  • It terminates when its function completes or when the program exits.
  • Goroutines can be leaked if not properly managed, so it's important to ensure they can exit.

Best Practices:

  • Don't create goroutines in libraries; let the caller control concurrency.
  • Be cautious about creating an unbounded number of goroutines.
  • Use channels or sync primitives to coordinate between goroutines.
  • Consider using worker pools for managing multiple goroutines efficiently.

Simple example with explanations of go-routines

package main

import (
    "fmt"
    "time"
)

// printNumbers is a function that prints numbers from 1 to 5
// It will be run as a goroutine
func printNumbers() {
    for i := 1; i <= 5; i++ {
        time.Sleep(500 * time.Millisecond) // Sleep for 500ms to simulate work
        fmt.Printf("%d ", i)
    }
}

// printLetters is a function that prints letters from 'a' to 'e'
// It will also be run as a goroutine
func printLetters() {
    for i := 'a'; i <= 'e'; i++ {
        time.Sleep(300 * time.Millisecond) // Sleep for 300ms to simulate work
        fmt.Printf("%c ", i)
    }
}

func main() {
    // Start printNumbers as a goroutine
    // The 'go' keyword before the function call creates a new goroutine
    go printNumbers()

    // Start printLetters as another goroutine
    go printLetters()

    // Sleep for 3 seconds to allow goroutines to finish
    // This is a simple way to wait, but not ideal for production code
    time.Sleep(3 * time.Second)

    // Print a newline for better formatting
    fmt.Println("\nMain function finished")
}
Enter fullscreen mode Exit fullscreen mode

4.Channels :

Channels are a core feature in Go that allow go-routines to communicate with each other and synchronize their execution. They provide a way for one go-routine to send data to another go-routine.

Purpose of Channels

Channels in Go serve two main purposes:
a) Communication: They allow goroutines to send and receive values to and from each other.
b) Synchronization: They can be used to synchronize execution across goroutines.

Creation: Channels are created using the make function:

ch := make(chan int)  // Unbuffered channel of integers
Enter fullscreen mode Exit fullscreen mode

Sending: Values are sent to a channel using the <- operator:

ch <- 42  // Send the value 42 to the channel
Enter fullscreen mode Exit fullscreen mode

Receiving: Values are received from a channel using the <- operator:

value := <-ch  // Receive a value from the channel
Enter fullscreen mode Exit fullscreen mode

Types of Channels

a) Unbuffered Channels:

  • Created without a capacity: ch := make(chan int)
  • Sending blocks until another goroutine receives.
  • Receiving blocks until another goroutine sends.
ch := make(chan int)
go func() {
    ch <- 42  // This will block until the value is received
}()
value := <-ch  // This will receive the value
Enter fullscreen mode Exit fullscreen mode

b) Buffered Channels:

  • Created with a capacity: ch := make(chan int, 3)
  • Sending only blocks when the buffer is full.
  • Receiving only blocks when the buffer is empty.
ch := make(chan int, 2)
ch <- 1  // Doesn't block
ch <- 2  // Doesn't block
ch <- 3  // This will block until a value is received
Enter fullscreen mode Exit fullscreen mode

Channel Directions

Channels can be directional or bidirectional:

  • Bidirectional: chan T
  • Send-only: chan<- T
  • Receive-only: <-chan T

Example :

func send(ch chan<- int) {
    ch <- 42
}

func receive(ch <-chan int) {
    value := <-ch
    fmt.Println(value)
}
Enter fullscreen mode Exit fullscreen mode

Closing Channels

Channels can be closed to signal that no more values will be sent:

close(ch)
Enter fullscreen mode Exit fullscreen mode

Receiving from a closed channel:

If the channel is empty, it returns the zero value of the channel's type.
You can check if a channel is closed using a two-value receive:

value, ok := <-ch
if !ok {
    fmt.Println("Channel is closed")
}
Enter fullscreen mode Exit fullscreen mode

Ranging over Channels

You can use a for range loop to receive values from a channel until it's closed:

for value := range ch {
    fmt.Println(value)
}
Enter fullscreen mode Exit fullscreen mode

Hey, Thank you for staying until the end! I appreciate you being valuable reader and learner. Please follow me here and also on my Linkedin and GitHub .

. . . . . . . . .
Terabox Video Player