4
\$\begingroup\$

Real case: I need to retrieve data from a web service (main process), but in case that web service is taking too long, I retrieve the data from the cache (that data might be older) in a parallel process (alt process). If the main process completes first, I return that data. If the main process doesn't complete before the timeout, then the alt process returns either the data (if alt process has completed) or a default value, if alt process hasn't completed by the timeout.

I have built the function that provides the generic capability, but I am not sure if it's the best way to implement the logic.

package common

import (
    "time"
)

// Function launches the race between mainFunc and mainFunc, with a cutoff timeout
// If mainFunc finishes before the timeout, its result is sent to chResult
// If timeout is triggered before mainFunc is complete, then:
//      If altFunc is complete by then, its result is sent to chResult
//      Otherwise, defaultResult is sent to chResult
func LaunchRace[T any](chResult chan T, delayTimeout time.Duration, mainFunc func(chan T), altFunc func(chan T), defaultResult T) {
    go func(ch chan T) {
        mainFunc(ch)
    }(chResult)

    go func(ch chan T) {
        chTimeout := time.After(delayTimeout)

        ch1 := make(chan T)
        go func() {
            altFunc(ch1)
        }()

        result := defaultResult

        for {
            select {
            case result = <-ch1:
                continue
            case <-chTimeout:
                ch <- result
                return
            }
        }

    }(chResult)
}

And some unit tests for it:

package common

import (
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
)

type Delay struct {
    DelayMain time.Duration
    DelayAlt  time.Duration
    Timeout   time.Duration
}

func Scenario(t *testing.T, delay Delay, chResult chan string) {
    LaunchRace(chResult, delay.Timeout,
        func(ch chan string) {
            time.Sleep(delay.DelayMain)
            ch <- "main"
        }, func(ch chan string) {
            time.Sleep(delay.DelayAlt)
            ch <- "alt"
        }, "default")
}

func TestMain(t *testing.T) {
    delay := Delay{
        DelayMain: 1 * time.Second,
        DelayAlt:  2 * time.Second,
        Timeout:   3 * time.Second,
    }

    chResult := make(chan string)
    Scenario(t, delay, chResult)
    result := <-chResult
    assert.Equal(t, "main", result)
}

func TestAlt(t *testing.T) {
    delay := Delay{
        DelayMain: 10 * time.Second,
        DelayAlt:  1 * time.Second,
        Timeout:   2 * time.Second,
    }

    chResult := make(chan string)
    Scenario(t, delay, chResult)
    result := <-chResult
    assert.Equal(t, "alt", result)
}

func TestDefault(t *testing.T) {
    delay := Delay{
        DelayMain: 10 * time.Second,
        DelayAlt:  2 * time.Second,
        Timeout:   1 * time.Second,
    }

    chResult := make(chan string)
    Scenario(t, delay, chResult)
    result := <-chResult
    assert.Equal(t, "default", result)
}
\$\endgroup\$

1 Answer 1

4
\$\begingroup\$

General comments

  • You don't need to pass in the channel as a parameter to the goroutine. Take a look at closures. Therefore,
go func(ch chan T) {
   mainFunc(ch)
}(chResult)

changes to

go mainFunc(ch)
  • In Go, if your function parameters have the same type consecutively, you can omit all of them except the last one. So this,
func(int1 int, string1 string, string2 string, int2 int) {}

will become this:

func(int1 int, string1, string2 string, int2 int) {}

context.Context

You can make this code more idiomatic by using Go's context.Context package.

The package contains a handy WithTimeout function. The Context type contains a Done channel that is closed whenever the deadline expires.

How does the program with context work?

  • Your function now accepts a context.Context as an argument. It's idiomatic to put it as the very first argument. It's convention to use ctx as the variable name.
func LaunchRaceWithContext[T any](ctx context.Context) T  {

}
  • Your main and cache functions, instead of taking a channel, return the type. Remember, channels are cheap.
func LaunchRaceWithContext[T any](ctx context.Context, mainFn, cacheFn func() T) {

}
  • You create 2 new channels, one that will receive from the main function and another from the cache function.
mainCh := make(chan T)
cacheCh := make(chan T)
  • Start the goroutines.
go func() {
   mainCh <- mainFn()
}()
go func() {
   cacheCh <- cacheFn()
}()
  • We will use Go's select statement to wait for whichever channel returns the value first.
var result T  // default value
select {
   case result = <-mainCh:
   case result = <-cacheCh:
   case <- ctx.Done()
}

return result

Above, we default construct T, and wait for whichever channels complete first. If the operation times out, the ctx.Done channel will close which means the default value will be returned. Otherwise, result will contain the value returned by either the main function or cache function.

  • On the caller side, we create a context with the desired timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
defer cancel()
  • and pass it to the function.
result := LaunchRaceWithContext(ctx, mainFn, cacheFn)

The entire code looks like this:

func LaunchRaceWithContext[T any](ctx context.Context, mainFn, cacheFn func() T) T {
    mainCh := make(chan T)
    cacheCh := make(chan T)

    go func() {
        mainCh <- mainFn()
    }()

    go func() {
        cacheCh <- cacheFn()
    }()

    var res T
    select {
    case res = <-mainCh:
    case res = <-cacheCh:
    case <-ctx.Done():
    }
    return res
}

func main() {
    ctx := context.Background()
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    mainFn := func() string {
        time.Sleep(8 * time.Second)
        return "main"
    }

    cacheFn := func() string {
        time.Sleep(10 * time.Second)
        return "cache"
    }

    res := LaunchRaceContext(ctx, mainFn, cacheFn)
    fmt.Println(res)
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.