Zum Inhalt springen

Understanding Go’s CSP Model: Goroutines and Channels

Cover

Preface

The implementation of Go’s CSP concurrency model consists of two main components: one is the Goroutine, and the other is the channel. This article will introduce their basic usage and points to note.

Goroutine

A Goroutine is the basic execution unit of a Go application. It is a lightweight, user-level thread whose underlying implementation of concurrency is based on coroutines. As is well known, coroutines are user threads running in user mode; therefore, Goroutines are also scheduled by the Go runtime.

Basic Usage

Syntax: go + function/method

You can create a Goroutine by using the go keyword followed by a function/method.
Example code:

import (
   "fmt"
   "time"
)

func printGo() {
   fmt.Println("Named function")
}

type G struct {
}

func (g G) g() {
   fmt.Println("Method")
}

func main() {
   // Create goroutine from named function
   go printGo()
   // Create goroutine from method
   g := G{}
   go g.g()
   // Create goroutine from anonymous function
   go func() {
      fmt.Println("Anonymous function")
   }()
   // Create goroutine from closure
   i := 0
   go func() {
      i++
      fmt.Println("Closure")
   }()
   time.Sleep(time.Second) // Prevent main goroutine from ending before the created goroutines have a chance to run; hence sleep for 1 second
}

Execution result:

Named function
Method
Anonymous function

When multiple Goroutines exist, their execution order is not fixed. Therefore, the printed results vary each time.

As seen from the code, by using the go keyword, we can create Goroutines based on named functions or methods, as well as anonymous functions or closures.

So, how does a Goroutine exit? Normally, as soon as the Goroutine’s function finishes executing or returns, it exits. If the function or method in the Goroutine has a return value, it will be ignored when the Goroutine exits.

channel

Channels play an important role in Go’s concurrency model. They can be used for communication between Goroutines and for synchronization between Goroutines.

Basic Operations of channel

A channel is a composite data type, and when declaring it, you need to specify the type of elements it will store.

Declaration syntax: var ch chan string

The above code declares a channel whose element type is string, meaning it can only store string values. A channel is a reference type and must be initialized before data can be written into it. It is initialized using make.

import (
   "fmt"
)

func main() {
   var ch chan string
   ch = make(chan string, 1)
   // Print address of channel
   fmt.Println(ch)
   // Send "Go" into ch
   ch <- "Go"
   // Receive data from ch
   s := <-ch
   fmt.Println(s) // Go
}

You can send data into a channel variable ch using ch <- xxx and receive data from it using x := <-ch.

Buffered vs. Unbuffered Channels

If you do not specify a capacity when initializing a channel, an unbuffered channel will be created:

ch := make(chan string)

In an unbuffered channel, send and receive operations are synchronous. After executing a send operation, the corresponding Goroutine will block until another Goroutine performs a receive operation, and vice versa. What happens if the send and receive operations are placed in the same Goroutine? Let’s look at the following code:

import (
   "fmt"
)

func main() {
   ch := make(chan int)
   // Send data
   ch <- 1 // fatal error: all goroutines are asleep - deadlock!
   // Receive data
   n := <-ch
   fmt.Println(n)
}

When the program runs, it will throw a fatal error at ch <- stating that all Goroutines are asleep — in other words, a deadlock has occurred. To avoid this, we need to place the send and receive operations into different Goroutines.

import (
   "fmt"
)

func main() {
   ch := make(chan int)
   go func() {
      // Send data
      ch <- 1
   }()
   // Receive data
   n := <-ch
   fmt.Println(n) // 1
}

From the above example, we can conclude that for unbuffered channels, the send and receive operations must be performed in two different Goroutines; otherwise, a deadlock will occur.

If you specify a capacity, a buffered channel will be created:

ch := make(chan string, 5)

Buffered channels differ from unbuffered channels: when performing a send operation, as long as the channel’s buffer is not full, the Goroutine will not be suspended. Only when the buffer is full will sending to the channel cause the Goroutine to be suspended. Example code:

func main() {
   ch := make(chan int, 1)
   // Send data
   ch <- 1

   ch <- 2 // fatal error: all goroutines are asleep - deadlock!
}

Declaring Send-Only and Receive-Only Channels

Channels that can both send and receive

ch := make(chan int, 1)

With the above code, we get a channel variable on which we can perform both send and receive operations.

Receive-only channel

ch := make(<-chan int, 1)

With the above code, we get a channel variable on which we can only perform receive operations.

Send-only channel

ch := make(chan<- int, 1)

With the above code, we get a channel variable on which we can only perform send operations.

Typically, send-only and receive-only channel types are used as function parameter types or return values:

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

func recv(ch <-chan int) {
   <-ch
}

Closing a Channel

You can close a channel using the built-in function close(c chan<- Type).

Closing a channel on the sending side
After a channel is closed, you can no longer perform send operations on it; otherwise, a panic will occur, indicating that the channel is already closed.

func main() {
   ch := make(chan int, 5)
   ch <- 1
   close(ch)
   ch <- 2 // panic: send on closed channel
}

After a channel is closed, you can still perform receive operations on it. If the channel has a buffer, the buffered data will be read out first. If the buffer is empty, the value retrieved will be the zero value of the channel’s element type.

import "fmt"

func main() {
   ch := make(chan int, 5)
   ch <- 1
   close(ch)
   fmt.Println(<-ch) // 1
   n, ok := <-ch
   fmt.Println(n)  // 0
   fmt.Println(ok) // false
}

When traversing a channel with for-range, if the channel is closed during iteration, the for-range loop will end.

Summary

This article first introduced how to create Goroutines and the conditions under which they exit.

It then described how to create channel variables, both buffered and unbuffered. It is important to note that for unbuffered channels, the send and receive operations must be executed in two different Goroutines; otherwise, an error will occur.

Next, it explained how to define send-only and receive-only channel types. Typically, these types are used as function parameter types or return values.

Finally, it covered how to close a channel and some precautions after closing it.

We are Leapcell, your top choice for hosting Go projects.

Leapcell

Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:

Multi-Language Support

  • Develop with Node.js, Python, Go, or Rust.

Deploy unlimited projects for free

  • pay only for usage — no requests, no charges.

Unbeatable Cost Efficiency

  • Pay-as-you-go with no idle charges.
  • Example: $25 supports 6.94M requests at a 60ms average response time.

Streamlined Developer Experience

  • Intuitive UI for effortless setup.
  • Fully automated CI/CD pipelines and GitOps integration.
  • Real-time metrics and logging for actionable insights.

Effortless Scalability and High Performance

  • Auto-scaling to handle high concurrency with ease.
  • Zero operational overhead — just focus on building.

Explore more in the Documentation!

Try Leapcell

Follow us on X: @LeapcellHQ

Read on our blog

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert