singleflight — Specification¶
Table of Contents¶
- Scope
- Notation
- Group Contract
- Do Contract
- DoChan Contract
- Forget Contract
- Happens-Before Edges
- Shared Flag Semantics
- Error and Panic Semantics
- Cancellation Non-Semantics
- Conformance Tests
- Non-Requirements
Scope¶
This document specifies the contract a coalescing group must satisfy to be considered a correct implementation of singleflight.Group. It does not prescribe a particular implementation. Two implementations with identical observable behaviour for all conforming programs are equivalent.
The specified surface is the public API of golang.org/x/sync/singleflight:
- Type
Group. - Type
Resultwith fieldsVal interface{},Err error,Shared bool. - Method
Group.Do(key string, fn func() (interface{}, error)) (interface{}, error, bool). - Method
Group.DoChan(key string, fn func() (interface{}, error)) <-chan Result. - Method
Group.Forget(key string).
Internal types and helpers are out of scope. The shape of panicError and the use of runtime.Goexit are implementation details, not part of the contract.
Notation¶
Gis aGroup.Kis a string key.Fis a loader function with signaturefunc() (interface{}, error).- An active call for
KinGis aDoorDoChaninvocation whose correspondingFhas been entered (line of code inFhas executed) and has not yet returned. - The window of an active call is the interval
[fn_enter, fn_exit]plus the cleanup that follows. Two calls are concurrent if their windows overlap. - "happens-before" (
hb) is the partial order of the Go memory model.
Group Contract¶
G1 (Zero value usable). The zero value of Group is ready for use. No constructor is required, no initialisation is needed before first use.
G2 (Concurrency safety). All methods of Group are safe for concurrent use by multiple goroutines.
G3 (Independence). Two distinct Group values do not interact. Calls on G1 have no effect on calls or state in G2, even if they use the same key.
G4 (No global state). The package introduces no process-global state. All state is per-Group.
Do Contract¶
D1 (Single execution per window). Given concurrent invocations G.Do(K, F1), G.Do(K, F2), ..., where all invocations begin while there is an active call for K in G, exactly one of the Fi shall be entered. The other invocations shall not enter their respective Fi.
D2 (First caller wins). The Fi that is entered is the one passed by the invocation whose registration of an internal call record for K happens-before any other concurrent registration. Implementations may use any synchronisation primitive to achieve this.
D3 (Shared result). All concurrent invocations of G.Do(K, *) whose windows overlap with the active call's window shall return the same (v, err) pair.
D4 (Shared flag). The third return value of G.Do(K, F) is true if and only if (v, err) was returned to more than one invocation of G.Do(K, *) (counting the present invocation).
D5 (Result lifetime). After all concurrent waiters have been served, the internal record for K shall be removed. A subsequent invocation of G.Do(K, F') after the removal shall execute F'.
D6 (No replay). An invocation G.Do(K, F) that arrives after the window of a previous call has closed shall enter F and execute it. The previous call's result is not replayed.
D7 (Blocking semantics). G.Do is a blocking call. It returns only after the loader has produced a result (either its own loader or the shared loader's).
D8 (No deadlock with self-call). If F, while executing, calls G.Do(K, F) (same key, same group), the inner call must not deadlock by waiting on the outer call. Implementations may detect this and behave in one of three ways: - Execute F again (re-entrant). - Return an error. - Document the behaviour as undefined.
This specification permits any of these behaviours; portable code shall not rely on a specific outcome. In practice, the reference implementation deadlocks. Do not call Do for the same key from within its own loader.
DoChan Contract¶
C1 (Channel return). G.DoChan(K, F) shall return a receive-only channel of Result.
C2 (Single send). The returned channel shall receive exactly one Result value before being either closed or left with no further sends. The reference implementation leaves the channel without closing after the single send.
C3 (Non-blocking caller). G.DoChan(K, F) shall not block beyond the time required for internal bookkeeping.
C4 (Coalescing). If a call for K is active when G.DoChan(K, F) is invoked, F shall not be entered. The returned channel shall receive the result of the active call.
C5 (No effect on cleanup). The receiver of the returned channel is permitted to not read from it. Implementations shall guarantee no goroutine leak in that case: the loader goroutine sends to a buffered channel and exits regardless of whether the receiver reads.
C6 (Fan-out fairness). When the active call completes, every channel registered against it shall receive the result. The order in which channels receive is not specified.
Forget Contract¶
F1 (Idempotent removal). G.Forget(K) shall remove the internal record for K if one exists. If no record exists, the call is a no-op.
F2 (Does not cancel). G.Forget(K) shall not cancel or otherwise affect an active call for K. The active loader runs to completion.
F3 (Does not affect existing waiters). Goroutines that have already begun waiting on the call for K (whether via Do or DoChan) shall still receive the active call's result.
F4 (Fresh start for future callers). After G.Forget(K) has been observed, the next invocation of G.Do(K, F') or G.DoChan(K, F') shall enter F' and shall not coalesce with the previous active call.
Happens-Before Edges¶
Let F be the loader for an active call producing result (v, err). Let W be any invocation of G.Do(K, *) or G.DoChan(K, *) that observes (v, err).
HB1 (Loader-to-Caller). The end of F's execution happens-before the return of W (for Do) or the receive on the channel returned by W (for DoChan).
HB2 (Caller-to-Loader for first caller). The invocation of G.Do or G.DoChan by the first caller happens-before the entry of F.
HB3 (Late arrival ordering). A waiter W whose invocation happens-after the registration of the active call's record observes the active call's result. A waiter whose invocation happens-before the registration enters its own loader.
These edges are sufficient to use the result of F without further synchronisation. In particular, fields of a struct returned from F are safe to read from any waiter without additional synchronisation, provided they are not subsequently mutated.
Shared Flag Semantics¶
S1 (Definition). Shared is true for a particular return iff at least two distinct invocations of Do or DoChan (counted together) received that exact (v, err).
S2 (Visible to all sharers). When Shared is true, every receiver of (v, err) sees Shared == true. The flag is symmetric.
S3 (No reliable order signal). Shared is false for the first caller iff no waiter joined; it is true if any waiter joined. The flag does not signal which caller was first.
Error and Panic Semantics¶
E1 (Error propagation). If F returns (_, err) with err != nil, every waiter on the active call receives the same err (by value, not by deep copy).
E2 (Panic propagation). If F panics with value p, every waiter on the active call receives a panic. The packaged panic value may be wrapped by the implementation; portable code shall treat the recovered value as opaque except to log it.
E3 (Goexit recovery). If F exits via runtime.Goexit, the implementation shall convert this into an observable failure for every waiter. The reference implementation re-runs Goexit from each waiter's goroutine. Portable code shall not depend on the specific mechanism.
E4 (No partial result). A waiter shall never receive a (v, err) pair that was not produced by F's return statement. In particular, on panic, v shall be nil (or the zero value of the type, if a typed wrapper is used).
Cancellation Non-Semantics¶
N1 (No context parameter). The methods of Group do not accept a context.Context. Loaders cannot be cancelled by callers.
N2 (Caller walk-away). A caller of DoChan that does not read the returned channel does not affect the loader's execution. The loader runs to completion.
N3 (No timeouts). The package provides no built-in timeout. Bounded execution is the loader's responsibility.
N4 (No retries). The package provides no retry semantics. Retry is the caller's responsibility.
Conformance Tests¶
A conformant implementation shall pass tests of the following shapes.
Test C1: Single execution under burst¶
func TestConformance_SingleExecution(t *testing.T) {
var g Group
var calls int32
fn := func() (interface{}, error) {
atomic.AddInt32(&calls, 1)
time.Sleep(20 * time.Millisecond)
return 42, nil
}
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
v, err, _ := g.Do("k", fn)
if v != 42 || err != nil {
t.Fatal("bad result")
}
}()
}
wg.Wait()
if atomic.LoadInt32(&calls) != 1 {
t.Fatalf("expected 1 call, got %d", calls)
}
}
Test C2: Shared flag¶
func TestConformance_SharedFlag(t *testing.T) {
var g Group
started := make(chan struct{})
proceed := make(chan struct{})
fn := func() (interface{}, error) {
close(started)
<-proceed
return 1, nil
}
var aShared bool
aDone := make(chan struct{})
go func() { _, _, aShared = g.Do("k", fn); close(aDone) }()
<-started
_, _, bShared := g.Do("k", fn)
close(proceed)
<-aDone
if !aShared || !bShared {
t.Fatalf("expected both shared, got a=%v b=%v", aShared, bShared)
}
}
Note: the second Do blocks on the active call. We close proceed after the second Do registers — but we need the second Do registration to happen before we close. In practice this test is racy in spirit; a robust version uses synchronisation between the second Do registration and the close(proceed) step.
Test C3: No replay¶
func TestConformance_NoReplay(t *testing.T) {
var g Group
var calls int32
fn := func() (interface{}, error) {
atomic.AddInt32(&calls, 1)
return "v", nil
}
g.Do("k", fn)
g.Do("k", fn)
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("expected 2 calls, got %d", got)
}
}
Test C4: Forget releases coalescing for future callers¶
func TestConformance_Forget(t *testing.T) {
var g Group
var calls int32
started := make(chan struct{})
fn := func() (interface{}, error) {
atomic.AddInt32(&calls, 1)
close(started)
time.Sleep(100 * time.Millisecond)
return "v", nil
}
go g.Do("k", fn)
<-started
g.Forget("k")
g.Do("k", fn)
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("expected 2 calls (forget broke coalescing), got %d", got)
}
}
Non-Requirements¶
The following are not required of a conforming implementation:
- Fairness. The order in which waiters are unblocked is not specified.
- Memory bounds. No upper bound on internal state size.
- Loader cancellation. Cancellation is explicitly not provided.
- Result caching across windows. The package is a coalescer, not a cache.
- Key normalisation. Keys are compared by string equality. No trimming, case folding, or canonical form.
- Stable shared flag for the executor. The executor sees
Shared=trueif at least one waiter joined; otherwisefalse. The flag does not identify the executor. - Idempotence guarantees on the loader. The package does not verify that the loader is idempotent. That is the user's responsibility.