Metronome's Cacophony (10/14) - concurrency in Go - Channel select

Asynchronous approach (ctd)

Channels (ctd)

Select statement

If you want to embrace Go's channels, but are feeling constrained by all the blocking operations on them, select will make your day. It is part of Go's syntax and - in its simplicity - it's stunning how powerful it is. Link to documentation.

Non-blocking channel receive with select statement

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6   words := make(chan string)
 7   select {
 8      case word := <-words:
 9         fmt.Println("word is:", word)
10   default:
11      fmt.Println("default case")
12   }
13}

will produce:

1default case
2
3Process finished with exit code 0

Above snippet attempts to receive a word from an empty channel words. As mentioned before, receive operation on an empty channel will block indefinitely. If you use select with default clause, it will allow you to attempt an operation on a channel without blocking penalty. If the channel cannot perform the operation in a non-blocking manner, select will ... uhm ... select default clause, execute whatever code is in default's body and leave select statement immediately after.

Non-blocking channel send with select statement

An example for sending:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6   words := make(chan string)
 7   select {
 8      case words <- "hello":
 9         fmt.Println(`"hello" sent`)
10   default:
11      fmt.Println("default case")
12   }
13}

will produce

1default case
2
3Process finished with exit code 0

because the channel in the example is unbuffered and there is no receiver attached to it at the time of sending. We already know that sending in this case (or sending to a channel with no more capacity) would normally block.

Note: if you remove default case from the two examples above you will loose all benefit of select. Both cases will behave like the non-select, blocking equivalents from previous section. Both will also produce you favourite - deadlock.

Channel closure detection with select

You have already seen a mechanism for verifying if the channel is closed. Same mechanism can be used with select:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6   words := make(chan string, 1)
 7   words <- "hello"
 8   close(words)
 9
10   select {
11   case word, ok := <-words:
12      fmt.Println("word is:", word, "ok:", ok)
13   }
14
15   select {
16   case word, ok := <-words:
17      fmt.Println("word is:", word, "ok:", ok)
18   }
19}

will output

1word is: hello ok: true
2word is:  ok: false
3
4Process finished with exit code 0

Notice that despite of closing the channel before we even proceed to select clause, the first receive will return true value. To reiterate, second return value from <- channel operation will return false when the channel is closed and empty.

What about sending?

 1package main
 2
 3func main() {
 4   words := make(chan string, 1)
 5   close(words)
 6
 7   select {
 8   case words <- "hello":
 9   default:
10   }
11
12}

will produce:

1panic: send on closed channel
2
3goroutine 1 [running]:
4main.main()
5	/home/seb/.GoLand2018.1/config/scratches/scratch_25.go:8 +0x76
6
7Process finished with exit code 2

because sending on channel after close() has been invoked against it, will always panic. Not even mighty select statement will help here.

Multiple case clauses/channels with select

You'll find yourself coordinating communication between multiple channels. select is flexible enough to allow for this to happen in a single statement.

As an illustration, let's imagine a factory with four assemblers and two conveyor belts which will take away ready products. Assembler is given an instructions to assemble a product. Assembler can be busy with a single product at a time. Once assembler finishes the task, ready product is put on the closest conveyor belt.

 1package main
 2
 3import (
 4   "fmt"
 5)
 6
 7func main() {
 8   type AssemblyInstructions struct{}
 9   type AssembledProduct struct{}
10
11   assemblerA := make(chan AssemblyInstructions, 1)
12   assemblerB := make(chan AssemblyInstructions, 1)
13   assemblerC := make(chan AssemblyInstructions, 1)
14   assemblerD := make(chan AssemblyInstructions, 1)
15
16   conveyorBelt1 := make(chan AssembledProduct, 1)
17   conveyorBelt2 := make(chan AssembledProduct, 1)
18   conveyorBelt1 <- AssembledProduct{}
19   conveyorBelt2 <- AssembledProduct{}
20
21   attemptsLeft := 10
22   for attemptsLeft > 0 {
23      attemptsLeft --
24      
25      select {
26      case assemblerA <- AssemblyInstructions{}:
27         fmt.Println("assemble car")
28      case assemblerB <- AssemblyInstructions{}:
29         fmt.Println("assemble pen")
30      case assemblerC <- AssemblyInstructions{}:
31         fmt.Println("assemble soup")
32      case assemblerD <- AssemblyInstructions{}:
33         fmt.Println("assemble mind")
34      case <-conveyorBelt1:
35         fmt.Println("finished on belt 1")
36      case <-conveyorBelt2:
37         fmt.Println("finished on belt 2")
38      default:
39         fmt.Println("defaulted")
40      }
41   }
42}

