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 yield keyword)

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 yield keyword.

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:

Mind Blown!

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!

Previous Post

Learning Go (Day 2)

Next Post

Tariffic!