The 4-chan Go programmer

GOLANG
4 min read

Introduction

We're using Go to write Dolt, the world's first version-controlled SQL database. Like most Go codebases, we use channels and goroutines to implement concurrent execution. Usually we use these constructs in the most boring and straightforward way possible, because concurrent programming is hard enough without trying to be clever. But at one point we inherited some code from another open source project that used channels in a very clever way: it used them to send additional channels.

var c chan chan struct{}

This is a channel that sends another channel, which then sends a struct. It's basically a way to pass channels between different goroutines, to implement some fan-out pattern among worker goroutines. Think of the "middle" channel as the middleman in the workflow: its job is to pass new channels as they are produced down to workers who actually do useful work. It did work, but it's the kind of overly clever idea that was hard to reason about and work with, especially once you consider goroutine leaks. We rewrote it, and that chan chan struct{} is long gone.

But it did get me thinking. How far can you take this dumb idea? Behold the 4-chan:

_4chan := make(chan chan chan chan int)

Why are you doing this

There's an old programming joke from back in the days when C and its derivatives dominated the landscape. A lot of people had a very hard time understanding pointers, probably because they didn't have this meme back then.

pointers

Because pointers were necessary to do many useful things in C, and relatively hard to understand for a lot of people, newbies would sometimes do very silly things with them, like declaring variables like this:

int****

And these novices didn't realize what a silly thing this is to do, thinking instead that getting a working program that used this many layers of pointer indirection was actually a sign of expertise. Such unfortunate newbies were called "4-star programmers".

Because Go is also largely derived from C, you can do the same thing in Go:

func main() {
	i := 1
	setInt(&i)

	fmt.Printf("i is now %d", i)
}

func setInt(i *int) {
	setInt2(&i)
}

func setInt2(i **int) {
	setInt3(&i)
}

func setInt3(i ***int) {
	setInt4(&i)
}

func setInt4(i ****int) {
	****i = 100
}

This compiles and prints i is now 100. You too can be a 4-star Go programmer, it's just that easy.

But we can take this one step farther, and use a construct that Go has but C does not: channels. Declared in code as chan.

4 chan

You see where I'm going with this.

The 4-chan Go programmer

The basic idea is that we'll write a program that uses 4 layers of channel indirection to complete some task. Our top-level channel has to be a 4-chan, so we declare it like this:

_4chan := make(chan chan chan chan int)

(It's somewhat irritating for the purpose of this gag that Go doesn't let you begin an identifier with a numeral, but that's life).

The values we send on that channel are 3-chans, like this:

_3chan := make(chan chan chan int)

And so on, down to the lowly int channel itself.

At each layer of indirection, we'll spawn producers according to some constant branching factor:

func sendChanChanChan(c chan chan chan chan int) {
	for range factor {
		go func() {
			logrus.Debug("starting 3chan producer")
			_3chan := make(chan chan chan int)
			sendChanChan(c, _3chan)
		}()
	}
}

And the same thing for consumers:

func receiveChanChanChan(c chan chan chan chan int) {
	for _3chan := range c {
		logrus.Debug("got message from 4chan")
		for range factor {
			logrus.Debug("starting 3chan consumer")
			go receiveChanChan(_3chan)
		}
	}
}

Finally we hit the bottom of the stack, where we send an actual value instead of a channel for values.

func send(_2chan chan chan int, _1chan chan int) {
	_2chan <- _1chan
	for range factor {
		go func() {
			logrus.Debug("starting int producer")
			for range factor {
				go func() {
					logrus.Debug("sending int")
					_1chan <- 1
				}()
			}
		}()
	}
}

For the consumers, we have to do something with the values we receive. Let's add them together.

var sum = &atomic.Int32{}

func receive(c chan int) {
	for s := range c {
		logrus.Debug("received int")
		sum.Add(int32(s))
	}
}

Now let's put it all together:

const factor = 3

var sum = &atomic.Int32{}

func main() {
	// logrus.SetLevel(logrus.DebugLevel)

	_4chan := make(chan chan chan chan int)

	go sendChanChanChan(_4chan)
	go receiveChanChanChan(_4chan)

	time.Sleep(500 * time.Millisecond)

	fmt.Printf("%d ^ 5: %d", factor, sum.Load())
}

This prints 3 ^ 5: 243. Which is correct! This program is a fully generalized way to compute a number's fifth power in a maximally distributed way. You can play with it here (or view with syntax highlighting on GitHub here). For larger factors you may need to increase the Sleep duration for it to run.

If you enable logging by uncommenting the first line, you'll get output like this that lets you see the branching factor at each layer of channel production / consumption:

sort 4chan.txt | sed -e 's/.*msg=//' | uniq -c | sort -n
      1 3 ^ 5: 243
      3 "starting 3chan producer"
      9 "starting 2chan producer"
      9 "starting 3chan consumer"
     27 "starting 2chan consumer"
     27 "starting chan producer"
     81 "starting 1chan consumer"
     81 "starting int producer"
    243 "received int"
    243 "sending int"

Commentary

There are a lot of reasons not to do this in real code: the difficulty of implementation and debugging, having a little self-respect, not wanting to be clubbed to death by your peers' bulky mechanical keyboards, etc.

On the other hand, it's extremely fun to do and pretty entertaining that it works at all.

So, trade-offs.

One of the best practical reasons not to send channels over channels is that it makes it really difficult to ever close any of them, which obviously you would want to do in a real use case. At one point I actually implemented close logic, which required adding a sync.WaitGroup everywhere so that I could keep track of when all the channel sends were finished before closing anything, but it made the gag much harder to read so I ditched it and stuck with time.Sleep() and massive goroutine leaks.

Conclusion

Have questions or comments about silly concurrency patterns in Go? Or maybe you are curious about the world's first version-controlled SQL database? Join us on Discord to talk to our engineering team and other Dolt users.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.