We have 6 channels, each with buffer of one. assembler* channels are empty while conveyorBelt* channels are prepopulated with one message each. select is used with all 6 channels, on 4 to send and on 2 to receive messages. We are going to invoke select 10 times to see how the situation develops as the saturation of channels changes.

Note: if you wonder if case clauses within select statement fallthrough to the next case upon match, the answer is no. Similarly, in Go's switch statement they don't, but in the switch you can use fallthrough to make them do. select does not come with one.

Java developers: the above note mentions falling through in switch clauses. In Java the clauses that match will fall through by default. That's why break and return is used, to stop it. #pay-per-keystroke 😃

The output:

 1assemble pen
 2finished on belt 2
 3assemble soup
 4assemble mind
 5finished on belt 1
 6assemble car
 7defaulted
 8defaulted
 9defaulted
10defaulted
11
12Process finished with exit code 0

Note: your output will most likely differ. If you run this enough times and you'll eventually get 6! (factorial) possible outputs, with the "defaulted" always trailing.

Explanation of what has happened is as following.

On each select invocation, it will select only one of the clauses. The mechanism for selecting close will choose pseudo-randomly one of clauses that wouldn't block.

Note: wait, what? That's slightly undeterministic, isn't it? It is done like that to not promote any of the single clauses over another one. In concurrent models this would be referred to as fairness. If execution is fair then all threads are roughly equally busy. Extreme case of unfair execution gives all the workload to one thread and the others never get time to do their job. This concurrent execution phenomenon is called starvation

On first iteration, none of the 6 operations would block. We can send any of the 4 messages (channels assembler* are empty and have a buffer of 1) and we can also receive a single message on any of the two prepopulated conveyorBelt* channels.

As per the output, coincidentally, the first clause won and we have sent the message on assemblerA channel. That channel is now full as the buffer has size 1 and nothing will ever receive messages from it. It means it won't get selected again as this would block operation.

On second iteration the clause which receives from conveyorBelt2 won. We have received the only message that this channel had, and it is now empty. This clause won't get selected again because receiving from an empty channel will block until the channel is closed, which does not happen here.

And so on, the pattern repeats until we get to the 7th iteration when all of the operations are blocking, in which case select picks default clause.

Until more messages is put on channels conveyorBelt* or messages are received from assembler* channels the ouput is going to remain the same.

Note: the above example contains quite a bit of repetition and hardcoded presumption as to how many channels there is. What if you want to serve an N-number of channels? I would consider changing the solution so that receiving happens from a channel merging the N-channels and writing to a channel that is then fanned out to N-channels. Also, I wouldn't discredit simple for-loop, going with the spirit of Go's simplicity. I've also seen a reflection based solution here, but I haven't used it.

Channel sending/receiving timeout with select

Ability to impose timeouts on long-running tasks is essential to keep control of the system's performance and UX. We rarely can take a no-compromise approach where we would expect the resources or messages to be readily available. Neither we can wait for completion indefinitely. Well, we often have no indication whatsoever as to what is the length of the task going to be.

In computing, delays are ubiquitous and a reminder of physical limitations of our world. Your code gets an advantage, if you are expecting and catering for delays, however minimal.

With the select syntax we can make conscious design decision about how much of a delay is acceptable for the system.

Armed with the knowledge about select you may even expect what the solution could be.

 1package main
 2
 3import (
 4   "time"
 5   "log"
 6)
 7
 8func main() {
 9   log.Println("start")
10   complimentsChannel := make(chan string, 1)
11
12   complimentsChannel <- "you've made fantastic dinner, thank you very much!"
13
14   select {
15      case response := <-complimentsChannel:
16         log.Printf("received compliment: %q\n" ,response)
17      case <- time.After(5 * time.Second):
18         log.Println("too long wait for a compliment, attack verbally")
19   }
20   log.Println("end")
21}

Function time.After() will create and return a channel which, after specified time, will have a single message sent to it by Go. We treat the above select case no different to a standard case receiving from two channels.

Output:

12018/05/01 22:34:51 start
22018/05/01 22:34:51 received compliment: "you've made fantastic dinner, thank you very much!"
32018/05/01 22:34:51 end
4
5Process finished with exit code 0

output demonstrates that, in case one of the channel operations won't block (compliment is ready to be received), it is performed immediately.

If you were to comment-out compliment sending in line 12, you would get this output:

12018/05/01 22:36:56 start
22018/05/01 22:37:01 too long wait for a compliment, attack verbally
32018/05/01 22:37:01 end
4
5Process finished with exit code 0

Notice the times in log.

Initially the timing channel returned by time.After() and complimentsChannel are empty. Because there is no default case in the above select, it will just wait forever, until a message appears in any. After specified timeout message appears in timing channel, which makes this select end.


Other posts in metronome-cacophony series