Concurrency Basics in Go: A Beginner's Journey to Efficient Programming

Shivam DubeyShivam Dubey
5 min read

Concurrency allows programs to execute multiple tasks at the same time, which makes them faster and more efficient. In Go, concurrency is built into the language and is relatively easy to use. Go uses goroutines for concurrent execution and channels for communication between them.

In this article, we'll explain the basic concepts of concurrency in Go, including:

  1. Goroutines: What they are and how to use them.

  2. Channels: How to communicate between goroutines.

  3. Buffered vs. Unbuffered Channels: What’s the difference?

  4. The Select Statement: Handling multiple channels.


1. Goroutines: Spawning, Behavior, and Lifecycle

A goroutine is a lightweight thread of execution. You can think of it as a small worker that performs a task in the background while the main program continues its execution. Goroutines are managed by Go's runtime, which handles scheduling and running them efficiently.

Example: Spawning a Goroutine

package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello from Goroutine!")
}

func main() {
    // Step 1: Start a new Goroutine
    go sayHello()  // This will run sayHello() in the background

    // Step 2: Main function keeps running
    time.Sleep(1 * time.Second)  // Give the Goroutine time to run
    fmt.Println("Hello from Main!")
}

Explanation:

  1. go sayHello(): This is how you spawn a new goroutine. The go keyword before a function call launches the function as a goroutine.

  2. time.Sleep(1 * time.Second): Since the main function will finish quickly, we use time.Sleep() to give the goroutine time to execute.

  3. In the output, you might see "Hello from Goroutine!" and "Hello from Main!" printed, though the order may vary because they run concurrently.

Output:

Hello from Goroutine!
Hello from Main!

Goroutines are very fast and lightweight, which allows you to spawn thousands of them without affecting performance significantly.


2. Channels: Creating, Sending, Receiving

A channel is a Go feature that allows goroutines to communicate with each other. A channel can be thought of as a "pipe" through which data can be sent and received.

Example: Basic Channel Communication

package main

import (
    "fmt"
)

func sendMessage(ch chan string) {
    ch <- "Hello from Goroutine!"  // Send data into the channel
}

func main() {
    // Step 1: Create a channel
    messageChannel := make(chan string)

    // Step 2: Start a goroutine that sends a message
    go sendMessage(messageChannel)

    // Step 3: Receive the message from the channel
    message := <-messageChannel
    fmt.Println(message)
}

Explanation:

  1. make(chan string): This creates a channel of type string.

  2. ch <- "Hello from Goroutine!": Sends data into the channel. The goroutine is pushing the string into the channel.

  3. message := <-messageChannel: The main function receives the message from the channel and stores it in the message variable.

  4. Finally, it prints the message.

Output:

Hello from Goroutine!

3. Buffered vs. Unbuffered Channels

Channels in Go can either be unbuffered or buffered. The main difference between them is how they handle data flow.

  • Unbuffered Channels: These channels do not have storage. When one goroutine sends data, it waits until another goroutine receives it. The sending and receiving happen synchronously.

  • Buffered Channels: These channels have a buffer, meaning they can hold a certain number of messages before they block. The sender doesn’t block until the buffer is full, and the receiver doesn’t block until the buffer is empty.

Example: Unbuffered Channel

package main

import (
    "fmt"
)

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

    // Start a goroutine to send data
    go func() {
        ch <- "Hello from Goroutine!"
    }()

    // Main function waits to receive data
    msg := <-ch
    fmt.Println(msg)
}

Explanation:

  1. Unbuffered channel: The channel ch will not hold any data. The main function will block at msg := <-ch until the goroutine sends a message.

Output:

Hello from Goroutine!

Example: Buffered Channel

package main

import (
    "fmt"
)

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

    // Send data into the buffered channel
    ch <- "Message 1"
    ch <- "Message 2"

    // Main function receives data
    msg1 := <-ch
    msg2 := <-ch
    fmt.Println(msg1)
    fmt.Println(msg2)
}

Explanation:

  1. make(chan string, 2): This creates a buffered channel with a capacity of 2. It can hold up to 2 messages before blocking.

  2. We send 2 messages into the channel and receive them one by one.

Output:

Message 1
Message 2

As you can see, with buffered channels, the main function does not block until the messages are received, and the sender can continue to send data without waiting.


4. Select Statement for Multiplexing Channels

The select statement allows a Go program to wait on multiple channel operations. It’s like a "switch" for channels. If there are multiple channels ready, select will choose one to execute. It’s used to handle situations where you want to perform operations on multiple channels concurrently.

Example: Using Select

package main

import (
    "fmt"
    "time"
)

func sendMessage(ch chan string, message string) {
    time.Sleep(2 * time.Second)
    ch <- message
}

func main() {
    // Create two channels
    ch1 := make(chan string)
    ch2 := make(chan string)

    // Start two goroutines
    go sendMessage(ch1, "Hello from channel 1!")
    go sendMessage(ch2, "Hello from channel 2!")

    // Use select to wait for messages from either channel
    select {
    case msg1 := <-ch1:
        fmt.Println("Received:", msg1)
    case msg2 := <-ch2:
        fmt.Println("Received:", msg2)
    }
}

Explanation:

  1. select: Waits for one of the channels to be ready for communication. It’s like a "choose one" for channels.

  2. In the program, sendMessage() sends messages into ch1 and ch2, and select picks one of them when it’s ready.

Output (varies based on which channel finishes first):

Received: Hello from channel 1!

Conclusion

In this article, we learned the basics of concurrency in Go, including:

  • Goroutines: Spawning concurrent tasks using the go keyword.

  • Channels: Sending and receiving messages between goroutines.

  • Buffered and Unbuffered Channels: Understanding how data flow works with channels.

  • Select Statement: Multiplexing multiple channels and making decisions based on their state.

Concurrency is a powerful feature in Go, and mastering it will allow you to build highly efficient and concurrent programs. Keep practicing these concepts, and soon you’ll be comfortable writing concurrent Go programs!

Happy coding! 🚀

0
Subscribe to my newsletter

Read articles from Shivam Dubey directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shivam Dubey
Shivam Dubey