go-sync

Gopher CircleCI codecov Go Report Card Go Reference Stability: Active

🔄 go-sync

A small Go library (package sync) with focused concurrency helpers:

📦 Install

Requires Go 1.26 or newer.

go get github.com/alexfalkowski/go-sync

🧪 Local validation

Local Make targets use shared tooling from the bin submodule. After cloning the repository for development, initialize it before running Make targets:

git submodule sync && git submodule update --init
make dep

Then run the narrow target you need, such as make specs.

Benchmarks

make benchmarks
make benchtime=1s benchmarks
make benchmark

Fuzzes

make fuzzes
make map-fuzz
make value-fuzz
make pool-fuzz
make group-fuzz
make worker-fuzz
make package=. name=FuzzMapStringIntOperations fuzztime=1000x fuzz

🧭 Package layout

The public API is intentionally small:

Most wrappers preserve the semantics of the standard library type they wrap while making those semantics easier to use from generic code.

🔁 Aliases

The package re-exports a few commonly used concurrency primitives and helper types for convenience:

These are type aliases rather than wrappers, so their behavior is exactly the same as the underlying type.

🪝 Hooks

Most execution helpers accept a sync.Hook:

OnError is only called when OnRun returns a non-nil error. If OnError returns a different error, that new error is returned.

How that returned error is observed depends on the helper:

package main

import (
    "context"
    "errors"
    "fmt"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    runErr := errors.New("boom")
    hook := sync.Hook{
        OnRun: func(context.Context) error {
            return runErr
        },
        OnError: func(_ context.Context, err error) error {
            return fmt.Errorf("wrapped: %w", err)
        },
    }

    err := hook.Error(context.Background(), hook.OnRun(context.Background()))
    fmt.Println(errors.Is(err, runErr))
}

⏱️ Wait vs Timeout

Wait and Timeout both run Hook.OnRun, but they differ:

[!IMPORTANT] Neither Wait nor Timeout forcibly stops OnRun. If the handler ignores context cancellation, it can continue running after the helper returns.

Use sync.IsTimeoutError(err) to check if an error matches sync.ErrTimeout or context.DeadlineExceeded.

⏳ Wait example (best effort)

package main

import (
    "context"
    "errors"
    "fmt"
    "time"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    err := sync.Wait(context.Background(), 10*time.Millisecond, sync.Hook{
        OnRun: func(context.Context) error {
            time.Sleep(time.Second)
            return errors.New("finished too late")
        },
    })

    // true: Wait timed out first.
    fmt.Println(err == nil)
}

🚦 Timeout example (propagated cancellation)

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    err := sync.Timeout(context.Background(), 10*time.Millisecond, sync.Hook{
        OnRun: func(ctx context.Context) error {
            <-ctx.Done()
            return context.Cause(ctx)
        },
    })

    fmt.Println(sync.IsTimeoutError(err))
}

👷 Worker

Worker schedules asynchronous handlers with bounded concurrency.

[!NOTE] Worker scheduling methods report scheduling errors only. Handler errors are routed through Hook.OnError and are not returned by Schedule or TrySchedule.

If count == 0, Schedule always blocks until ctx is done and TrySchedule returns sync.ErrWorkerFull immediately.

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    worker := sync.NewWorker(4)
    defer func() {
        if err := worker.Wait(context.Background()); err != nil {
            log.Printf("wait failed: %v", err)
        }
    }()

    for i := 0; i < 3; i++ {
        job := i
        err := worker.Schedule(context.Background(), sync.Hook{
            OnRun: func(context.Context) error {
                fmt.Println("job", job)
                return nil
            },
            OnError: func(_ context.Context, err error) error {
                log.Printf("job failed: %v", err)
                return err
            },
        })
        if err != nil {
            log.Printf("schedule failed: %v", err)
        }
    }
}

⏳ Future

Async starts a typed operation immediately and returns a Future[T] for its eventual result.

package main

import (
    "context"
    "fmt"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    future := sync.Async(context.Background(), func(context.Context) (int, error) {
        return 42, nil
    })

    value, err := future.Await(context.Background())
    fmt.Println(value, err == nil)
}

