Go's 10-Year Select Proposal Revived: bradfitz's Mutex.LockChan() Demo

The article traces the 10-year history of Go issue #16620, which sought to make sync.Cond selectable, explains the level-vs-edge-triggered conflict, reviews community workarounds, and details bradfitz's new Mutex.LockChan() demo that enables cancellable locking via select.

TonyBai
TonyBai
TonyBai
Go's 10-Year Select Proposal Revived: bradfitz's Mutex.LockChan() Demo

Ten Years Ago: A Real Pain Point from the HTTP/2 Battlefield

In 2016, while developing Go's HTTP/2 implementation, bradfitz encountered a concrete scenario: he needed a condition variable for flow control while simultaneously waiting on multiple channels, including net/http.Request.Cancel. He often had to wait for any of several events — flow-control condition satisfied, user cancelled the request, or peer closed the connection — to happen first.

The problem was that sync.Cond could not appear in a select statement. He proposed adding a WaitChan() method to Cond that would return a channel notified when the condition variable was signaled.

The Real Blocker: Two Fundamentally Different Trigger Models

Go core member ianlancetaylor provided the crucial insight: channels are level-triggered, condition variables are edge-triggered.

A channel's "has a value" state persists until the value is read.

A condition variable's Signal / Broadcast is a one-time event: it wakes goroutines waiting at that exact moment; if no one is waiting, the notification is lost and not stored.

Forcing an edge-triggered signal into a level-triggered channel model forces awkward choices: either manually drain the signal when no receiver exists, or produce far more wake-ups than expected. Broadcast is especially thorny — it means "wake all currently waiting goroutines." Translated to channel semantics, questions arise: should every goroutine selecting on that channel be woken? What value should be sent?

Diagram showing semantic difference between level-triggered channels and edge-triggered condition variables
Diagram showing semantic difference between level-triggered channels and edge-triggered condition variables

Because of this, ianlancetaylor concluded the issue was not a simple API patch but a "quite subtle design" change.

Community Workarounds Over Ten Years

Extra goroutine bridge: A dedicated goroutine blocks on cond.Wait() and notifies the real worker via a channel. Simple but leaks goroutines when the outer context cancels before the bridge goroutine can be cleaned up.

context.AfterFunc + Cond.Broadcast combo: Since Go 1.21, context.AfterFunc can call cond.Broadcast() on cancellation, waking waiters to check the context. Simpler than a bridge goroutine, but requires Broadcast (not Signal) because a cancellation-triggered broadcast might be "stolen" by another waiter if only Signal is used. (Example from benhoyt in the pebble project.)

Third-party libraries: Libraries like condchan and missinggo 's chancond attempted pure-channel cancellable condition variables. However, Go core member rogpeppe demonstrated races and panics under concurrent Signal / Broadcast calls, confirming ianlancetaylor's assessment.

Prerequisite unblocked: A long-standing dependency, issue #8898 (runtime support for new channel types/semantics), was resolved via #61542, removing the theoretical blocker. ianlancetaylor explicitly noted the proposal was "unblocked" again.

Turning Point: bradfitz Returns with a Demo CL

Ten years later, bradfitz submitted Demo CL 828544 (go-review.googlesource.com/c/go/+/828544). Notably, it does not implement the originally envisioned Cond.WaitChan(). Instead, it takes a narrower, more verifiable entry point: an experimental LockChan() method on sync.Mutex.

The design doc describes: calling mu.LockChan() returns a channel that is closed exactly when the lock is successfully acquired ; once a value is received, subsequent receives see the closed state without re-acquiring the lock; each LockChan() call returns a one-time-use channel.

Usage with context:

select {
case <-mu.LockChan():
    // lock acquired in this branch, remember mu.Unlock()
case <-ctx.Done():
    // request cancelled, lock not acquired
}

For repeated locking with cancellation in a loop:

for {
    select {
    case <-mu.LockChan():
        // do work
        mu.Unlock()
    case <-ctx.Done():
        return ctx.Err()
    }
}
Sequence diagram illustrating LockChan semantics: channel closed on lock acquisition, select with context cancellation
Sequence diagram illustrating LockChan semantics: channel closed on lock acquisition, select with context cancellation

The demo sparked immediate discussion. One commenter asked a critical boundary detail: in a for { select { ... } } loop, does each mu.LockChan() call produce a fresh, independent channel? This directly affects safety in retry loops and is a key test case for the design.

What This Step Solves — and What Remains

Solved: LockChan() converts the lock-acquisition edge event into a one-time, unidirectional channel close . Closing is idempotent and safely receivable multiple times, neatly sidestepping the core level-vs-edge conflict for Signal scenarios.

This explains why the demo starts with Mutex rather than Cond: mutex acquisition is inherently exclusive and occurs once, naturally fitting a "close-once channel"; Cond.Broadcast must wake arbitrary numbers of waiters in varying states, a completely different complexity level.

Unresolved:

This is still only a demo (experimental code) , not yet in the formal proposal review process, let alone a Go release.

It covers only Mutex; the original issue's target — sync.Cond (especially Broadcast) — has no corresponding solution yet.

Whether RWMutex needs an RLockChan() and how to design it remains open.

Why This Matters

Even as a demo, Mutex.LockChan() signals a significant direction: Go may finally gain standard-library native support for "acquire lock while responding to cancellation/timeout," a common need in network services, connection pools, and rate limiters — without hand-written bridge goroutines or context.AfterFunc patches.

For long-time issue followers (e.g., the developer repeatedly asking detailed questions), the original author's return after a decade is a positive signal: the problem that has vexed the Go community for ten years is once again being seriously advanced.

References

Go issue #16620: github.com/golang/go/issues/16620

bradfitz's Demo CL 828544: go-review.googlesource.com/c/go/+/828544 context.AfterFunc documentation: pkg.go.dev/context#AfterFunc

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

concurrencyGocondition variableMutexselectchannelsync.CondGo proposalbradfitzLockChan
TonyBai
Written by

TonyBai

Tony Bai's tech world (tonybai.com). Not satisfied with just "knowing how", we strive for mastery. Focused on Go language internals, high-quality engineering practices, and cloud‑native architecture, exploring cutting‑edge intersections of Go and AI. Gophers who pursue technology are welcome—follow me and evolve with Go.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.