This repository provides Go caching primitives tuned for contrasting read and write workloads, plus concurrency helpers and a faster singleflight implementation.
- Distinct cache implementations optimized for write-heavy (
WriteHeavyCache) and read-heavy (ReadHeavyCache) access patterns. - Expiration-aware variants with stale-while-revalidate helpers (
GetWithExpireStatus) for serving stale data while refreshing asynchronously. - Integer-specific caches with atomic-like increment operations.
RollingCachefor append-and-rotate workloads.- A generics-based singleflight that trades optional features for lower latency and zero allocations, plus a faster lock manager for keyed locking.
- Benchmarks and Docker automation under
benchmark/demonstrating performance gains over standard singleflight.
Install the library using go get:
go get github.com/catatsuy/cache
Ensure Go 1.25+ to match CI (.github/workflows/go.yml).
package main import ( "fmt" "github.com/catatsuy/cache" ) func main() { c := cache.NewWriteHeavyCache[int, string]() c.Set(1, "apple") value, found := c.Get(1) if found { fmt.Println("Found:", value) // Output: Found: apple } }
WriteHeavyCache uses sync.Mutex for both reads and writes, prioritizing write throughput.
c := cache.NewWriteHeavyCache[int, string]() c.Set(1, "apple") value, found := c.Get(1)
ReadHeavyCache relies on sync.RWMutex to allow concurrent readers while protecting writes.
c := cache.NewReadHeavyCache[int, string]() c.Set(1, "orange") value, found := c.Get(1)
The expiration variants accept TTLs per entry and expose GetWithExpireStatus to support stale-while-revalidate flows.
c := cache.NewWriteHeavyCacheExpired[int, string]() // assumes import "time" c.Set(1, "apple", 1*time.Second) fmt.Println(c.Get(1)) // Found: apple time.Sleep(2 * time.Second) _, found := c.Get(1) fmt.Println(found) // false
c := cache.NewReadHeavyCacheExpired[int, string]() // assumes import "time" c.Set(1, "orange", 1*time.Second) fmt.Println(c.Get(1)) // Found: orange time.Sleep(2 * time.Second) _, found := c.Get(1) fmt.Println(found) // false
if v, found, expired := c.GetWithExpireStatus(key); found { if expired { go func() { fresh := fetch(ctx, key) c.Set(key, fresh, 5*time.Minute) }() } return v }
WriteHeavyCacheInteger and ReadHeavyCacheInteger embed increment helpers for counters.
c := cache.NewWriteHeavyCacheInteger[int, int]() c.Set(1, 100) c.Incr(1, 10) value, _ := c.Get(1) fmt.Println(value) // 110
RollingCache maintains ordered slices with efficient append and rotate operations.
c := cache.NewRollingCache[int](10) c.Append(1) c.Append(2) fmt.Println(c.GetItems()) // [1 2] rotated := c.Rotate() fmt.Println(rotated) // [1 2] fmt.Println(c.GetItems()) // []
LockManager provides keyed locks for coordinating access across goroutines.
lm := cache.NewLockManager[int]() lm.Lock(1) // work lm.Unlock(1)
SingleflightGroup prevents duplicate in-flight work for the same key.
sf := cache.NewSingleflightGroup[string]() value, err, _ := sf.Do("key", func() (string, error) { return "Data for key key", nil }) if err != nil { fmt.Println("Error:", err) } else { fmt.Println("Result:", value) }
Combine it with a cache to coalesce heavy loads:
func Get(key int) int { if value, found := c.Get(key); found { return value } v, err, _ := sf.Do(fmt.Sprintf("cacheGet_%d", key), func() (int, error) { value := HeavyGet(key) c.Set(key, value) return value, nil }) if err != nil { panic(err) } return v }
github.com/catatsuy/cache also ships a lightweight cache API that pairs well with Singleflight. The snippets below show how to compose them. Import helper packages such as fmt and time as needed.
var ( c = cache.NewWriteHeavyCache[int, int]() sf = cache.NewSingleflightGroup[int]() ) // Get returns the cached value when present; otherwise it loads it by calling HeavyGet. // Singleflight makes sure HeavyGet only runs once per key when multiple callers race. func Get(key int) (int, error) { if value, found := c.Get(key); found { return value, nil } v, err := sf.Do(fmt.Sprintf("cacheGet_%d", key), func() (int, error) { value := HeavyGet(key) c.Set(key, value) return value, nil }) if err != nil { return 0, err } return v, nil }
The pattern below serves stale data immediately using GetWithExpireStatus and refreshes it once per key via Singleflight.
var ( c = cache.NewWriteHeavyCacheExpired[int, int]() sf = cache.NewSingleflightGroup[int]() ) func Get(key int) (int, error) { if v, found, expired := c.GetWithExpireStatus(key); found { if !expired { return v, nil } go func(k int) { sf.Do(fmt.Sprintf("cacheGet_%d", k), func() (int, error) { value := HeavyGet(k) c.Set(k, value, 1*time.Minute) return value, nil }) }(key) return v, nil } v, err := sf.Do(fmt.Sprintf("cacheGet_%d", key), func() (int, error) { value := HeavyGet(key) c.Set(key, value, 1*time.Minute) return value, nil }) if err != nil { return 0, err } return v, nil }
The benchmark/ module compares several singleflight variants in Go. Singleflight collapses concurrent requests sharing a key into a single execution.
- StandardSingleflight: Baseline
golang.org/x/sync/singleflightusinginterface{}, with panic/Goexit propagation, a shared-result flag, and synchronous cleanup afterfncompletes. - StandardSingleflightCast: Same as the baseline, but the benchmark performs a type assertion (for example
v.(int)) to measure that overhead. This is just a benchmark variant. - GenericsSingleflight: Lightly patched generic port (
Group[T]) hosted atgithub.com/catatsuy/sync/singleflight. Matches the standard semantics (panic/Goexit, shared flag, synchronous delete) with slightly fewer allocations. - CustomSingleflight: The generics-based implementation shipped in this repository (
github.com/catatsuy/cache). It focuses on latency and zero allocations via return-first with asynchronous map delete, per-call mutexes, no shared flag, and no panic/Goexit handling. Intended for idempotent, finite work (e.g., cache fills).
Contract for CustomSingleflight:
fnmust not panic, must be idempotent, and must finish in finite time. If you need panic propagation or the shared flag, prefer the standard implementation.
Benchmarks use a dedicated module in benchmark/go.mod so they can evolve dependencies independently; run them with go test -C benchmark -modfile=go.mod to pick up the local sources.
Environment: EC2 c7g.xlarge (Graviton3, 4 vCPU) / Debian 13 / Go 1.25.1
goos: linux
goarch: arm64
BenchmarkSingleflight/std/keys=1 18832320 195.1 ns/op 88 B/op 1 allocs/op
BenchmarkSingleflight/std/keys=1-2 15887760 225.8 ns/op 87 B/op 1 allocs/op
BenchmarkSingleflight/std/keys=1-4 10460737 337.7 ns/op 82 B/op 1 allocs/op
BenchmarkSingleflight/std-cast/keys=1 18096949 198.7 ns/op 88 B/op 1 allocs/op
BenchmarkSingleflight/std-cast/keys=1-2 16042627 221.6 ns/op 87 B/op 1 allocs/op
BenchmarkSingleflight/std-cast/keys=1-4 10191168 331.6 ns/op 82 B/op 1 allocs/op
BenchmarkSingleflight/generics/keys=1 18848503 191.7 ns/op 80 B/op 1 allocs/op
BenchmarkSingleflight/generics/keys=1-2 16614574 217.5 ns/op 79 B/op 0 allocs/op
BenchmarkSingleflight/generics/keys=1-4 11035903 323.9 ns/op 75 B/op 0 allocs/op
BenchmarkSingleflight/custom/keys=1 91318575 42.49 ns/op 0 B/op 0 allocs/op
BenchmarkSingleflight/custom/keys=1-2 26094780 149.9 ns/op 0 B/op 0 allocs/op
BenchmarkSingleflight/custom/keys=1-4 23411012 151.2 ns/op 0 B/op 0 allocs/op
BenchmarkSingleflight/std/keys=10 18525980 197.5 ns/op 87 B/op 1 allocs/op
BenchmarkSingleflight/std/keys=10-2 16850523 215.0 ns/op 87 B/op 1 allocs/op
BenchmarkSingleflight/std/keys=10-4 12107134 302.3 ns/op 86 B/op 1 allocs/op
BenchmarkSingleflight/std-cast/keys=10 18550858 197.3 ns/op 87 B/op 1 allocs/op
BenchmarkSingleflight/std-cast/keys=10-2 16768419 214.9 ns/op 87 B/op 1 allocs/op
BenchmarkSingleflight/std-cast/keys=10-4 12467149 296.0 ns/op 86 B/op 1 allocs/op
BenchmarkSingleflight/generics/keys=10 18988800 193.7 ns/op 80 B/op 1 allocs/op
BenchmarkSingleflight/generics/keys=10-2 16899808 211.1 ns/op 79 B/op 0 allocs/op
BenchmarkSingleflight/generics/keys=10-4 12377605 286.6 ns/op 78 B/op 0 allocs/op
BenchmarkSingleflight/custom/keys=10 75470974 49.51 ns/op 0 B/op 0 allocs/op
BenchmarkSingleflight/custom/keys=10-2 28253089 135.4 ns/op 0 B/op 0 allocs/op
BenchmarkSingleflight/custom/keys=10-4 17369714 199.8 ns/op 8 B/op 0 allocs/op
PASS
- Setup:
go test -C benchmark -modfile=go.mod -bench=. -benchmem -benchtime=3s -cpu=1,2,4(RunParallel),keys=1,10, trivialfn(return i, nil). - CustomSingleflight is consistently fastest.
keys=1(worst contention): 42.49 ns/op vs std 195.1 (@P=1 → ~×ばつ), 151.2 vs 337.7 (@P=4 → ~×ばつ).keys=10(moderate contention): 49.51 vs 197.5 (@P=1 → ~×ばつ), 199.8 vs 302.3 (@P=4 → ~×ばつ).
- Allocations / memory
- CustomSingleflight: 0 allocs/op (≈0 B/op).
- GenericsSingleflight: 0–1 allocs/op (~75–80 B/op).
- Standard / StandardSingleflightCast: 1 alloc/op (~86–88 B/op).
- Standard vs StandardSingleflightCast are essentially identical; type assertion cost is negligible.
Absolute ns/op varies by machine, but the ordering and relative gaps remain similar in our tests.
From benchmark/, build and run the Dockerized benchmark harness:
cd benchmark docker build -t benchmark-runner . docker run --rm benchmark-runner
Or, run the Go benchmarks directly from the repository root:
go test -C benchmark -modfile=go.mod -bench=. -benchmem -benchtime=3s -cpu=1,2,4For full API documentation, visit pkg.go.dev.