👥 Group

🧩 ErrorGroup / ErrorsGroup / WaitGroup

sync.ErrorGroup is a type alias for errgroup.Group. sync.ErrorsGroup waits for every scheduled function and returns all non-nil errors joined with errors.Join. sync.WaitGroup is a type alias for sync.WaitGroup.

package main

import (
    "errors"
    "fmt"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    var g sync.ErrorGroup

    g.Go(func() error { return nil })
    g.Go(func() error { return errors.New("boom") })

    fmt.Println(g.Wait() != nil)
}

Use ErrorsGroup when callers need every error rather than only the first one:

ErrorsGroup retains recorded errors for its lifetime. Use a fresh ErrorsGroup for each independent batch of work. Functions passed to ErrorsGroup.Go must not panic; panics are not joined into the error returned by Wait. Call SetLimit(n) to bound how many functions run concurrently: a negative n means unbounded, which is also the default for a zero-value ErrorsGroup. A limit of 0 means every subsequent call to Go blocks forever, since a concurrency slot is never available. SetLimit must not be called while any function started by Go is still running. Use TryGo for non-blocking, best-effort submission: it starts a function only if a concurrency slot is currently free, returning false without starting it otherwise, while still joining every error into a later Wait. Start the first function before calling Wait for an empty group, and wait for a batch to finish before starting the next independent batch. Do not copy an ErrorsGroup after first use.

package main

import (
    "errors"
    "fmt"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    var g sync.ErrorsGroup

    first := errors.New("first")
    second := errors.New("second")

    g.Go(func() error { return first })
    g.Go(func() error { return second })

    err := g.Wait()
    fmt.Println(errors.Is(err, first), errors.Is(err, second))
}

✈️ SingleFlightGroup

SingleFlightGroup[T] deduplicates concurrent work by key.

package main

import (
    "fmt"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    var g sync.SingleFlightGroup[int]

    v, err, shared := g.Do("key", func() (int, error) {
        return 42, nil
    })
    fmt.Println(v, err == nil, shared)
}

🏊 Pool

🧺 Generic Pool

Pool[T] is a typed wrapper around sync.Pool.

package main

import (
    "fmt"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    type item struct {
        ID int
    }

    pool := sync.Pool[item]{
        New: func() *item {
            return &item{ID: 10}
        },
    }
    it := pool.Get()
    pool.Put(it)

    fmt.Println("ok")
}

🧽 BufferPool

BufferPool is a convenience wrapper over Pool[bytes.Buffer].

package main

import (
    "fmt"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    bp := sync.NewBufferPool()
    buf := bp.Get()
    defer bp.Put(buf)

    buf.WriteString("hello")
    out := bp.Copy(buf)
    fmt.Println(string(out))
}

⚛️ Value

Value[T] is a typed wrapper around atomic.Value.

package main

import (
    "fmt"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    var v sync.Value[int]
    fmt.Println(v.Load())

    v.Store(1)
    fmt.Println(v.Swap(2))
    fmt.Println(v.CompareAndSwap(2, 3))
}

🗺️ Map

Map[K, V] is a typed wrapper around sync.Map.

package main

import (
    "fmt"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    m := sync.NewMap[string, int]()

    m.Store("one", 1)
    v, ok := m.Load("one")
    fmt.Println(v, ok)

    prev, loaded := m.LoadOrStore("one", 99)
    fmt.Println(prev, loaded)

    m.Range(func(k string, v int) bool {
        fmt.Println(k, v)
        return true
    })
}

Nil interface edge-case example:

package main

import (
    "fmt"
    "io"

    "github.com/alexfalkowski/go-sync"
)

func main() {
    var m sync.Map[string, io.Reader]
    var r io.Reader

    m.Store("reader", r)
    m.Range(func(_ string, value io.Reader) bool {
        fmt.Println(value == nil)
        return true
    })
}

🔎 Background / References

This library draws inspiration from:

For executable, CI-verified usage examples, see example_test.go. Those examples back the rendered package documentation on pkg.go.dev.