Golang Routines and Channels

Introduction:

Golang is a free and open source programming language created by Google in 2007. It is a popular language choice for building microservices and we use it extensively here at Hootsuite along with Scala. What makes Golang such a powerful language is its efficiency and scalability. Go compiles to binary and doesn’t depend on virtual machines (i.e JVM for Java). It’s built as an alternative to C and C++ so it’s fast and highly efficient but has garbage collection and memory safety features. My personal favourite feature of Golang are the routines and channels and how easy it is to use them. A goroutine is a function running independently in the same address space as other goroutines. They are a bit like threads but much much cheaper. Communications between goroutines is done using go channels. With these two features, you can build powerful concurrent programs that scale.

Goroutines and Channels:

So how easy is it to write concurrent programs in Golang? You can spawn a new goroutine simply using the go keyword:
package main

import ( "fmt" "time" )

func myFunc(done chan string) { for i := 0; i < 10; i++ { time.Sleep(time.Millisecond * 500) fmt.Println(i, " myFunc") } fmt.Println("finished loop in myFunc") done <- "goroutine finished" // send the message into the channel }

func main() { done := make(chan string) // make the "done" channel go myFunc(done) // run myFunc on a goroutine for i := 0; i < 5; i++ { time.Sleep(time.Millisecond * 500) fmt.Println(i, " main") }

msg := <- done // receive from the channel fmt.Println(msg) }

In this simple program, myFunc() runs concurrently with main(), and the “done” channel is used for communication between the two routines and synchronization. Run this program and see what it prints(https://play.golang.org/)! You should expect to see myFunc and Main numbers interleaved as they are running concurrently.

The above example uses the channel as a blocking receive to wait for the myFunc() goroutine to finish. If that channel wasn’t there, the program would exit right after the main() for loop finishes, and we would never finish the rest of the loop execution in myFunc() (try commenting out the last two lines of the code and running it). The presence of the receive channel (<- done) in main() prevents the program from existing until a notification is received. When the for loop in myFunc() finishes, it will pass the message “goroutine finished” into the “done” channel, that message is then received in the main() and program is allowed to exit.

That was a pretty simple example of a go routine and channel usage, I won’t go too much into syntax but for those that are interested, https://gobyexample.com/goroutines provides great examples of the concepts.

Now that we have covered some of the basics, lets see how we use goroutines in a real microservice here at Hootsuite.

Goroutines in Interaction History Service:

Interaction history is a feature that allows a Hootsuite user to view all their past interactions with another user for a social network. When we show the interaction history for Twitter, we would display all the mentions, likes, quotes,  and retweets that happened between the two users. So every time any of these events occur, our service has to store it. Our service consumes roughly 1 million of these events a day, and we handle this heavy lifting with goroutines and channels.

At Hootsuite, we have an “Event Bus” that is built using Apache Kafka which is responsible for distributing real-time events. Using the Sarama go client for Kafka,  we are able to subscribe to topics on the Event Bus which we are interested in such as Twitter mentions, likes, quotes etc, and get these events real time which we receive through channels.

Here is the simplified/modified version of the function we have for consuming events:

func consumeMessages(consumer *saramaCluster.Consumer) {
   for w := 0; w < 400; w++ {
      go func() {
         // infinite for loop
         for {
            select {
            case msg := <-consumer.Messages(): // consumer.Messages() returns a read channel where new messages will be passed through
                saveMessageToDB(msg)
case err := <-consumer.Errors(): // consumer.Errors() returns a read channel where error messages will be passed through logError(err) } }() } }
Lets take a look at what’s happening here. We constructed 400 goroutines, each running an infinite for loop that is waiting for message to come back from the channels. Go’s “select” lets us switch between messages received over a channel. If we received a message from the Message channel, we save that message to our database, but if we received a message from the Errors channel, we log the error.

What if we had only spawned 1 goroutine instead of 400 to achieve this task? The processing time for these messages isn’t very long so if there is no other possible delays, we might not even notice a difference. However, in a real service, we often experience network delays, and in our case, delays reading and writing to the database. When these delays are happening, another goroutine can jump in and start processing another message. These context switches happen so fast, it almost seems like things are happening in parallel. With 400 goroutines each ready to process messages, we are able to handle the heavy traffic that comes over from the event bus.

The Power of Goroutines

So how were we able to construct 400 goroutines without suffering any consequence? It is actually pretty common for a single go program to create thousands of goroutines. The key here is that goroutines are not threads, they are much lighter weight. In Go, when you block the current execution, you only block the current goroutine, not the thread. You can have thousands of goroutines running concurrently on a single thread and they will be able to context switch efficiently. Goroutines are lighter weight than OS threads because they use little memory and resources. Their initial stack size is also small but has the ability to grow as needed. And if you ever want to run go routines in parallel,  Go provides the ability to add more logical processors via the GOMAXPROCS environment variable or runtime function.

 

About the Author

Colby Song is Software Developer Co-op at Hootsuite on the Engagement Team. Colby studies Computer Science at UBC. Connect with him on LinkedIn