Learning Go (Day 3)
I covered several different topics on Day 3, including:
- Patterns around Error Handling.
- Creating and Publishing Modules.
- Additional Tooling for Go. (Ex: static analyzers)
- GoRoutines, Channels,
select, Context, and WaitGroups.
Here are my thoughts:
No official package registry?
Go does not have an "official package registry" like Python or Javascript. Instead, Go uses a decentralized approach where packages are downloaded from source code repositories. Additionally, it provides various services such as https://pkg.go.dev/ to help developers discover packages.
This mostly seems fine, but what recourse does the community have
when somebody "publishes" a malicious package? Many development
teams lack robust Continuous Integration,
so relying upon tooling such as govulncheck
seems a bit risky. Is there a way for the community to flag malicious
packages and protect developers from downloading them unintentionally? 🤔
GoRoutines and Generators
Generators are one of my favorite features in Python
and Javascript!
They make it a lot easier to split data processing pipelines into
smaller units of work, and they also served as the original foundation
for concurrent programming (ex: async/await) in both languages.
At a glance, GoRoutines look like Generators:
- Both enable concurrent programming.
- Both (can) temporarily yield control whenever they write to or
read from a channel. (Note: Generators accomplish this via the
yieldkeyword)
However, GoRoutines have some advantages:
- GoRoutines allow for parallel execution. Javascript itself does not support parallel execution within user code, and therefore its Generators cannot execute in parallel, either. Python's Generators can theoretically be parallelized, but the Global Interpreter Lock largely ruins this.
- GoRoutines allow for any number of distinct channels. In contrast,
Generators are effectively restricted to a single channel due to
their reliance upon the
yieldkeyword.
But, don't worry! Generators also have some advantages over GoRoutines!
- Generators will automatically be terminated and garbage collected once they are no longer referenced. In contrast, GoRoutines cannot be assigned to a variable, which means that this automatic cleanup is not possible in Go. Instead, developers must ensure that their GoRoutines are terminated properly to avoid memory leaks.
- Generators are a very standard and convenient way to implement iterators. It's possible to implement iterators using GoRoutines, but this practice is discouraged unless the iteration entails concurrent work of some kind. (Presumably because GoRoutines introduce additional complexity and overhead)
select statements
This was me when I saw my first select statement:
This language feature solves so many problems related to
asynchronous programming! For starters, it completely eliminates
the need to distinguish between blocking and non-blocking channel
operations. If you want the channel operation to be non-blocking,
then you can wrap it with a select statement with a default case:
func main() {
var xChannel = MakeIntCounter()
for {
select {
case x := <-xChannel: // Executes when xChannel is ready
fmt.Println(x)
default: // Executes when xChannel is blocked
fmt.Println("No channels are ready. Waiting 1 second...")
time.Sleep(time.Second)
}
}
}
You can monitor multiple channels simultaneously by specifying
multiple case statements within the select block:
func main() {
var xChannel = MakeIntCounter()
var yChannel = MakeIntCounter()
for {
select {
case x := <-xChannel: // Executes when xChannel is ready
fmt.Printf("x: %d\n", x)
case y := <-yChannel: // Executes when yChannel is ready
fmt.Printf("y: %d\n", y)
default: // Executes when all channels are blocked
fmt.Println("No channels are ready. Waiting 1 second...")
time.Sleep(time.Second)
}
}
}
But wait — there's more! In many languages, it's easy to accidentally introduce a deadlock by accessing resource locks in an inconsistent order. For example, the following code in Go intentionally creates a deadlock:
func main() {
var ch1 = make(chan string)
var ch2 = make(chan string)
go func() {
ch1 <- "Hello from GoRoutine!" // Blocks until ch1 is read
fmt.Println(<-ch2)
}()
ch2 <- "Hello from main!" // Blocks until ch2 is read
fmt.Println(<-ch1)
}
However, Go's select statements (continually?) evaluate
their case blocks in a random order, thus sidestepping the problem:
func main() {
var ch1 = make(chan string)
var ch2 = make(chan string)
go func() {
select {
case ch1 <- "Hello from GoRoutine!":
case msg := <-ch2:
fmt.Println(msg)
}
}()
select {
case ch2 <- "Hello from main!":
case msg := <-ch1:
fmt.Println(msg)
}
}
Cleanup
Consider the following:
// Returns a channel that increments its underlying value
// once per second.
func MakeIntCounter() <-chan int {
var ch = make(chan int)
go func() {
for x := 0; ; x++ {
// Emit the current value of `x` if `ch` is ready
select {
case ch <- x:
default:
}
fmt.Println("tick...")
time.Sleep(time.Second)
}
}()
return ch
}
func doStuff() {
var ch = MakeIntCounter()
for x := range ch {
fmt.Println(x)
if x >= 10 {
fmt.Println("That's enough! I'm done!")
return
}
}
}
func main() {
doStuff()
fmt.Println("Sleeping 5 seconds before terminating...")
time.Sleep(5 * time.Second)
fmt.Println("Terminating!")
}
When I ran this program, it produced the following output:
tick...
0
tick...
1
tick...
2
tick...
3
tick...
4
tick...
5
tick...
6
tick...
7
tick...
8
tick...
9
tick...
10
That's enough! I'm done!
Sleeping 5 seconds before terminating...
tick...
tick...
tick...
tick...
Terminating!
Uh oh... the GoRoutine continued running even after doStuff()
returned! What happened?
This is an example of the memory leak that I alluded to earlier in
this post. Since GoRoutines cannot be referenced, there's really no
way for them to fall out of scope and be terminated automatically.
Instead, it's the developer's responsibility to inform the GoRoutine
that it needs to terminate. So, let's address this by utilizing Context:
// Returns a channel that increments its underlying value
// once per second.
func MakeIntCounter(ctx context.Context) <-chan int {
var ch = make(chan int)
go func() {
for x := 0; ; x++ {
// Emit the current value of `x` if `ch` is ready
select {
case ch <- x:
case <-ctx.Done():
fmt.Println("Terminating the GoRoutine!")
close(ch)
return
default:
}
fmt.Println("tick...")
time.Sleep(time.Second)
}
}()
return ch
}
func doStuff() {
var ctx, cancel = context.WithCancel(context.Background())
// Ensure `ctx` will get cancelled when `doStuff()` returns
defer cancel()
var ch = MakeIntCounter(ctx)
for x := range ch {
fmt.Println(x)
if x >= 10 {
fmt.Println("That's enough! I'm done!")
return
}
}
}
func main() {
doStuff()
fmt.Println("Sleeping 5 seconds before terminating...")
time.Sleep(5 * time.Second)
fmt.Println("Terminating!")
}
Now the output looks like this:
tick...
0
tick...
1
tick...
2
tick...
3
tick...
4
tick...
5
tick...
6
tick...
7
tick...
8
tick...
9
tick...
10
That's enough! I'm done!
Sleeping 5 seconds before terminating...
Terminating the GoRoutine!
Terminating!
Looks good 2me! 🥳
Wrapping Up
I'm officially sold on Go now after seeing the power of
the select statement! At this point, I'm excited to dive in and
begin building an application of some kind!