Welcome back to the series tutorials on Go Concurrency. If you missed the previous tutorials please refer to: Go Concurrency with GoRoutines.
In this tutorial, we will help you understand the concept of Channels.
With concurrency programming, communication in a memory sharing environment plays a vital role in synchronisation across your programme. In Go, Channels area built-in feature for synchronisation purposes. They mainly act as a data transfer pipeline that GoRoutines can either send to or read from. Hence, each GoRoutine (including main() has access to the same shared memory and this becomes particularly useful when there are multiple GoRoutines. Let's dive into a simple example.
package main import ( "fmt" ) func main() { c := make(chan string) go countCat(c) for i := 0; i < 5; i++ { message := <- c fmt.Println(message) } } func countCat(c chan string) { for i := 0; i < 5; i++ { c <- "Cat" } }
Firstly, we create a new channel c with make() where we assigned "string" as the data type which c receives from. Note that a channel can only be assigned one data type. The GoRoutine gocountCat() sends "Cat" into c with the syntax c<-"Cat" whereas message receives from c with the syntax <-c in the main GoRoutine, followed by printing the variable. In this example, countCat() is the sender whereas main() is the receiver/consumer. We are sending 5 strings of "Cat" into the channel and subsequently printing all 5 "Cat" in main(). Channel operation (i.e. write or read) are blocking in nature.
This means:
Deadlock errors occur when all GoRoutines are all blocked or "sleeping".
Let's make a small change to the code and discuss in more detail about Channels.
package main import ( "fmt" ) func main() { c := make(chan string) go countCat(c) for i := 0; i < 4; i++ { message := <- c fmt.Println(message) } } func countCat(c chan string) { for i := 0; i < 5; i++ { c <- "Cat" } }
This time the main function only consumes part of data in the Channel (i.e. the for loop in main() only has 4 steps whereas the for loop in countCat() has 5 steps). Can you spot the potential problems in this script?
Yes, you are right, the countCat GoRoutine actually has been blocked forever because the last "Cat" sent into the channel is not consumed by any other GoRoutines. The reason we do not see deadlock here is because the main() GoRoutine exits immediately after printing four "Cat". Let's make this example more clear with the help of WaitGroup.
package main import ( "fmt" "sync" ) func main() { c := make(chan string) wg := sync.WaitGroup{} wg.Add(1) go countCat(c, &wg) for i := 0; i < 4; i++ { message := <- c fmt.Println(message) } wg.Wait() } func countCat(c chan string, wg *sync.WaitGroup) { for i := 0; i < 5; i++ { c <- "Cat" } wg.Done() }
This time, you will face a deadlock because the main() GoRoutine is asked to wait until the go countCat is done. However, countCat is never done as we explained earlier due to the fact that Channel operations are blocking the GoRoutines. More specifically, the last GoRoutine of countCat() at the 5th step (i.e. c <-"Cat") was blocked and not consumed by main () GoRoutine since it stopped at the 4th step. Hence, wg.Done() in countCat() was never executed.
Alternatively, we can make the main() to be the sender and other GoRoutines to be the receiver.consumer (see example below). In this case, you must put the receiver GoRoutines before you send data in main(), otherwise deadlock will happen.
package main import ( "fmt" ) func main() { c := make(chan string) go countCat(c) for i := 0; i < 5; i++ { c <- "Car" } } func countCat(c chan string) { for i := 0; i < 5; i++ { message := <- c fmt.Println(message) } fmt.Println("done") }
Next, we have a simple example that is a common mistake often made by beginners. Let's have a look at it.
package main import "fmt" func main(){ c := make(chan string) c <- "Cat" message := <- c fmt.Println(message) }
This code will produce a deadlock because it tries to send and receive data from Channel C within the same GoRoutine which is main() in this case. Since Channel operations are blocking the GoRoutine, the main() GoRoutine stops at c<-"Cat" forever since it's blocked and it would never reach the line message:= <-c. The simple workaround for this problem is to use a buffered channel. The buffered channel will not block the GoRoutine unless it is full.
package main import "fmt" func main(){ c := make(chan string, 1) c <- "Cat" message := <- c fmt.Println(message) }
In this example, we make a buffered channel with the size equal to 1. In this case, the channel can hold one data and will not block main() so that we can proceed to the line message:= <-c. However, if we try to spend more than one data to the channel before we receive data from it, deadlock will happen again because the size of this buffered channel is only 1 (see example below).
package main import "fmt" func main(){ c := make(chan string, 1) c <- "Cat" c <- "Cat" message := <- c fmt.Println(message) }
The last part of this tutorial is about closing channels and using range to loop through channels.
package main import ( "fmt" "sync" ) func main() { c := make(chan string) wg := sync.WaitGroup{} wg.Add(1) go countCat(c, &wg) for message:= range c { fmt.Println(message) } wg.Wait() } func countCat(c chan string, wg *sync.WaitGroup) { for i := 0; i < 5; i++ { c <- "Cat" } wg.Done() close(c) }
In this example, we use close(c) to close the channel in countCat after we finish sending all data to the channel. In other words, we are telling the channel that there will be no more data sent into this channel. In main(), we use range instead of a specific number of iterations to receive data from the channel. The range syntax will detect if the channel has been closed or not. Otherwise, it will produce a deadlock if we do not close the channel in the sender GoRoutine while using range.
Next week, we are going to talk about one of the most critical elements of Concurrency Programming which is Mutex. The concept of Mutex exists widely in computer programming and we are going to look at it from the GoLang perspective.
Stay tuned!