sync.OnceFunc — Tasks¶
Task 1 — Rewrite a sync.Once usage as OnceFunc¶
You are handed this code:
package logger
import (
"log/slog"
"os"
"sync"
)
var (
initOnce sync.Once
logger *slog.Logger
)
func Get() *slog.Logger {
initOnce.Do(func() {
f, err := os.OpenFile("/var/log/app.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
panic(err)
}
logger = slog.New(slog.NewJSONHandler(f, nil))
})
return logger
}
Rewrite it using sync.OnceValue so that:
- There is no package-level
*slog.Loggervariable. - There is no
sync.Oncevariable. - The
Getfunction returns the same logger every call. - A panic in initialization still propagates.
Expected solution:
package logger
import (
"log/slog"
"os"
"sync"
)
var Get = sync.OnceValue(func() *slog.Logger {
f, err := os.OpenFile("/var/log/app.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
panic(err)
}
return slog.New(slog.NewJSONHandler(f, nil))
})
Get is now a func() *slog.Logger value, called the same way (logger.Get()).
Task 2 — Build a lazy config loader¶
Write a package config that exposes Load() (*Config, error) where:
Configisstruct{ DSN string; Workers int }.- The first call reads
CONFIG_DSNandCONFIG_WORKERSfrom the environment, parsesWorkersas an int, and returns a(*Config, error). - Every subsequent call returns the exact same
(*Config, error)pair without re-reading the environment. - Concurrent calls are safe.
Solution:
package config
import (
"fmt"
"os"
"strconv"
"sync"
)
type Config struct {
DSN string
Workers int
}
var Load = sync.OnceValues(func() (*Config, error) {
dsn := os.Getenv("CONFIG_DSN")
if dsn == "" {
return nil, fmt.Errorf("CONFIG_DSN is empty")
}
workers, err := strconv.Atoi(os.Getenv("CONFIG_WORKERS"))
if err != nil {
return nil, fmt.Errorf("CONFIG_WORKERS: %w", err)
}
return &Config{DSN: dsn, Workers: workers}, nil
})
Test it with two goroutines that both call Load() and confirm both get the same *Config pointer:
func TestLoadOnce(t *testing.T) {
os.Setenv("CONFIG_DSN", "postgres://x")
os.Setenv("CONFIG_WORKERS", "4")
var wg sync.WaitGroup
var a, b *Config
wg.Add(2)
go func() { defer wg.Done(); a, _ = Load() }()
go func() { defer wg.Done(); b, _ = Load() }()
wg.Wait()
if a != b {
t.Fatalf("expected same pointer, got %p and %p", a, b)
}
}
Task 3 — Observe panic-reuse behavior¶
Write a program that:
- Wraps a function that panics with
"boom"usingsync.OnceFunc. - Calls the wrapper from three goroutines.
- Each goroutine catches its panic with
recoverand prints the value. - Verifies that all three goroutines print
"boom".
Solution:
package main
import (
"fmt"
"sync"
)
func main() {
wrapped := sync.OnceFunc(func() {
panic("boom")
})
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
fmt.Printf("goroutine %d recovered: %v\n", i, r)
}
}()
wrapped()
}(i)
}
wg.Wait()
}
Expected output (order will vary):
Compare this to the same program with sync.Once.Do: two of the three goroutines will print nothing, because after the first panic the Once is consumed and later Do calls return silently.
Task 4 — Idempotent close¶
You have a Connection struct with a Close() method that must be safe to call any number of times. Implement it using sync.OnceFunc.
package conn
import (
"fmt"
"sync"
)
type Connection struct {
name string
close func()
}
func New(name string) *Connection {
c := &Connection{name: name}
c.close = sync.OnceFunc(func() {
fmt.Printf("closing %s\n", c.name)
// ...release resources...
})
return c
}
func (c *Connection) Close() { c.close() }
Test:
func TestCloseIdempotent(t *testing.T) {
c := New("alpha")
c.Close()
c.Close()
c.Close()
// Should print "closing alpha" exactly once.
}
Run it under go test -race to confirm concurrent Close calls do not race.
Task 5 — Replace three sync.Once patterns in a file¶
Find the file in your own codebase (or any open-source Go 1.21+ project) and identify three usages of sync.Once. For each one, decide:
- Is the wrapped function value-producing? (Then it should be
OnceValueorOnceValues.) - Is it side-effect only? (Then
OnceFunc.) - Is the panic-on-second-call contract better, worse, or irrelevant?
- After replacing, does any unused state at package scope go away?
Write up the three rewrites side-by-side. The point is to internalize the pattern through real code, not toy examples.
Task 6 — Confirm GC of captured state¶
Demonstrate that after a successful sync.OnceValue call, the wrapped function's captured state is collected. Use runtime.SetFinalizer:
package main
import (
"fmt"
"runtime"
"sync"
)
type Big struct{ buf [1 << 20]byte } // 1 MiB
func main() {
big := &Big{}
runtime.SetFinalizer(big, func(*Big) {
fmt.Println("big collected")
})
load := sync.OnceValue(func() int {
// big is captured here; OnceValue drops the closure after success.
return len(big.buf)
})
fmt.Println("len:", load())
big = nil
runtime.GC()
runtime.GC()
fmt.Println("done")
}
Expected output:
If you replace sync.OnceValue with a hand-rolled sync.Once that keeps the closure captured indefinitely, the finalizer never runs.
Task 7 — Lazy compiled regex with a feature flag¶
Write a function ValidateEmail(addr string) bool that:
- Uses a moderately complex email regex compiled lazily with
sync.OnceValue. - Returns
truefor valid email addresses,falsefor invalid. - If a global
featureFlagEmailValidationisfalse, returnstrueunconditionally and does not compile the regex.
Solution:
package validator
import (
"regexp"
"sync"
)
var featureFlagEmailValidation = true
var emailRx = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`^[\w.+-]+@[\w-]+\.[\w.-]+$`)
})
func ValidateEmail(addr string) bool {
if !featureFlagEmailValidation {
return true
}
return emailRx().MatchString(addr)
}
The regex is compiled only when the flag is on and ValidateEmail is actually called. If the flag is off across the entire run, the regex is never compiled.
Task 8 — OnceFunc-based shutdown coordination¶
Write a small program with:
- A
shutdownfunction wrapped insync.OnceFuncthat prints "flushing logs", "closing connections", "goodbye". - A signal handler goroutine that calls
shutdownonSIGINT. - A deferred call to
shutdownfrommain. - Verify that on Ctrl+C, the shutdown messages print exactly once (and not twice — once from the signal handler, once from the deferred call).
Solution:
package main
import (
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
var shutdown = sync.OnceFunc(func() {
fmt.Println("flushing logs")
fmt.Println("closing connections")
fmt.Println("goodbye")
})
func main() {
defer shutdown()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
shutdown()
os.Exit(0)
}()
fmt.Println("running; press Ctrl+C")
time.Sleep(30 * time.Second)
}
Run it, press Ctrl+C, observe that the three shutdown messages print exactly once.
Task 9 — Convert a struct method using sync.Once¶
Given:
type Server struct {
cfgOnce sync.Once
cfg *Config
cfgErr error
}
func (s *Server) Config() (*Config, error) {
s.cfgOnce.Do(func() {
s.cfg, s.cfgErr = loadServerConfig()
})
return s.cfg, s.cfgErr
}
Rewrite Server so that there is no sync.Once field and no cfgErr field — only a wrapper function.
Solution:
type Server struct {
config func() (*Config, error)
}
func NewServer() *Server {
s := &Server{}
s.config = sync.OnceValues(loadServerConfig)
return s
}
func (s *Server) Config() (*Config, error) { return s.config() }
Config is now a method that delegates to the wrapper. The struct has one field instead of three.
Task 10 — Verify the GC reclaims the loader closure¶
Write a benchmark that:
- Creates a
sync.OnceValuewrapping a closure that captures a 10 MiB byte slice. - Calls the wrapper.
- Forces GC.
- Reads memory stats and verifies the 10 MiB is freed.
Skeleton:
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var m1, m2 runtime.MemStats
buf := make([]byte, 10<<20)
load := sync.OnceValue(func() int {
return len(buf)
})
runtime.GC()
runtime.ReadMemStats(&m1)
fmt.Println("loaded:", load())
buf = nil
runtime.GC()
runtime.GC()
runtime.ReadMemStats(&m2)
fmt.Printf("HeapAlloc before: %d KB\n", m1.HeapAlloc/1024)
fmt.Printf("HeapAlloc after: %d KB\n", m2.HeapAlloc/1024)
}
You should see the "after" HeapAlloc significantly lower than "before" — the closure's reference to buf was dropped after load() succeeded.