Select in Go
In the context of Go programming language, the select
statement is used to choose between multiple communication operations on channels. It allows a Go program to perform non-blocking operations on channels, enabling the program to wait for one or more channels to be ready for communication.
The select
statement works by evaluating the communication operations in the order they appear in the code, and if multiple operations are ready, it randomly selects one of them to execute. It can be used in conjunction with channel sends and receives, allowing the program to perform different actions depending on which channel becomes ready first.
Code Usage
Let’s say we are to build a Racer that determines which HTTP Server returns the response faster.
To test the code, we want to test which URL returns faster.
1
2
3
4
5
6
7
8
9
10
11
func TestRacer(t *testing.T) {
slowURL := "http://www.facebook.com"
fastURL := "http://www.quii.dev"
want := fastURL
got := Racer(slowURL, fastURL)
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Racer(a, b string) (winner string) {
startA := time.Now()
http.Get(a)
aDuration := time.Since(startA)
startB := time.Now()
http.Get(b)
bDuration := time.Since(startB)
if aDuration < bDuration {
return a
}
return b
}
So we take two URLs and make them race with each other, hence returning the faster website.
time.Since()
: takes in a timestamp object as its parameter and then automatically calculates the elapsed timehttp.Get
: Perform an HTTPGET
request against theURL
. The function will either return an http.Response or error.
However when we access HTTP requests, we don’t want to directly use the real external services because they can be rather slow and flaky. Instead, we can use the net/http/httptest
package to mock an HTTP server.
So now, we switch over to use mocks in our tests.
Mocking
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func TestRacer(t *testing.T) {
slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(20 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
fastServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
slowURL := slowServer.URL
fastURL := fastServer.URL
want := fastURL
got := Racer(slowURL, fastURL)
if got != want {
t.Errorf("got %q, want %q", got, want)
}
slowServer.Close()
fastServer.Close()
}
http.HandlerFunc
is the goto standard for defining a real HTTP server in Go. However wrapping it in a httptest.NewServer
makes it easier for us to test the HTTP server. We send in the http.HandlerFunc
via an anonymous function.
The slowServer adds an additional 20 milliseconds sleep deliberately for the test case.
Refactoring
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func Racer(a, b string) (winner string) {
aDuration := measureResponseTime(a)
bDuration := measureResponseTime(b)
if aDuration < bDuration {
return a
}
return b
}
func measureResponseTime(url string) time.Duration {
start := time.Now()
http.Get(url)
return time.Since(start)
}
We remove the repetitiveness in measuring the elapsed time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func TestRacer(t *testing.T) {
slowServer := makeDelayedServer(20 * time.Millisecond)
fastServer := makeDelayedServer(0 * time.Millisecond)
defer slowServer.Close()
defer fastServer.Close()
slowURL := slowServer.URL
fastURL := fastServer.URL
want := fastURL
got := Racer(slowURL, fastURL)
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func makeDelayedServer(delay time.Duration) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(delay)
w.WriteHeader(http.StatusOK)
}))
}
And go through the same process for the testing too. Here we notice a new term: defer
. The defer
prefix for the function call means that the function will be called at the end of the containing function. For this case, it will serve to close the server and not leave any hanging ports or resources that need to be cleaned up. The defer
prefix guarantees that the function will be called but can be also called near the declaration for code reading-efficiency.
Synchronising Processes
##
But with Go, we have a great tool called concurrency at hand. This abstracts the exact response times and only cares about which one is the actual one to come back sooner.
This is where select
comes in.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func Racer(a, b string) (winner string) {
select {
case <-ping(a):
return a
case <-ping(b):
return b
}
}
func ping(url string) chan struct{} {
ch := make(chan struct{})
go func() {
http.Get(url)
close(ch)
}()
return ch
}
Ping
The ping
creates a chan struct{}
because we don’t care what type is sent to the channel. Just signal once it’s done.
But why struct?
Structs
are the smallest data type from a memory perspective as it doesn’t require allocation
Defining Channels
Using make
to create a channel is preferable over declaring var ch chan struct{}
as the var
would automatically initialize the value with zero for integers and “” for strings. For channels, the default initialization is nil
which is problematic when sending as such values will be blocked.
Select
Once a value is sent to a channel, you can wait for values with := ←ch
which is a blocking call whilst waiting.
If you want to wait on multiple channels, use select
. The first value to be received will “win”.
In our code above, ping
will set up two channels and whichever writes to the channel first will be executed.
Add error if HTTP takes more than 10 seconds
1
2
3
4
5
6
7
8
9
10
11
12
13
t.Run("returns an error if a server doesn't respond within 10s", func(t *testing.T) {
serverA := makeDelayedServer(11 * time.Second)
serverB := makeDelayedServer(12 * time.Second)
defer serverA.Close()
defer serverB.Close()
_, err := Racer(serverA.URL, serverB.URL)
if err == nil {
t.Error("expected an error but didn't get one")
}
})
1
2
3
4
5
6
7
8
9
10
func Racer(a, b string) (winner string, error error) {
select {
case <-ping(a):
return a, nil
case <-ping(b):
return b, nil
case <-time.After(10 * time.Second):
return "", fmt.Errorf("timed out waiting for %s and %s", a, b)
}
}
So ultimately, we want to add an additional case that returns an error if the function exceeds 10 seconds. For this we use time.After which returns a chan to signal it down after the specified time.
Final Refactoring
However the test takes 10 seconds to run, so we want to make the timeout configurable.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var tenSecondTimeout = 10 * time.Second
func Racer(a, b string) (winner string, error error) {
return ConfigurableRacer(a, b, tenSecondTimeout)
}
func ConfigurableRacer(a, b string, timeout time.Duration) (winner string, error error) {
select {
case <-ping(a):
return a, nil
case <-ping(b):
return b, nil
case <-time.After(timeout):
return "", fmt.Errorf("timed out waiting for %s and %s", a, b)
}
}
So we add a timeout to manually configure our Racer, now called ConfigurableRacer.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func TestRacer(t *testing.T) {
t.Run("compares speeds of servers, returning the url of the fastest one", func(t *testing.T) {
slowServer := makeDelayedServer(20 * time.Millisecond)
fastServer := makeDelayedServer(0 * time.Millisecond)
defer slowServer.Close()
defer fastServer.Close()
slowURL := slowServer.URL
fastURL := fastServer.URL
want := fastURL
got, err := Racer(slowURL, fastURL)
if err != nil {
t.Fatalf("did not expect an error but got one %v", err)
}
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("returns an error if a server doesn't respond within the specified time", func(t *testing.T) {
server := makeDelayedServer(25 * time.Millisecond)
defer server.Close()
_, err := ConfigurableRacer(server.URL, server.URL, 20*time.Millisecond)
if err == nil {
t.Error("expected an error but didn't get one")
}
})
}
Now, we can separate the two functionalities we want to test. So in our first test, where we race to servers to see which returns faster, we use the default Racer which gives a 10 seconds timeout. And then in our second test we check whether the function returns an error properly if the HTTP connections take longer than our configured time duration.
Finale
Note that the select statement does not block if none of the communication operations are ready. It allows the program to continue execution without waiting indefinitely for a channel operation to complete. This post was influenced by Learn Go with tests.