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
- Introduction
- Basic synchronous solution
- Ticker
- Goroutine
- WaitGroup
- Sharing state
- Atomicity
- Mutex
- Channels introduction
- Channel select
- Goroutines and channels
- Solution with channel
- Videos and final word
- Appendices