Skip to main content

Command Palette

Search for a command to run...

Go: Managing Time and Timing in Concurrent Applications

Updated
10 min read
Go: Managing Time and Timing in Concurrent Applications

In this chapter, we'll explore various techniques for managing time in concurrent Go programs.


Throttling

Suppose we have work that needs to be done in large quantities:

func work() {
    // Something very important, but not very fast.
    time.Sleep(100 * time.Millisecond)
}

The simplest approach is to process tasks sequentially:

func main() {
    start := time.Now()

    work()
    work()
    work()
    work()

    fmt.Println("4 calls took", time.Since(start))
}
4 calls took 400ms

Four calls of 100 ms each take a total of 400 ms when executed one after the other.

Of course, it's faster to do the work in parallel with N handlers like this:

  • If there's a free handler, give it the task.
  • Otherwise, wait until one becomes available.

In the "Channels" chapter we solved a similar problem using a semaphore. Recall the principle:

  • Create an empty channel with a buffer size of N.
  • Before starting, a goroutine puts a token (some value) into the channel.
  • Once finished, the goroutine takes a token from the channel.

Let's create a wrapper throttle(n, fn) to ensure concurrent execution. We'll set up a sema channel and make sure that no more than n work functions are running at the same time:

func throttle(n int, fn func()) (handle func(), wait func()) {
    // Semaphore for n goroutines.
    sema := make(chan struct{}, n)

    // Execute fn functions concurrently, but not more than n at a time.
    handle = func() {
        sema <- struct{}{}
        go func() {
            fn()
            <-sema
        }()
    }

    // Wait until all functions have finished.
    wait = func() {
        for range n {
            sema <- struct{}{}
        }
    }

    return handle, wait
}

Now the client calls the work() function through the wrapper, not directly:

func main() {
    handle, wait := throttle(2, work)
    start := time.Now()

    handle()
    handle()
    handle()
    handle()
    wait()

    fmt.Println("4 calls took", time.Since(start))
}
4 calls took 200ms

Here's how it works:

  • The first and second calls start processing immediately;
  • The third and fourth wait for the previous two to finish.

With two handlers, 4 calls complete in 200 ms.

Such throttling works well when the parallelism level n and the individual work() times match (more or less) the rate of handle() calls. Then each call has a good chance of being processed immediately or with a small delay.

However, if there are many more calls than the handlers can manage, the system will slow down. Each work() will still take 100 ms, but handle() calls will hang, waiting for a place in a semaphore. This isn't a big deal for data pipelines, but could be problematic for online requests.

Sometimes, clients may prefer to get an immediate error when all handlers are busy. We need another approach for such cases.


Backpressure

Let's change the throttle() logic:

  • If there's room in the semaphore, execute the function.
  • Otherwise, return an error immediately.

This way, the client doesn't have to wait for a stuck call.

The select statement will help us once again.

Before:

// Execute fn functions concurrently,
// but not more than n at a time.
handle = func() {
    sema <- struct{}{}
    go func() {
        fn()
        <-sema
    }()
}

After:

// Execute fn functions concurrently,
// but not more than n at a time.
handle = func() error {
    select {
    case sema <- struct{}{}:
        go func() {
            fn()
            <-sema
        }()
        return nil
    default:
        return errors.New("busy")
    }
}

Let's recall how select works:

  • Checks which cases are not blocked.
  • If multiple cases are ready, randomly selects one to execute.
  • If all cases are blocked, waits until one is ready.

The third point (all cases are blocked) actually splits into two:

  • If there's no default case, select waits until one is ready.
  • If there is a default case, select executes it.

The default case is perfect for our situation:

  • If there's a token in the sema channel, we run fn.
  • Otherwise, we return a "busy" error without waiting.

Let's look at the client:

func main() {
    handle, wait := throttle(2, work)

    start := time.Now()

    err := handle()
    fmt.Println("1st call, error:", err)

    err = handle()
    fmt.Println("2nd call, error:", err)

    err = handle()
    fmt.Println("3rd call, error:", err)

    err = handle()
    fmt.Println("4th call, error:", err)

    wait()

    fmt.Println("4 calls took", time.Since(start))
}
1st call, error: <nil>
2nd call, error: <nil>
3rd call, error: busy
4th call, error: busy
4 calls took 100ms

The first two calls ran concurrently (each took 100 ms), while the third and fourth got an error immediately. All calls were handled in 100 ms.

Of course, this approach (sometimes called backpressure) requires some awareness on the part of the client. The client should understand that a "busy" error means overload, and either delay further handle() calls or reduce their frequency.


