Let’s introduce one of the stars ๐คฉ of our show. The humble goroutine, started
with the keyword go
. Don’t let it’s brevity fool you, it’s a very powerful
weapon โ๏ธ and the bread ๐ and butter ๐ง of concurrency.
func completeTasks() {
go makeDinner()
go doLaundry()
go watchLatestEpisode()
}
The above mirrors the tasks done in the introduction to concurrency, but in Go code form.
Goroutines declare different tasks ๐ฝ๏ธ ๐งบ๐บ they are going to finish to completion They do not have to execute in parallelโ They open the possibility to do so.
Though let’s not get ahead of ourselves. What is a goroutine? Well it’s actually a play on words ๐น There is something known as a coroutine that appears in certain languages. I’ll be ๐ฏ % honest and say that wiki link is kinda garbage for an explanation on what a coroutine is, so we can do better.
We have coroutines (from now on going to be called goroutines) ๐ and threads ๐งต they are similar because they both have their own work to complete, their own stack, their own variables, and their own pointer, but the difference is a thread ๐งต runs in parallel with other threads, and goroutines are collaborative ๐ค and are therefore concurrent.
Let’s say you have a CPU with 8 cores. You can have 8 threads ๐งต doing work at the same time. Each thread can have tons of goroutines ๐ All 8 threads are running at the same time, but only one 1๏ธโฃ goroutine is running on each of those threads. Therefore you have 8 threads and 8 goroutines running.
If we zoom in ๐ on 1 thread ๐งต we can see it has 1000 goroutines๐ and if we check only 1 goroutine is running but it gives up execution for one of the other 999 goroutines ๐ that needs it. By the way this is a simplification, so don’t take this to heart โค๏ธ We just want to capture the idea – Concurrency is not parallelism and goroutines do not run in parallel, but instead concurrently.
Setup
Let’s make our directory goroutine
and the files we want inside of
that directory example_test.go
goroutine.go
mkdir goroutine
touch goroutine/example_test.go goroutine/goroutine.go
Now let’s open up goroutine.go
and for the very first line we’ll add
package goroutine
Next for example_test.go
for the very first line we’ll add
package goroutine_test
We can import basics/goroutine
into cmd/main.go
and run functions
from there with go run cmd/main.go
and also to run our example_test.go
๐
we use go test goroutine/example_test.go
in the commandline.
Can’t stop, addicted to the… Async ๐
Now the first thing we need to understand about asynchronous programming, is what asynchronous means. And more importantly what it doesn’t mean. Asynchronous does not mean concurrent because a callback ๐ฑ can be given to a function and the program can sit around โ waiting for the results.
type Item struct {
// other values here
// ...
updated bool
}
// Fetch immediately returns, then fetches the item and does some work on it.
// The caller of `Fetch("some string", func (i Item) { ... })` will **wait**
// around for the Item to change before moving forward. Not concurrent.
func Fetch(name string, waitOnThisCallback func(Item)) {
go func() {
// grab an Item somehow with `name`
waitOnThisCallback(item)
}()
}
Concurrent programs do not wait ๐ When a goroutine is blocked it
relinquishes execution to another goroutine that needs to do its work ๐ This
is concurrency (grab โ more work when doing nothing), not asynchronicity (Make
a task execute on a different thread goroutine, for us gophers).
๐ค It still may be confusing so let’s see why async
is such a popular topic
in other languages, by looking at the benefits.
- Avoid blocking โ UI and network threads ๐งต
- Reduce idle threads ๐งต
- Reclaim stack ๐ frames
- Initiate concurrent ๐ work
In Go our goroutines are ๐ King/Queen ๐ Let’s see why none of the benefits are beneficial in Go.
- The runtime manages threads – there is no blocking; no need to touch the kernel to switch goroutines
- Goroutines are tiny ๐ค therefore idle goroutines are insignificant in comparison.
- Each goroutine has its own stack and variables exist as long as they have a reference, the stacks are managed for us and so is the space. Thanks Go! ๐
- This ๐ concurrency ๐ is literally what a goroutine does
You know what asynchronicity actually does? Create caller-side ambiguity ๐ (Great talk by Bryan C. Mills, definitely give it a watch!๐) and that’s why it should be avoided like the plague ๐คข At least in Go. Other languages have to use it because they have no choice ๐คท they have nothing better. The benefits above apply to their language. Go does have something better – goroutines.
This is not to say Go doesn’t have asynchronous programming. The word asynchronous and concurrent are very similar. It’s just good to know there’s a small difference. The devil’s ๐ in the details, as they say. Though in Go we often talk about concurrent work more than we do asynchronous work and that’s because…
Everything, and I do mean everything relies on goroutines in Go. The main
function of a Go program? func main() { ... }
That’s a goroutine. ๐คฏ Named
functions? Goroutine. ๐ฒ Anonymous functions? Goroutine. ๐ Closures?
Goroutine. ๐ It’s not surprising at all to see a Go application with
more than a 1000+ ๐ goroutines running concurrently…. I told you the support
is very good, didn’t I? ๐ธ
When spinning up another goroutine from the main goroutine. If the main goroutine exits, it cuts the execution short for the other goroutines. The main goroutine does not wait for the other goroutines to finish execution. We can think of the main goroutine like the trunk of a tree ๐ฒ if we cut ๐ช down the trunk ๐ชต the whole thing falls.
Coding Time!
goroutine.go
// WillNotWait shows us what it means to be asynchronous. In Go we spin up
// goroutines that have their own stack and their own goals to accomplish. It's
// very common to see 1000s of goroutines running in a go application!
func WillNotWait() {
// NOTE(jay): This will be seen if we run `go test` for `ExampleWillNotWait`
// it just won't be a part of the main goroutine's output because it exits.
go toofast()
}
func toofast() { fmt.Println("We'll never see this... without waiting") }
example_test.go
func ExampleWillNotWait() {
goroutine.WillNotWait()
// Output:
}
In Sync
The reason we have independent goroutines is so they can do work and when
they’re done, share their results. This requires some orchestration ๐ผ ๐ป ๐บ Go
has first class support with channels chan
but that’s in a coming lesson
(coming soon!)
Concurrency is a building block ๐งฑ to parallelism. It’s dangerous to think the more goroutines we throw into an application the faster it will perform. That’s like putting gas โฝ on a fire ๐ฅ hoping to put it out ๐ต
If we can do the work right now, don’t put it in a goroutine. Our last function? We too quickly tried to add another goroutine and found our first accidental lesson with concurrency. What we did was try to create another goroutine to do extra work โ in parallel โ, what actually happened was the main goroutine still had work to do and didn’t give up any time for that other goroutine to start doing its work. The main goroutine finished and shut โฐ๏ธ everything else down. So we learned Start goroutines when you have concurrent work to do now.
It’s dead simple to add concurrency into the program – go
– so when we see
there are tasks in the program that don’t rely on each other or could be
scheduled in such a way across threads then add concurrency.
Small aside – a really fun thing to look into is Amdahl’s Law ๐งโโ which says in theory
If 95% of the program can be parallelized, the theoretical maximum speedup with a CPU with 16 cores is 10 times the amount.
Ten times the amountโโ That’s amazingโ ๐ but that’s with 95% parallelization…. And 16 cores. For someone like me and for many programs it’d be something closer to 8 cores and 50%, which is still 2 times as fastโ In order to parallelize we first need to have concurrency and to have concurrency we need separate tasks and to separate those tasks we use goroutines.
In the meantime, we can play around with and understand goroutines better by artificially making our main goroutine take longer than any of our other goroutines we make. We will make the main goroutine wait ๐ ๐ ๐ which will signal to the scheduler that other goroutines can run right now as we wait on the main goroutine (concurrency in action). We will then see the output from the other goroutine we make! And with that we will have our very first success with creating a concurrent function ๐ฅณ๐๐ฅณ๐๐ฅณ๐
Coding Time!
goroutine.go
// SwitchToOther shows us how to artificially allow the goroutine we spawn to
// finish and exit, by slowing down the main goroutine. This is **not** how
// it's done in go. We use channels for true concurrency, but this is important
// to see before we introduce channels.
func SwitchToOther() {
go toofast()
// Make it wait 8 milliseconds to see separate goroutines output.
time.Sleep(8 * time.Millisecond)
}
example_test.go
func ExampleSwitchToOther() {
goroutine.SwitchToOther()
// Output:
// We'll never see this... without waiting
}
What’s a goroutine accept? ๐ค
The go
keyword spins up a new goroutine, but what can we put after go
? Well
any function. Whether it be a public ๐ฃ or private ๐named
function
, an anonymous function ๐ฅธ, a
closure
๐ฆช
or even
methods!
(All of these are
functions, so we could also just say go
takes any function).
The reason being, a goroutine needs to execute a set of instructions. If we
tried go 1
what instructions are we trying to do โ๏ธ It wouldn’t make any sense
to do that. So, when we have work that needs to be done, we do it in a separate
goroutine.
Coding Time!
goroutine.go
type async string
func (a async) myMethod() {
fmt.Println(a, "from a method: use in a new goroutine if you want!")
}
func AcceptableTypes(val any) {
// The `go` keyword needs a function and that is all, even if it is an
// anonymous function, it can still be used in a goroutine
go func(comingFrom string) {
fmt.Println("coming from:", comingFrom)
}("anonymous function goroutine")
go func(v any) {
switch t := val.(type) {
case string:
fmt.Printf("you chose %T: %s\n", t, t)
case int:
fmt.Printf("you chose %T: %d\n", t, t)
case bool:
fmt.Printf("you chose %T: %t\n", t, t)
case float64:
fmt.Printf("you chose %T: %f\n", t, t)
case []struct{}:
fmt.Printf("you chose %T: %#v\n", t, t)
default:
fmt.Printf("What is this? ๐ %T: %#v\n", t, t)
}
}(val)
a := async("My cool new type ๐")
go a.myMethod()
go SwitchToOther()
// NOTE(jay): We have to wait (`time.Sleep`), because the main goroutine will
// shutdown other goroutines and exit immediately. Comment out ๐ to see
time.Sleep(8 * time.Millisecond)
fmt.Println("๐๐๐ Time to exit")
fmt.Println()
}
example_test.go
func ExampleAcceptableTypes() {
// XXX(jay): This is going to fail!!!! Remember -- Goroutines **do not
// execute in the order they are in a function** They execute asynchronously
// (not line by line). We may get lucky and have this pass every so often,
// but it's not guaranteed!
goroutine.AcceptableTypes([]struct{}{{}, {}, {}})
goroutine.AcceptableTypes(struct {
name string
age int
}{"Gary", 900})
// Output:
// We'll never see this... without waiting
// My cool new type ๐ from a method: use in a new goroutine if you want!
// coming from: anonymous function goroutine
// you chose []struct {}: []struct {}{struct {}{}, struct {}{}, struct {}{}}
// ๐๐๐ Time to exit
//
// coming from: anonymous function goroutine
// We'll never see this... without waiting
// My cool new type ๐ from a method: use in a new goroutine if you want!
// What is this? ๐ struct { name string; age int }: struct { name string; age int }{name: "Gary", age:900}
// ๐๐๐ Time to exit
}
No Deterministic Order
We saw from the previous example that the test we made fails 9 times out of 10. It’s only when the stars align โญ โญ โญ and the goroutines finish in the order they were created will it pass. Let’s really drive ๐ that point home with one more example.
It’s very important to understand these fundamentals of goroutines. In understanding goroutines we understand asynchronicity. In understanding goroutines we understand concurrency. Every tool ๐ ๏ธ has a purpose in your toolkit ๐งฐ Concurrency is no different. Goroutines are no different. They are not the end all be all. They are the bees knees ๐ and the cat’s meow ๐ฑ but they won’t solve your life. They just make concurrency trivially easy ๐ in Go.
Coding Time!
goroutine.go
// NoOrder shows that asynchronous truly means there is no determined order.
// That the goroutines are not in sync and are not in serialized order. We
// spawn several goroutines and without sorting the outputs **this output will
// never be deterministic** this means the order is determined by which
// goroutine got time scheduled first.
func NoOrder() {
for i := 0; i < 3; i++ {
go processData(fmt.Sprintf("goroutine%d", i))
}
go processData("goroutine3")
go processData("goroutine4")
go processData("goroutine5")
time.Sleep(3 * time.Millisecond)
}
func processData(comingFrom string) { fmt.Println("coming from:", comingFrom) }
example_test.go
Use go test -run NoOrder
to just see the output for the NoOrder
example.
func ExampleNoOrder() {
// XXX(jay): This is going to fail!!!! Remember -- Goroutines **do not
// execute in the order they are in a function** They execute asynchronously
// (not line by line). We may get lucky and have this pass every so often,
// but it's not guaranteed!
goroutine.NoOrder()
// Output:
// coming from: goroutine5
// coming from: goroutine0
// coming from: goroutine1
// coming from: goroutine2
// coming from: goroutine3
// coming from: goroutine4
}