Time: Throttle, Backpressure, Timeouts

Updated

September 8, 2026

Time: Throttle, Backpressure, Timeouts

Overview

Time tools in concurrent Go: limit parallelism, reject overload, bound wait, and schedule delayed work—without leaking timers.

Throttle (≤ N concurrent)

Buffered channel as semaphore:

sema capacity N:

handle:  sema <- token   // acquire
         go work(); <-sema  // release in worker

wait:    fill all slots again to ensure idle
func throttle(n int, fn func()) (handle func(), wait func()) {
    sema := make(chan struct{}, n)
    handle = func() {
        sema <- struct{}{}
        go func() {
            fn()
            <-sema
        }()
    }
    wait = func() {
        for i := 0; i < n; i++ {
            sema <- struct{}{} // re-acquire all slots
        }
    }
    return handle, wait
}
  throttle (≤ N):
    acquire slot → run work → release
    full? → block

  backpressure:
    full? → return "busy" immediately (select default)

With N=2 and 4×100ms jobs → ~200ms total.

Backpressure (fail fast)

handle = func() error {
    select {
    case sema <- struct{}{}:
        go func() {
            fn()
            <-sema
        }()
        return nil
    default:
        return errors.New("busy")
    }
}
select + default:
  ready case → take it
  else → default immediately (no wait)

Clients must handle "busy" (retry later, shed load, degrade).

Operation timeout with select

func withTimeout(timeout time.Duration, fn func() int) (int, error) {
    done := make(chan struct{})
    var result int
    go func() {
        result = fn()
        close(done)
    }()
    select {
    case <-done:
        return result, nil
    case <-time.After(timeout):
        return 0, errors.New("timeout")
    }
}
sequence (top → bottom):
  actors: caller, worker, timer
  caller --> worker  : start fn
  caller --> timer  : After(timeout)
  worker --> caller  : done
  timer --> caller  : fire
  note: alt fn finishes first
  note: else timeout first
  note: return error; worker may still run!

Caveat: on timeout the worker goroutine may continue unless fn also observes cancel (prefer context—next chapter).

Timers vs time.After

API Notes
time.After Convenient; new timer every call—bad in hot loops
time.NewTimer Reuse with Stop/Reset carefully
time.AfterFunc Run function after delay; Stop cancels
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
    // fired
case <-cancel:
    if !timer.Stop() {
        select {
        case <-timer.C:
        default:
        }
    }
}

Hot path anti-pattern:

for {
    select {
    case <-in:
    case <-time.After(time.Hour): // allocates every iteration
    }
}

Runnable example

go mod init example && go run .
package main

import (
    "errors"
    "fmt"
    "time"
)

func work() { time.Sleep(80 * time.Millisecond) }

func main() {
    sema := make(chan struct{}, 2)
    handle := func() error {
        select {
        case sema <- struct{}{}:
            go func() {
                work()
                <-sema
            }()
            return nil
        default:
            return errors.New("busy")
        }
    }
    for i := 1; i <= 4; i++ {
        fmt.Println(i, handle())
    }
    time.Sleep(200 * time.Millisecond)
}

Expected: first two nil, next two busy.

Try next: Replace timeout demo with context.WithTimeout so the worker can exit early.