In this tutorial, we are going to look into Concurrency using Goroutines and Channels with the help of some of the best examples. A goroutine is a lightweight thread built into Go that allows functions to run at the same time (concurrently). Channels allow multiple goroutines to communicate with each other. Goroutines and channels are the building blocks of writing concurrent programs in Go. We will understand both the concept in great detail in below sections.
In order to follow through this article:-
- You need to have a basic understanding of programming.
- You need to download the Go compiler from the official website and follow the directions to install it based on your operating system being either Windows, MacOS or any of the Linux distributions.
- You need a code editor like visual studio, atom or sublime text. Alternatively, you can download an Integrated Development Environment (IDE). An IDE is an improved code editor with added qualities customized to help you write and execute codes easier. Goland is the most popular IDE used for developing Go programs.
- You need to have an understanding of Go data types, structs, functions and methods.
Concurrency using Goroutines and Channels - Explained with examples
Concurrency is the concept of running different functions or tasks at the same time. Concurrency does not really involve running different tasks at the same time, instead it is a way to handle numerous simultaneous effects by managing time for execution. For example, in a program where one task is waiting and another task executes in that idle time.
Different tasks can run concurrently on a CPU thread. Concurrency should not be confused with parallelism which involves running tasks at the same time on different CPU threads. Go provides rich support for concurrency using goroutines and channels.
Goroutines are lightweight threads managed by the Go runtime. A goroutine is a function that runs in the background, while the main function continues execution. We create a goroutine by prefixing a function call with the go keyword.
Running the program above will not return any output. This is because after creating the new goroutine inside the main function, the main goroutine continues execution till it exits without waiting for the new goroutine. We can add a delay before exiting main to be able to see the result.
Goroutines are functions that run at the same time and are managed by the Go runtime. Every Go program is made up of at least one goroutine - the main function. We can also launch multiple Goroutines within main and they will execute concurrently. Calling multiple independent functions concurrently would produce unexpected behavior.
In the programs above, we’ve created goroutines on functions from the standard library. Goroutines could also be created with named and anonymous functions. In fact, in most small examples of concurrent programs, goroutines are usually written with anonymous functions. Let’s demonstrate examples of using named and anonymous functions.
In the program above, we create a function, triple, that takes in a pointer to an array and triples each element in it. On line 18, we run the function in a new goroutine which runs in the background. On line 19, we add a delay of 1 second for the triple function to complete tripling each element in the array, and we print the updated array on line 21.
Using a time delay is not an efficient way to ensure that the goroutine exits before main exits, instead we use a feature called channels which we will discuss about later in this article. The function above creates a goroutine for a named function. Let’s modify the example above to use an anonymous function and closures.
From the program above, note the parenthesis on line 15. We used the parenthesis to call the function immediately after it is declared. If the anonymous function takes in parameters, we would have passed in its argument within the parenthesis.
Notice that for the goroutines we have shown with functions, we have not declared or accepted any values like we would normally do with functions. This is because we cannot accept values from goroutines the normal way (using assignments), instead we use a feature called channels.
Channels are a way of communicating between two goroutines. Suppose we want to receive a value from a goroutine to continue execution of the main goroutine, we can pass in a channel to the goroutine to accept a value of a particular type. A channel is a reference type, it works similar to pointers in Go by pointing to a memory address location containing datum of type T. We define a channel using the built-in make function, and chan keyword and data type in parenthesis.
We send and receive values from a function using the <- directive. Let’s see an example of using a channel to receive values from a goroutine.
From the program above, we declared a function square that takes in a channel and a number n, and returns the square of n using the channel. On line 8, we sent the square of n into the channel. Since a channel is a reference type, any update to a channel within a function affects the caller. We create the new goroutine from square on line 15 and receive the new value from intCh and store it into a new variable b on line 16. The main goroutine blocks on line 16 until a value is received into intCh, therefore, we won’t need a delay to prevent main from exiting before the new goroutine.
As in line 16 above, if we query from a channel in main, but the channel has not received a value, the program execution blocks till a value is received. This can lead to a deadlock - a state where a channel expects to receive a value from a goroutine, but there’s no goroutine which populates it. The compiler panics if a deadlock is detected in a program.
We can also pass channels into functions as unidirectional. This means that within the function, the channel will be able to either receive values, or send values, but not both. Unidirectional channels could be send-only or receive-only. Let’s demonstrate this behavior using the example above.
In the program above, we defined ch in square() as a send-only channel. Therefore, within square(), a value can only be sent into ch. Let's see an example that uses a receive-only channel below.
What if we want to return more than one value from a goroutine? The channels created in the examples above can receive one value at a time. This type of channel is called an unbuffered channel. An unbuffered channel blocks when you try to send it more than one value at a time. We can receive multiple values from a channel at the same time if we use buffered channels.
Suppose we want to receive multiple values of the same type from a goroutine, we can create a buffered channel. A buffered channel is created using the same syntax as a channel, but an int value is passed into make to represent the number of values the channel can store at the same time.
In the snippet above, we created a buffered channel that can store 5 string values at a time. A buffered channel works with a first-in-first-out (FIFO) mechanism. Let’s write an example that uses buffered channels:-
In the program above, we create a function that returns even numbers between 0 and a number passed into the function. On line 19, we created a buffered channel that can hold 5 values at a time. We then create a new goroutine in line 22 with a num value of 10. The enumEvenNumbers function loops through 0 to num and adds any even number encountered to a channel.
After the loop, we close the channel using the built-in function close(). We only need to close a channel if we need to inform another goroutine that no value will be sent into the channel again. In most cases, Go’s built-in garbage collector will automatically determine if a channel is still in use and dispose of it.
Unlike an unbuffered channel, we can range through a buffered channel like on line 25 above. Therefore, as we receive values into the buffered channel from enumEvenNumbers, we range through them and print their values out. Ranging through the values in a buffered channel works like receiving values from a channel one after the other. The program above will always work irrespective of the value of num passed into enumEvenNumbers because:-
- The buffered channel, ch blocks when it has received 5 values until at least one of the values is received.
- Receiving from the ch blocks when no value is stored in the channel at a particular time.
- The close function informs the main goroutine that ch won't be receiving any more values, then the function ends.
We can show the first behavior described above by increasing the value of num to a larger value, say 20, and add a 1 second delay before we begin to print values from the channel. Let’s make the changes below.
If you run the program above locally, it hesitates for approximately 1 second, then prints all even numbers. During the 1 second hesitation, the enumEvenNumbers goroutine keeps running and populates the buffered channel with the first 5 even numbered values. After this, enumEvenNumbers blocks till after the 1 second delay so it can release some of its values on line 30. After at least one of the values from ch is received, it is now able to continue accepting values from the enumEvenNumbers goroutine. This is possible since both goroutines are running at the same time, and channels are reference types.
We can also demonstrate the second behavior listed above by moving the delay on line 26 in the program above, to a new line immediately after line 12. The program is expected to print a result where each even number is printed between 1 second intervals. I’ll leave that as an exercise for you to try. Note that in production programs, it is not efficient to use time delays, they were only used above to demonstrate how buffered channels work.
The select statement is like a switch statement, but we use the select statement to receive from multiple channels without one blocking another. Select switches on channels, defining a piece of code to execute if any channel receives a value first. Select statement can also define a default case that executes if no channel has received a value at any iteration. Let’s see an example of using a select statement.
The program above sorts a slice and returns its middle number using a channel. We passed in two channels: intCh and quit. intCh receives the middle number from the slice while quit receives 0 if the length of the slice is even. On line 25, we passed in a slice of odd length, and used the select statement to select from the first channel to receive a value. Since the length of the slice is odd, we first received a value on intCh and executed line 31.
On line 36, we passed in a slice of even length and received a value back first on quit. We then executed the code on line 44. Note that the select statement does not run concurrently, instead it waits for at least one channel to receive a value, or executes the default case if specified. The compiler panics due to a deadlock if none of the channels receives a value, and no default case is specified.
Example: Concurrent Fibonacci Series
We can use the knowledge of channels and goroutines to create a program that takes a number n and prints the first n fibonacci numbers. Let’s see the implementation below.
Let’s go through the program above from the main function. We first create two channels on line 19 and 20, then create a variable n and assign it a value representing the number of fibonacci numbers to be printed. We create a new goroutine using an anonymous function on line 24, so the function begins to run in the background and main continues its execution. For the purpose of our explanation, let’s call the new goroutine goroutine 1.
The main function continues its execution to run the fibonacci function on line 31. This means that fibonacci and the new goroutine are now being executed at the same time. fibonacci takes in the two channels ch and quit. Looking at the definition of the function fibonacci from line 5, we declared two variables x and y, and initialized them to 0 and 1 respectively. 0 and 1 represent the first two numbers in a fibonacci series. We then set up an infinite for-loop on line 7 containing a select statement.
The first case statement checks for where a value (x) can be passed into channel ch without blocking, then executes a double assignment. The double assignment does the fibonacci calculation by adding two consecutive digits to form the next. The second case statement checks if quit contains a value, then exits fibonacci if a value is found. Since goroutine 1 is running alongside fibonacci (on main), let’s see what goroutine 1 is currently doing.
goroutine 1 declares a for-loop that runs n times. On each iteration, the current value in ch is received and printed out on line 26. Therefore, goroutine 1 blocks on receiving a value from ch except a value has been sent into ch on line 9 in fibonacci. This means that both goroutines (main and goroutine 1) depend on each other in the sense that main populates ch, while goroutine 1 receives from ch so another value can be sent into ch in the next iteration. The process terminates after the for-loop in goroutine completes its iterations, and line 28 is executed to send a value into quit. This terminates fibonacci on its next iteration main returns.
One of the major drivers of Go’s adoption is its ease of implementing concurrency. Many older programming languages were built without taking into cognition the possibility of executing multiple tasks at the same time. Though these languages now support concurrency, they don't have it built into the language like Go. Concurrency ultimately makes programs more efficient since a variety of tasks can be running at the same time.