So in my never ending quest for learning the teeny little bits about GoLang since it seems like that is the direction my career is going.
(tl;dr: first it was Java, then it was Rails, now it seems Go is the new "good to learn because every company uses it" language)
So far I've really enjoyed these things about Go:
- Being able to attach methods to structs, making it sort of Object Oriented, but not really at the same time. It's a nice blend of procedural and OO.
- Functions are first-class citizens, e.g. I can pass functions around as arguments or declare a type as a function signature (see: https://pkg.go.dev/net/http#HandlerFunc).
- Channels! (today's topic)
Channels are a pretty highly debated topic in the Go community, the most notable complaints (that I've thought about at least) being the difference between a buffered/unbuffered channel and how that blocks sending/receiving with the channel.
Before going into it I'll explain the difference between the two.
Buffered Channels
A buffered channel is a channel that only has x number of slots, which are set when the channel is declared. e.g: ch := make(chan int, 3)
declares a channel with 3 slots in it. It can be looked at almost like a stack, it does not block until the channel is full. One can produce asynchronously until the channel is full.
Here is a code example illustrating this https://play.golang.org/p/vBjzgqhIiaZ I'll put it inline here too:
package main
import (
"fmt"
"os"
"time"
)
func main() {
// Make a channel with 2 slots
ch := make(chan int, 2)
go consume(ch)
for i := 0; i < 5; i++ {
fmt.Fprintf(os.Stdout, "producing int %d\n", i)
ch <- i
}
// sleeping to let the last 2 get consumed.
time.Sleep(500 * time.Millisecond)
}
func consume(ch chan int) {
fmt.Println("sleeping 3 sec...")
time.Sleep(3 * time.Second)
for {
fmt.Fprintf(os.Stdout, "Got %d\n", <-ch)
}
}
Basically this program does a few things:
- Creates a buffered channel with 2 slots
- Starts a consuming goroutine to consume from the channel
- Produces 5 messages to the channel, outputting when it is trying to produce
- The sub-thread consumes as it can, but the main thread can only produce two ints before hanging on the third
- The sub-thread consumes after three seconds and the program runs to completion
Now this seems pretty simple since it is like writing to a stack or queue that has a limit. Things start to get interesting once we move onto...
Unbuffered Channels
An unbuffered channel is much the same as a buffered channel minus the fact that it blocks on consume until the message is received for every message (unless the channel is closed). This can be looked at as a pipe that can just continually be written to or read from, with
Here is a code example again, https://play.golang.org/p/ODGQpNfv2lo, but I will put it inline as well:
package main
import (
"fmt"
"os"
"time"
)
func main() {
ch := make(chan int)
go consume(ch)
for i := 1; i <= 5; i++ {
fmt.Fprintf(os.Stdout, "producing int %d\n", i)
ch <- i
}
// sleeping to let the last 2 get consumed.
time.Sleep(500 * time.Millisecond)
}
func consume(ch chan int) {
count := 1
for ; ; count++ {
if count%3 == 0 {
fmt.Println("TOO FAST, taking a break. Sleeping 3 seconds")
time.Sleep(3 * time.Second)
}
fmt.Fprintf(os.Stdout, "Got %d\n", <-ch)
}
}
This one is a bit harder to understand so it may be better to just run and inspect the output as you read the steps I have below.
- Like before, making a channel (unbuffered) and starting a consumer goroutine.
- Immediately start producing to the messages, the consumer thread receives 2 of them, then decides to take a break for 3 seconds, which causes the main thread to hang.
- Once the sleeping is done the consumer catches up and consumes everything left by the main thread as the loop runs to completion.
Personally, unbuffered channels are much easier to understand IMO. If one goroutine produces a message to a channel, it hangs until a different goroutine picks it up. Easy.
Unbuffered channels are the nicest from the point of view that they can have multiple routines consuming them, so it is easy to scale batch processing etc. Here is a code example where I spin up five workers to consume off of the same channel! https://play.golang.org/p/xeXb_1a7YUn
package main
import (
"fmt"
"os"
"time"
)
func main() {
ch := make(chan int)
go consumeMultiple(1, ch)
go consumeMultiple(2, ch)
go consumeMultiple(3, ch)
go consumeMultiple(4, ch)
go consumeMultiple(5, ch)
for i := 1; i <= 5; i++ {
fmt.Fprintf(os.Stdout, "producing int %d\n", i)
ch <- i
}
time.Sleep(500 * time.Millisecond)
}
func consumeMultiple(id int, ch chan int) {
fmt.Fprintf(os.Stdout, "Consumer %d got %d\n", id, <-ch)
}
So what was the point of this blog post other than a channel tutorial?
- I actually really enjoy the fact that you can range over channels so 3rd party (or your own!) libraries can just return a channel which easy to loop over. We do that for kafka consumers at work.
- A buffered channel is a pretty easy way to limit the number of workers on a system. Rather than using
sync.WaitGroup
another way is just to use a buffered channel since the channel will block on send once the channel is full, thus limiting the number of "in-flight" workers. Of course the other way is just to use a buffered channel and fire upn
workers based on a configuration or something. - Now I'm thinking about
sync.WaitGroup
again. I should write something about it. I enjoy a LOT that it will block until workers are completed (would be nice for the example snippets I have here). - Channels feel sort of actor-ey to me, much like Erlang/Elixir processes that receive messages. Though processes over there are buffered with a default "mailbox size" that is the size, and it will overflow and crash if too many messages are waiting to be processed. But it is the same concept. It's nice to be able to create a worker thread in Go that kinda just does things and maintains its own state (read as: I like actors and you should too).
Anyways, thanks for reading my ramblings. I'm over 1k words already so I should probably end this one here!