A little about Goroutines in Go!
jefferson otoni lima
Posted on April 25, 2023
Goroutines instead of threads
One of the main goals of the Go programming language is to make concurrency simpler, faster, and more efficient.
Goroutines are certainly one of the main features of Go and one of the main reasons why Go is so efficient at performing tasks in parallel.
When we create APIs in Go using net/http and receive a large number of requests, what does the pkg net/http use? If your answer is Goroutines, you are correct 😍.
I avoid the term "lightweight threads" as it creates confusion for those who are starting or trying to understand what Goroutines are. Although it is true that Goroutines are similar to "lightweight threads" in other systems, Go uses a different implementation that is optimized for the context of Go. Therefore, it may be better to explain Goroutines in terms of how they work in Go, which I believe will make it even easier for you to understand.
Instead of saying that Goroutines are "lightweight threads", we can say that Goroutines are lightweight units of concurrency that are executed in a single execution thread or in several depending on how the Go runtime decides to manage them. This means that Go can execute many Goroutines simultaneously in a single thread or multiple threads.
Goroutines are managed by the Go runtime and not by the underlying operating system. This means that Goroutines are much lighter and more efficient than traditional operating system threads, and that Go can manage them more effectively to achieve maximum performance and scalability.
One of the goals of Goroutines in Go is to allow you to write concurrent programs easily and efficiently. This means that you can have multiple Goroutines running simultaneously, each executing a different task, without worrying about resource locks, active waits, or other common problems in concurrent programming.
To create a Goroutine in Go, you can simply add the "go" keyword before a function call. For example, consider the following code:
package main
func myFuncJeff() {
// your code here.
}
func main() {
// start the Goroutine
go myFuncJeff()
// you can put something here
}
Goroutines are lightweight and take advantage of all available processing power. Goroutines only exist in the virtual space of the Go runtime and not in the operating system.
Goroutine is a method/function that can be executed independently along with other goroutines. Each concurrent activity in the Go language is usually called a Goroutine.
Go is a multiparadigm language and one of the most relevant paradigms is concurrency. One of the most relevant and important points in the Go language is working with concurrency. Go innovated by breaking the traditional model of threads and their usage by creating a new model, goroutines. Goroutines are responsible for executing tasks in Go asynchronously. They are very powerful and a simple machine with 1GB of RAM and 1 CPU can run thousands of goroutines.
In Go, Goroutines make concurrency easy to use. Goroutines can be very cheap: they have little overhead besides the memory for the stack, which is only a few kilobytes.
To make stacks small, the Go runtime uses resizable, bounded stacks. A newly launched goroutine gets a few kilobytes, which is almost always enough. When it's not, the runtime grows (and shrinks) the memory to store the stack automatically, allowing many goroutines to live in a modest amount of memory.
The CPU overhead has, on average, three cheap instructions per function call. It's practical to create hundreds of thousands of goroutines in the same address space. If goroutines were just threads, the system resources would run out at a much lower number.
package main
func enviarEmail(...string) {
// Code to send email
}
func enviarParaFila(mensagem string) {
// Code sending to queue
}
func main() {
// goroutine sends email
go enviarEmail("jeff@gmail.com", "my email")
// goroutine sends to queue
go enviarParaFila("Message to queue")
// Do something else here
}
In this example, we created two functions, enviarEmail and enviarParaFila, which simulate sending an email and sending a message to a queue, respectively. Then, in the main() function, we start two different Goroutines, one to send an email and another to send a message to the queue.
Note that we are using the go keyword before each function call to start a separate Goroutine for each task. This means that the program will continue to run normally, without waiting for the email or queue message to be completed.
The design of Go was heavily influenced by C.A.R. Hoare's 1978 article "Communicating Sequential Processes" (CSP).
Simultaneity in CSP ideas
One of the most successful models for providing high-level language support for concurrency is Hoare's Communicating Sequential Processes (CSP), Occam and Erlang are two well-known languages that derive from CSP. The concurrency primitives of Go derive from a different part of the genealogy whose main contribution is the powerful notion of channels as first-class objects. Experience with several previous languages showed that the CSP model fits well within a procedural language framework.
The biggest difference between Go and the CSP model, aside from syntax, is that Go models communication channels explicitly as channels, while Hoare's language processes send messages directly to each other, similar to Erlang.
Well, to get to this result, Go again received criticism of the adopted model. Go incorporates a variant of CSP (Communicating Sequential Processes), a formal language for describing interaction patterns in concurrent systems with first-class channels. A single-write approach was not adopted to value semantics in the context of concurrent computing as is done in Erlang, instead they adopted something practical and which resulted in something powerful, allowing for simple and safe concurrent programming, but does not prohibit incorrect programming. And the created motto was: "Don't communicate by sharing memory, share memory by communicating".
The simplicity and concurrency support offered by Go generates robustness.
package main
import (
"fmt"
"net/http"
"time"
)
func getSites(url string) {
response, err := http.Get(url)
if err != nil {
fmt.Printf("Error reading %s: %s\n", url, err)
return
}
defer response.Body.Close()
fmt.Printf("%s read successfully\n", url)
}
func main() {
// URLs we will read
urls := []string{
"https://www.google.com",
"https://www.facebook.com",
"https://www.github.com",
"https://www.linkedin.com",
}
// Separate goroutine to read each URL
for _, url := range urls {
go getSites(url)
}
// Do something else here
// ...
time.Sleep(5 * time.Second)
}
In this example, we defined a getSite
function that uses the net/http
package to make an HTTP request to a specific website and read the response. Then, in the main()
function, we defined a list of URLs that we want to read and started a separate Goroutine to read each URL.
Note that we're using a for
loop to iterate through the list of URLs and start a separate Goroutine for each URL using the go
keyword. This means that the program will continue to run normally, without waiting for each HTTP request to complete.
To give the Goroutines time to do their job, we use the time.Sleep()
function to suspend the program's execution for a short period of time. In this example, we use a delay of 5 seconds to allow the Goroutines to finish executing.
Now, let's write the same code using sync.WaitGroup
to synchronize our Goroutines and gain even better control over them.
package main
import (
"fmt"
"net/http"
"sync"
)
func getSite(url string, wg *sync.WaitGroup) {
// Signal the WaitGroup when the Goroutine finishes
defer wg.Done()
resp, err := http.Get(url)
if err != nil {
fmt.Printf("Error reading %s: %s\n", url, err)
return
}
defer resp.Body.Close()
fmt.Printf("%s read successfully\n", url)
}
func main() {
// URLs we will read
urls := []string{
"https://www.google.com",
"https://www.facebook.com",
"https://www.github.com",
"https://www.linkedin.com",
}
// Create a WaitGroup to synchronize the Goroutines
var wg sync.WaitGroup
for _, url := range urls {
// Add WaitGroup for each Goroutine
wg.Add(1)
// Pass the WaitGroup as a pointer
go getSite(url, &wg)
}
// Wait until all Goroutines finish
wg.Wait()
fmt.Println("done")
}
Using WaitGroup, we can ensure that all Goroutines finish executing before the program continues. This is especially useful when we want the program to only terminate when all concurrent tasks are completed.
Concurrency is different from Parallelism
One of the famous quotes is: "Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once." Concurrency is the composition of independently executing computations. Concurrency is a way of structuring software and definitely not parallelism, although it enables parallelism.
If you have only one physical core in your processor, your program can still be concurrent but cannot be parallel. On the other hand, a well-written concurrent program can efficiently run in parallel on a processor that has more than one physical core. I suggest checking out this video of a talk by Rob Pike "Concurrency is not Parallelism."
Concurrency in Go is very powerful and also easy to use, this was the intention of the engineers who developed Go. Solving many problems is much more efficient using concurrency and this is the power of Go, which has become a god when it comes to concurrency. Due to this, problems encompassed in this universe will be solved with much efficiency and most importantly, with very little computational resources.
Want to further deepen your understanding of concurrency? Just follow this link. Want to test and see some examples? Click here.
Goroutine is a very extensive topic, full of details and many interesting situations. So, it requires us to make a post only about it in the future so that it can be treated with due care and attention.
Check out a simple example.
package main
import (
"fmt"
"time"
)
func main() {
go func(){fmt.Println("hi I'm a goroutine1!")}()
go func(){fmt.Println("hi I'm a goroutine2!")}()
go func(){fmt.Println("hi I'm a goroutine3!")}()
fmt.Println("Hello, we are testing goroutines in Go!")
time.Sleep(time.Second)
}
Channels
Channels in Go are a way to synchronize communication between Goroutines and share data. They allow one Goroutine to safely and efficiently send data to another Goroutine without the need for locks or semaphores.
Channels are created using the make() function and can be used to send or receive values of a specific type. Channels can be defined with an optional capacity, which specifies how many values can be stored in the channel before it starts to block. If the capacity is not specified, the channel will have a capacity of zero, meaning it can only store one value at a time.
To send a value to a channel, we use the syntax channel <- value. To receive a value from a channel, we use the same syntax but reversed, like value <- channel. When we use the <- syntax without a variable on the left or right side, it is used to block the execution of the Goroutine until a value is sent or received on the channel.
package main
import "fmt"
func main() {
// Criando um canal sem buffer
canalSemBuffer := make(chan int)
// Criando um canal com buffer de tamanho 3
canalComBuffer := make(chan int, 3)
// Enviando valores para o canal sem buffer
go func() {
canalSemBuffer <- 1
canalSemBuffer <- 2
canalSemBuffer <- 3
close(canalSemBuffer)
}()
// Enviando valores para o canal com buffer
canalComBuffer <- 1
canalComBuffer <- 2
canalComBuffer <- 3
close(canalComBuffer)
// Recebendo valores do canal sem buffer
for valor := range canalSemBuffer {
fmt.Println("Valor recebido do canal sem buffer:", valor)
}
// Recebendo valores do canal com buffer
for valor := range canalComBuffer {
fmt.Println("Valor recebido do canal com buffer:", valor)
}
}
In this example, we are creating two channels, one with a buffer size of 0 and another with a buffer size of 3. When a channel has a buffer size greater than 0, it becomes a buffered channel. Buffered channels can hold a limited number of values until they are read. If the buffer is full and more values are sent to the channel, the sending goroutine will block until a value is read from the channel.
In the code above, we are sending three values to the buffered channel without blocking, and then closing the channel. After that, we are receiving these values from the channel using a for range loop.
We are also sending three values to the unbuffered channel using a goroutine, and then closing the channel. After that, we are receiving these values from the channel using a for range loop as well. Since the unbuffered channel has no capacity, the goroutine blocks until a receiver is ready to receive a value from the channel.
package main
import "fmt"
func produtor(canal chan<- int) {
for i := 0; i < 10; i++ {
canal <- i // Envia um valor para o canal
}
close(canal) // Fecha o canal
}
func consumidor(canal <-chan int) {
for valor := range canal { // Itera sobre os valores recebidos do canal
fmt.Println(valor) // Exibe o valor recebido
}
}
func main() {
// Cria um canal sem capacidade definida
canal := make(chan int)
// Goroutine do produtor para enviar valores para o canal
go produtor(canal)
// Goroutine do consumidor para receber os valores do canal
consumidor(canal)
}
In this example, we have two different Goroutines: a producer and a consumer. The producer Goroutine sends values to the channel, while the consumer Goroutine receives them and displays them on the screen.
Note that we use the chan keyword to define the type of the channel. In this example, we are using chan int, which means that we are creating a channel that can be used to send or receive integer values.
We also use the <-chan and chan<- keywords to specify whether the channel should be used to send or receive values. In the case of the producer Goroutine, we are using canal chan<- int, which means that we are creating a channel that can only be used to send integer values. In the consumer Goroutine, we are using canal <-chan int, which means that we are creating a channel that can only be used to receive integer values.
By calling the close() function on the channel in the producer Goroutine, we are signaling that there are no more values to be sent through the channel. This causes the range function in the consumer Goroutine to exit the loop when all values have been received.
Let's reinforce the concept: A channel is a communication object used by Goroutines and plays a fundamental role in communication between Goroutines. Technically, a channel is the transfer of data in which the data can be passed or read. Thus, a Goroutine can send data to a channel, while other Goroutines can read the data from the same channel, like a queue. Channels are the safest way to communicate in the Go language. There are other ways to share data in Go, not as efficient as channels. The team that developed Go decided not to close the possibilities, and it is possible to share data without using channels.
Declaring a Channel without and with buffer
type Promise struct {
Result chan string
Error chan error
}
var (
ch1 = make(chan *Promise) // received a pointer from the structure
ch2 = make(chan string, 1) // allows only 1 channels
ch3 = make(chan int, 2) // allows only 2 channels
ch4 = make(chan float64) // has not been set can freely receive
ch5 = make(chan []byte) // by default the capacity is 0
ch6 = make(chan bool, 1) // non-zero capacity
ch7 = make(chan time.Time, 2)
ch8 = make(chan struct{}, 2)
ch9 = make(chan struct{})
ch10 = make(map[string](chan int)) // map channel
ch11 = make(chan error)
ch12 = make(chan error, 2)
// receives a zero struct
ch14 <-chan struct{}
ch15 = make(<-chan bool) // can only read from
ch16 = make(chan<- []os.FileInfo) // can only write to// holds another channel as its value
ch17 = make(chan<- chan bool) // can read and write to
)
Receiving values on the channel
func main() {
ch2 <- "okay"
defer close(ch2)
fmt.Println(ch2, &ch2, <-ch2)
ch7 <- time.Now()
ch7 <- time.Now()
fmt.Println(ch7, &ch7, <-ch7)
fmt.Println(ch7, &ch7, <-ch7)
defer close(ch7)
ch3 <- 1 // okay
ch3 <- 2 // okay
// deadlock // ch3 <- 3 // does not accept any more
// values, if you do it will error : deadlockdefer close(ch3)
fmt.Println(ch3, &ch3, <-ch3)
fmt.Println(ch3, &ch3, <-ch3)
ch10["lambda"] = make(chan int, 2)
ch10["lambda"] <- 100defer close(ch10["lambda"])
fmt.Println(<-ch10["lambda"])
}
A small example of a goroutine using channels.
Example 1:
package main
import "fmt"
func goroutine(c chan string) {
fmt.Println("Eu sou um canal: " + <-c + "!")
}
func main() {
fmt.Println("start goroutine!")
c := make(chan string)
go goroutine(c)
c <- "jeffotoni"
}
Example 2:
package main
import (
"fmt"
"time"
)
// escrevendo no canal
func write(ch chan int) {
for i := 0; i < 5; i++ {
ch <- i
fmt.Println("escrever:", i, "to ch")
}
close(ch)
}
func main() {
// channel com buffer
ch := make(chan int, 2)
// goroutine
go write(ch)
//aguarde um pouco
time.Sleep(1 * time.Second)
// listando o canal
for v := range ch {
fmt.Println("ler", v, "from ch")
time.Sleep(2 * time.Second)
}
}
In this example, we are defining a variable called ch that is a channel that can only be used to receive values of type struct{}. We use the <-chan keyword to specify that this channel can only be used to receive values.
Check out the code below:
package main
import "fmt"
func main() {
// Creates an unbuffered channel
ch := make(chan struct{})
// Starts a Goroutine to send a value to the channel
go func() {
ch <- struct{}{} // Sends a value to the channel
}()
// Receives a value from the channel and prints a message
<-ch
fmt.Println("Value received from channel")
}
Select
The select is a control structure in Go that allows you to monitor multiple channels simultaneously and execute the first operation that is ready. This allows you to write concise and efficient code that responds to events from multiple channels at the same time.
package main
import (
"fmt"
"time"
)
func sendMessage(channel chan<- string, message string, delay time.Duration) {
// Wait for a random period of time
time.Sleep(delay)
// Send the message to the channel
channel <- message
}
func main() {
// Create two unbuffered channels
channel1 := make(chan string)
channel2 := make(chan string)
// Start two separate goroutines to send messages to the channels
go sendMessage(channel1, "Message for channel 1", time.Second*2)
go sendMessage(channel2, "Message for channel 2", time.Second*1)
// Use select to receive the first message that is ready
select {
case msg1 := <-channel1:
fmt.Println("Message received from channel 1:", msg1)
case msg2 := <-channel2:
fmt.Println("Message received from channel 2:", msg2)
}
}
In this example, we have two different Goroutines that send messages to two different channels. Using select, we are monitoring both channels and displaying the first message that arrives in one of them.
Note that we are using the <- syntax to receive values from the channels inside the select cases. The select will wait until one of the cases is ready, meaning a message has been received from one of the channels.
Additionally, we are using time.Sleep() in the sendMessage() function to simulate a random delay before sending the message to the channel. This is done to demonstrate that the message sent to the channel faster may not be the first one to be received due to the delays.
Select is a powerful tool in Go that can be used to write efficient code that monitors multiple channels at the same time. It is particularly useful for handling asynchronous events and allowing the program to perform multiple tasks simultaneously.
I hope I have helped with a better understanding of Goroutines.
Another complementary and important subject is "Workloads: CPU-Bound and IO-Bound" which I recommend reading to understand even more.
Here you will find more examples:
Go manual at: gobootcamp.jeffotoni
Various examples of Goroutines at: goexample
In Go, we have an arsenal to work with Goroutines and manage concurrency without losing simplicity, readability, and productivity.
Go is love ❤
Posted on April 25, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.