Loading...
Loading...
Marco Franssen

If you're building software that needs concurrency, Go is worth a look: it's designed with concurrency in mind. We can run work concurrently using goroutines. As a simple introduction for readers with c# and Java backgrounds, think of a goroutine as a lightweight thread. Experienced Gophers, please don't take that comparison literally! I know goroutines and threads aren't the same, but it's an easy way to set the context.
If this is your first time working with Go, I also wrote a post on setting up your development environment. Once you're settled, continue here to learn about concurrency, parallelism, and channels for communicating between goroutines. Oh, and there are plenty of examples!
As I mentioned, a goroutine has a smaller footprint than a thread in languages such as C# or Java. Threads consume approximately 1 MB of memory because of their larger stack size, while a goroutine starts with only a fraction of that: 2 KB. Goroutines let us achieve concurrency as well as parallelism. Before we dive in, let's clarify the difference.
Concurrency != Parallelism
Concurrency can enable parallelism, but running work in parallel requires multiple processor cores. In general, four cores let us do four things in parallel. The GOMAXPROCS environment variable lets you limit parallelism in your Go program. For example, GOMAXPROCS=2 limits parallel execution to two threads, even if your system has eight cores. This lets you test behavior on machines with fewer cores, or disable parallelism with GOMAXPROCS=1. We can also get the number of available CPUs in our code using the runtime package.
Let's look at an example that runs entirely in main, without introducing concurrency or parallelism:
package main
import (
"fmt"
"time"
)
func main() {
beep()
}
func beep() {
for i := 0; ; i++ {
fmt.Println("beep", i)
time.Sleep(time.Second)
}
}This example keeps beeping every second until you quit the program. Try it in the playground. Now let's run the beep function in a goroutine by putting go in front of our beep function call.
go beep()Run it again. You'll notice that nothing is printed: the program exits immediately because we don't wait for the goroutine. Let's use a poor man's example to show that making main wait for two seconds gives the goroutine time to print two messages.
package main
import (
"fmt"
"time"
)
func main() {
go beep()
fmt.Println("Listening for the beeps")
time.Sleep(2 * time.Second)
fmt.Println("That Beep is anoying, quit listening!")
}
func beep() {
for i := 0; ; i++ {
fmt.Println("beep", i)
time.Sleep(time.Second)
}
}In this example, the beep runs for two seconds before the program ends. All goroutines stop when the program exits, so if main doesn't block or wait in some way, a goroutine might never run. Goroutines aren't guaranteed to start immediately. Now let's improve this by using channels to communicate between the two goroutines.
A channel in Go lets goroutines communicate. You can think of it as a queue: a goroutine publishes messages at one end, and another consumes them at the other end. Channels handle the synchronization that you'd otherwise need to manage between these operations. Let's define a channel that receives messages of type string.
beeps := make(chan string)We can send to the channel with beeps <- "Bleep" and receive from it with <-beeps. Let's change our original example to communicate through a channel.
package main
import (
"fmt"
"time"
)
func main() {
beeps := make(chan string)
go beep(beeps)
fmt.Println("Listening for the beeps")
for i := 0 ; i < 5 ; i++ {
fmt.Printf("We received: %s\n", <-beeps)
}
fmt.Println("That Beep is anoying, quit listening!")
}
func beep(beeps chan string) {
for i := 0; ; i++ {
beeps <- fmt.Sprintf("beep %d", i)
time.Sleep(time.Second)
}
}We've changed the beep function to send a formatted string to the channel instead of printing it. In main, a loop receives five messages from the channel. Go handles the synchronization between the send and receive operations, and main prints each message. Sending to a channel looks a little like assigning a value to a variable: beeps <- "Awesomeness". To receive, we use <-beeps, which takes the next value from the channel. Here, we pass that value straight to a string-formatting function. You could also assign it to a variable, like this: lastBeep := <-beeps.
Another thing to highlight: we created an unbuffered channel. On a single CPU core, the program pauses the sending goroutine so the other goroutine can receive the value and print it. Switching between the two goroutines involves context switching. You might not notice this on a multicore system, but you can set GOMAXPROCS=1 to try it with one core. I'm not sure we'll see much difference here, since the example isn't particularly CPU-intensive.
Imagine a more complex program where synchronization and context switching take longer than a nanosecond, and we don't need to print each message immediately. We could use a buffered channel to hold messages before the sender has to wait for the receiver. This lets the publishing goroutine keep going until the buffer is full, while the consuming goroutine may receive messages later. Again, that's a poor man's explanation, so bear with me and don't take it too literally.
beepsBuffered := make(chan string, 1024)Finally, I'd like to show you an example that waits until the user exits the program. We'll add another channel and replace the for loop with a select statement to handle messages from either channel.
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
beeps := beep()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGKILL)
fmt.Println("Listening for the beeps")
for {
select {
case message := <-beeps:
fmt.Printf("Message received: %s\n", message)
case <-quit:
fmt.Println("That Beep is anoying, quit listening!")
return
}
}
}
func beep() <-chan string {
beeps := make(chan string, 1024)
go func() {
for i := 0; ; i++ {
beeps <- fmt.Sprintf("beep %d", i)
time.Sleep(10 * time.Millisecond)
}
}()
return beeps
}Unfortunately, this code won't run in the playground because of its limitations, so you'll have to try it on your own machine. The second channel has type os.Signal and a buffer size of one. We want the program to quit as soon as possible, without waiting for more signals. Try ctrl+c, which is one of the signals we want to receive on the quit channel. Another option is to stop the process with kill <process_id>. This time, I also buffered the beep channel because I don't mind receiving the beeps with a small delay. I've used a generator function that returns a receive-only channel and starts the beeps at a faster pace.
Try running this example locally, limiting it to a single CPU core:
mkdir go-concurrency
go mod init github.com/marcofranssen/go-concurrency
// Put above code in main.go
GOMAXPROCS=1 go run main.goWith buffering, the program may not immediately receive every message from the channel when running on one processor. In general, if you want goroutines to synchronize at each send and receive, use an unbuffered channel. If a small delay is fine, buffering may improve performance under high load.
In the following video, Rob Pike explains concurrency and parallelism in more detail. I also recommend this presentation.
For more on goroutines, take this tour. Thanks for sticking with me all the way to this last sentence!
Marco Franssen
Explore implicit interfaces and type assertions in Go, with practical examples of custom error types, cleaner tests and type switches.
Marco Franssen
Learn Go test and benchmark conventions with a JSON example, then explore code coverage, Testify assertions and options for comparing performance.
Marco Franssen
Explore Go's defer with practical examples of execution order, panic and recover, recursive calls, and closing a file after reading its contents.
Marco Franssen
Set up your first Go project on Windows, learn basic syntax, write and run tests, and cross-compile a simple application for Linux and Raspberry Pi.