Concurrency is a cool topic and once you get it, it becomes a huge asset. To be honest, I was afraid to write this at first because I myself wasn't very adaptable to concurrency until recently. I've mastered the basics so I want to help other beginners learn about Go's concurrency. This is the first of many concurrency tutorials, please stay tuned for more tutorials.
What is concurrency and why it matters
Concurrency refers to the ability to run multiple things at the same time. Your computer has a CPU. A CPU has several threads. Each thread usually runs one program at a time. When we usually write code, these codes run sequentially, that is, each job is run back to back. In concurrent code, these tasks are run simultaneously by threads.
A good metaphor is a metaphor for a home cook. I still remember the first time I tried cooking pasta. I followed the recipe step by step. I cut the vegetables, made the sauce, then cooked the spaghetti and mixed the two. Here, each step is carried out in sequence, so the next work must wait until the current work is completed.
Fast forward to now and I'm becoming more experienced in cooking spaghetti. I now start making pasta first and then make the sauce during this period. Cooking time is almost reduced to half, as cooking spaghetti and sauce are done at the same time.
Concurrency and parallelism
Concurrency is somewhat different from parallelism. Parallelism is similar to concurrency, in which multiple tasks occur simultaneously. However, in parallelism, multiple threads are performing different work separately, while in concurrency, one thread wanders between different work.
Therefore, concurrency and parallelism are two different concepts. A program can be run in concurrently or in parallel. Your code can be written in order or in concurrent. This code can be run on a single-core machine or a multi-core machine. Think of concurrency as a feature of your code, and think of parallelism as a feature of execution.
Goroutines, the worker Mortys
Go makes writing concurrent code very simple. Each concurrent work is represented by a goroutine. You can start a goroutine by using the go keyword before the function call. Have you seen "Rick and Morty"? Imagine that your main function is a Rick who delegates the task to goroutine Mortys.
Let's start with a continuous code.
package main import ( "fmt" "time" ) func main() { simple() } func simple() { ((), "0") () ((), "1") () ((), "2") () ("done") }
2022-08-14 16:22:46.782569233 +0900 KST m=+0.000033220 0
2022-08-14 16:22:47.782728963 +0900 KST m=+1.000193014 1
2022-08-14 16:22:48.782996361 +0900 KST m=+2.000460404 2
done
The above code prints out the current time and a string. Each print statement runs for one second. Overall, this code takes about three seconds to complete.
Now let's compare it with a concurrent code.
func main() { simpleConc() } func simpleConc() { for i := 0; i < 3; i++ { go func(index int) { ((), index) }(i) } () ("done") }
2022-08-14 16:25:14.379416226 +0900 KST m=+0.000049175 2
2022-08-14 16:25:14.379446063 +0900 KST m=+0.000079012 0
2022-08-14 16:25:14.379450313 +0900 KST m=+0.000083272 1
done
The above code starts three goroutines, printing the current time and i respectively. This code took about a second to complete. This is about three times faster than the sequential version.
"Wait a minute," I heard you ask. "Why wait a full second? Can't we delete this line to make the program run as fast as possible?" Good question! Let's see what happens.
func main() { simpleConcFail() } func simpleConcFail() { for i := 0; i < 3; i++ { go func(index int) { ((), index) }(i) } ("done") }
done
Um....... The program does exit without any panic, but we lack output from goroutines. Why are they skipped?
This is because by default, Go does not wait for the completion of goroutine. Do you know that main runs in goroutine? The main program starts the worker by calling simpleConcFail, but it exits before the worker completes the worker.
Let's go back to the culinary metaphor. Imagine you have three chefs who are responsible for cooking sauces, pasta and meatballs. Now, imagine if Gordon Ramsey ordered the chefs to make a plate of spaghetti and meatballs. The three chefs will work hard to cook sauces, spaghetti and meatballs. However, before the chefs were finished, Gordon rang the bell and ordered the waiter to serve. Obviously, the food is not ready and customers can only get an empty plate.
That's why we're currently waiting for a second when we exit the show. We are not always sure that every job will be done in one second. There is a better way to wait for the work to be done, but we need to learn another concept first.
To sum up, we learned these things:
- Work is entrusted to goroutines.
- Using concurrency can improve your performance.
- The main goroutine does not wait for the work goroutine to complete by default.
- We need a way to wait for each goroutine to complete.
Channels, the green portal
How do goroutines communicate? Of course it is through the channel. The function of a channel is similar to that of a portal. You can send and receive data through the channel. Here is how you make a channel in Go.
ch := make(chan int)
Each channel is strongly typed and only data of that type is allowed to pass. Let's see how we use this.
func main() { unbufferedCh() } func unbufferedCh() { ch := make(chan int) go func() { ch <- 1 }() res := <-ch (res) }
1
Very simple, right? We made a channel called ch. We have a goroutine that sends 1 to ch, we receive that data and save it to res.
You ask, why do we need a goroutine here? Because not doing this will lead to deadlocks.
func main() { unbufferedChFail() } func unbufferedChFail() { ch := make(chan int) ch <- 1 res := <-ch (res) }
fatal error: all goroutines are asleep - deadlock!
We came across a new word. What is a deadlock? Deadlock means that your program is stuck. Why is the above code stuck in a deadlock?
To understand this, we need to know an important feature of the channel. We create an unbuffered channel, which means that nothing can be stored in it for a certain time. This means that both the sender and the receiver must be prepared at the same time to transmit data on the channel.
In the failed example, the actions of sending and receiving occur in sequence. We sent 1 to ch, but at that time no one received the data. Receive occurs on a later line, which means that 1 cannot be sent until the receive line runs. Sadly, 1 cannot be sent first, because ch is buffered and has no space to hold any data.
In this working example, the actions of sending and receiving occur simultaneously. The main function starts goroutine and tries to receive it from ch, at which time goroutine is sending 1 to ch.
Another way to receive from a channel without deadlocking is to close the channel first.
func main() { unbufferedCh() } func unbufferedCh() { ch2 := make(chan int) close(ch2) res2 := <-ch2 (res2) }
0
Close the channel means that data can no longer be sent to it. We can still receive it from that channel. For unbuffered channels, receiving from a closed channel will return a channel type zero value.
To sum up, we learned these things:
- Channels are the way goroutines communicate with each other.
- You can send and receive data through the channel.
- Channels are strongly typed.
- Channels without buffering have no space to store data, so sending and receiving must be done simultaneously. Otherwise, your code will be deadlocked.
- A closed channel will not accept any data.
- Receiving data from an enclosed non-buffered channel returns a zero value.
Wouldn't that be great if the channel can hold the data for a while? This is where the buffer channel comes into play.
Buffered channels, the portal that is somehow cylindrical?
A buffer channel is a channel with a buffer. Data can be stored therein, so sending and receiving do not need to be done simultaneously.
func main() { bufferedCh() } func bufferedCh() { ch := make(chan int, 1) ch <- 1 res := <-ch (res) }
1
Here, 1 is stored in ch until we receive it.
Obviously, we can't send more information to a channel full of buffers. You need space in the buffer to send more.
func main() { bufferedChFail() } func bufferedChFail() { ch := make(chan int, 1) ch <- 1 ch <- 2 res := <-ch (res) }
fatal error: all goroutines are asleep - deadlock!
You can't receive from an empty buffer channel, either.
func main() { bufferedChFail2() } func bufferedChFail2() { ch := make(chan int, 1) ch <- 1 res := <-ch res2 := <-ch (res, res2) }
fatal error: all goroutines are asleep - deadlock!
If a channel is full, the send operation will wait until there is available space. This is proven in this code.
func main() { bufferedCh2() } func bufferedCh2() { ch := make(chan int, 1) ch <- 1 go func() { ch <- 2 }() res := <-ch (res) }
1
We receive it once to get 1 so that the goroutine can be sent 2 to the channel. We did not receive twice from ch, so we only received 1.
We can also receive from closed buffer channels. In this case, we can set a range on the closed channel to iterate over the remaining items inside.
func main() { bufferedChRange() } func bufferedChRange() { ch := make(chan int, 3) ch <- 1 ch <- 2 ch <- 3 close(ch) for res := range ch { (res) } // you could also do this // (<-ch) // (<-ch) // (<-ch) }
1
2
3
Distance measurement on an open channel will never stop. This means that at some point the channel will be empty and the ranging loop will attempt to receive from an empty channel, resulting in a deadlock.
To summarize:
- A buffer channel is a channel with space to accommodate items.
- Sending and receiving do not have to be done simultaneously, unlike non-buffered channels.
- Sending to and receiving to an empty channel will result in a deadlock.
- You can iterate on a closed channel to receive the remaining values in the buffer.
Wait for Godot...I mean, goroutines to complete, using channels
Channels can be used to synchronize goroutines. Remember I told you that before transmitting data through unbuffered channels, must both the sender and the receiver be ready? This means the receiver will wait until the sender is ready. We can say that receiving is blocking, meaning that the receiver will block the run of other code until it receives something. Let's use this clever trick to sync our goroutines.
func main() { basicSyncing() } func basicSyncing() { done := make(chan struct{}) go func() { for i := 0; i < 5; i++ { ("%s worker %d start\n", (()), i) (((5)) * ) } close(done) }() <-done ("exiting...") }
We made a done channel that was responsible for blocking the code until the goroutine was completed. done can be of any type, but struct{} is often used for these types of channels. Its purpose is not to transmit structures, so its type does not matter.
Once the work is finished, the worker goroutine will close done. At this point, we can receive it from done and it will be an empty structure. The receive action unblocks the code so that it can exit.
This is how we use the channel to wait for the goroutine to complete.
Summarize
Concurrency may seem like a daunting topic. Of course I think that's the case. However, after understanding the basics, I think it's really beautiful to implement. Hopefully you can gain something from this tutorial We just touched the surface, and Go has a lot to offer us. Next time we will meet in more concurrency tutorials. goodbye!
This is the article about this article about how to learn about concurrency in Golang. For more related content on Golang, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!