Operation Timeout

Here's a function that normally takes 10 ms, but in 20% of the calls it takes 200 ms:

func work() int {
    if rand.Intn(10) < 8 {
        time.Sleep(10 * time.Millisecond)
    } else {
        time.Sleep(200 * time.Millisecond)
    }
    return 42
}

Let's say we don't want to wait more than 50 ms. So, we set a timeout — the maximum time we're willing to wait for a response. If the operation doesn't complete within the timeout, we'll consider it an error.

Let's create a wrapper that runs the given function with the given timeout:

func withTimeout(timeout time.Duration, fn func() int) (int, error) {
    // ...
}

We'll call it like this:

func main() {
    for range 10 {
        start := time.Now()
        timeout := 50 * time.Millisecond
        if answer, err := withTimeout(timeout, work); err != nil {
            fmt.Printf("Took longer than %v. Error: %v\n", time.Since(start), err)
        } else {
            fmt.Printf("Took %v. Result: %v\n", time.Since(start), answer)
        }
    }
}
Took 10ms. Result: 42
Took 10ms. Result: 42
Took 10ms. Result: 42
Took longer than 50ms. Error: timeout
Took 10ms. Result: 42
Took longer than 50ms. Error: timeout
Took 10ms. Result: 42
Took 10ms. Result: 42
Took 10ms. Result: 42
Took 10ms. Result: 42

Here's the idea behind withTimeout():

  • Run the given fn() in a separate goroutine.
  • Wait for the timeout period.
  • If fn() returns a result, return it.
  • If it doesn't finish in time, return an error.

Here's how you can implement it:

// withTimeout executes a function with a given timeout.
func withTimeout(timeout time.Duration, fn func() int) (int, error) {
    var result int

    done := make(chan struct{})
    go func() {
        result = fn()
        close(done)
    }()

    select {
    case <-done:
        return result, nil
    case <-time.After(timeout):
        return 0, errors.New("timeout")
    }
}

The time.After(d) function returns a channel that will receive a value after duration d. It's a convenient way to implement timeouts.


Timer

A timer is a mechanism for executing code after a certain delay. Go provides the time.Timer type for this purpose.

Basic Timer Usage

func main() {
    timer := time.NewTimer(2 * time.Second)
    <-timer.C
    fmt.Println("Timer fired!")
}

NewTimer(d) creates a timer that will send the current time to channel C after duration d. You can stop the timer before it fires using Stop():

func main() {
    timer := time.NewTimer(2 * time.Second)
    go func() {
        time.Sleep(1 * time.Second)
        if timer.Stop() {
            fmt.Println("Timer stopped")
        }
    }()
    <-timer.C
    fmt.Println("Timer fired!")
}

Timer Reset

Sometimes you need to reset a timer to extend or restart its duration. However, there are important considerations when resetting timers.

Let's consider a consumer that processes tokens and logs a warning if no tokens arrive within a timeout period:

type token struct{}

func consumer(cancel <-chan token, in <-chan token) {
    const timeout = time.Hour
    for {
        select {
        case <-in:
            // do stuff
        case <-time.After(timeout):
            // log warning
        case <-cancel:
            return
        }
    }
}

Let's write a client that measures the memory usage after 100K channel sends:

func main() {
    cancel := make(chan token)
    defer close(cancel)

    tokens := make(chan token)
    go consumer(cancel, tokens)

    measure(func() {
        for range 100000 {
            tokens <- token{}
        }
    })
}
Memory used: 24223 KB, # allocations: 300011

Behind the scenes, each time.After creates a timer that is later freed by the garbage collector. So our for loop is essentially creating a myriad of timers, doing a lot of allocations, and creating unnecessary work for the GC. This is usually not what we want.

To avoid creating a timer on each loop iteration, you can create it at the beginning and reset it before moving on to the next iteration. The Reset method in Go 1.23+ is perfect for this:

func consumer(cancel <-chan token, in <-chan token) {
    const timeout = time.Hour
    timer := time.NewTimer(timeout)
    for {
        timer.Reset(timeout)
        select {
        case <-in:
            // do stuff
        case <-timer.C:
            // log warning
        case <-cancel:
            return
        }
    }
}
Memory used: 0 KB, # allocations: 2

This approach does not create new timers, so the GC does not need to collect them.

Reset in Go pre-1.23

Due to implementation quirks in Go versions prior to 1.23, Reset should only be called on an already stopped or expired timer with an empty output channel. So, to reset the timer correctly, you have to use a helper function:

// resetTimer stops, drains and resets the timer.
func resetTimer(t *time.Timer, d time.Duration) {
    if !t.Stop() {
        select {
        case <-t.C:
        default:
        }
    }
    t.Reset(d)
}
func consumer(cancel <-chan token, in <-chan token) {
    const timeout = time.Hour
    timer := time.NewTimer(timeout)
    for {
        resetTimer(timer, timeout)
        select {
        case <-in:
            // do stuff
        case <-timer.C:
            // log warning
        case <-cancel:
            return
        }
    }
}
Memory used: 0 KB, # allocations: 2

time.AfterFunc

To make matters worse, time.AfterFunc also creates a timer, but a very different one. It has a nil C channel, so the Reset method works differently:

  • If the timer is still active (not stopped, not expired), Reset clears the timeout, effectively restarting the timer.
  • If the timer is already stopped or expired, Reset schedules a new function execution.
func main() {
    var start time.Time

    work := func() {
        fmt.Printf("work done after %dms\n", time.Since(start).Milliseconds())
    }

    // run work after 10 milliseconds
    timeout := 10 * time.Millisecond
    start = time.Now()  // ignore the data race for simplicity
    t := time.AfterFunc(timeout, work)

    // wait for 5 to 15 milliseconds
    delay := time.Duration(5+rand.Intn(11)) * time.Millisecond
    time.Sleep(delay)
    fmt.Printf("%dms has passed...\n", delay.Milliseconds())

    // Reset behavior depends on whether the timer has expired
    t.Reset(timeout)
    start = time.Now()

    time.Sleep(50*time.Millisecond)
}

If the timer has not expired, Reset clears the timeout:

8ms has passed...
work done after 10ms

If the timer has expired, Reset schedules a new function call:

work done after 10ms
13ms has passed...
work done after 10ms

To reiterate:

  • Go ≤ 1.22: For a Timer created with NewTimer, Reset should only be called on stopped or expired timers with drained channels.
  • Go ≥ 1.23: For a Timer created with NewTimer, it's safe to call Reset on timers in any state (active, stopped or expired). No channel drain is required.
  • For a Timer created with AfterFunc, Reset either reschedules the function (if the timer is still active) or schedules the function to run again (if the timer has stopped or expired).

Timers are not the most obvious things in Go, are they?


Ticker

Sometimes you want to perform an action at regular intervals. There's a tool for this in Go called a ticker. A ticker is like a timer, but it keeps firing until you stop it:

func work(at time.Time) {
    fmt.Printf("%s: work done\n", at.Format("15:04:05.000"))
}

func main() {
    ticker := time.NewTicker(50 * time.Millisecond)
    defer ticker.Stop()

    go func() {
        for {
            at := <-ticker.C
            work(at)
        }
    }()

    // enough for 5 ticks
    time.Sleep(260 * time.Millisecond)
}
07:20:00.150: work done
07:20:00.200: work done
07:20:00.250: work done
07:20:00.300: work done
07:20:00.350: work done

NewTicker(d) creates a ticker that sends the current time to the channel C at interval d. You must stop the ticker eventually with Stop() to free up resources.

In our case, the interval is 50 ms, which allows for 5 ticks.

If the channel reader can't keep up with the ticker, the ticker will skip ticks:

func work(at time.Time) {
    fmt.Printf("%s: work done\n", at.Format("15:04:05.000"))
    time.Sleep(100 * time.Millisecond)
}

func main() {
    ticker := time.NewTicker(50 * time.Millisecond)
    defer ticker.Stop()

    go func() {
        for {
            at := <-ticker.C
            work(at)
        }
    }()

    // enough for 3 ticks because of the slow work()
    time.Sleep(260 * time.Millisecond)
}
07:20:00.150: work done
07:20:00.200: work done
07:20:00.300: work done

In this case, the receiver starts to fall behind after the second tick.

As you can see, the ticks don't pile up; they adapt to the slow receiver.


Summary

Now you know that handling time in concurrent programs is not about (ab)using time.Sleep. Here are some useful tools you've learned:

  • Timeouts limit operation time.
  • Timers help with delayed operations.
  • Tickers are for periodic actions.
  • Default case in select allows non-blocking processing.

These tools provide efficient and reliable ways to manage time-based operations in concurrent Go programs.

More from this blog

Go & DevOps Blog

24 posts

Backend Developer | Python | Go | gRPC | Kubernetes | Ansible | IaC