Skip to content

proposal: sync: remove the Cond type #21165

Closed

Description

@bcmills

In the discussion on #16620, I've noticed that the majority of use-cases folks describe for sync.Cond turn out to work fine with a channel instead: (*sync.Cond).Broadcast corresponds to calling close on the channel, and (*sync.Cond).Signal corresponds to sending on the channel.

The existence of sync.Cond suggests that people might want to use it, but it is currently under-documented (#20491), incompatible with other Go synchronization patterns (e.g. select statements; see #16620), and unlike most other types in the sync package, does not have a valid zero-value (its Wait method requires a non-nil L field). It has an additional "no copy" invariant enforced through both a run-time dynamic check and special-case code in the vet tool.

On top of that, condition variables are fiendishly difficult to use: they are prone to either missed or spurious signals [citation needed — experience reports welcome].

An audit of the Go standard library shows only a handful of uses:

  • io/pipe.go: The use of sync.Cond was added in https://golang.org/cl/4252057. The previous implementation used channels and had no known bugs.
  • syscall/net_nacl.go: The comment there says "We do not use channels because we need to be able to handle writes after and during close, and because a chan byte would require too many send and receive operations in real use." It does not explain why these considerations preclude the use of channels (e.g. a separate 'chan struct{}' to signal closing, and channels of tokens or slices rather than channels of tokens or bytes).
  • net/http/h2_bundle.go: There are two sync.Conds in this file. One is is in a struct "like io.Pipe". The other is only used with Broadcast (never Signal), and can thus be replaced with a channel that is closed to broadcast readiness.
  • net/http/server.go: This sync.Cond is again only used with Broadcast, and thus easy to replace with a channel.
  • crypto/tls/conn.go: Again only used with Broadcast.

Of the above uses, only the one in syscall/net_nacl.go does not have an obvious channel equivalent. However, it appears to be used to limit the size of a buffer, and I know of at least one similar "limiter" API (x/sync/semaphore) that is implemented in terms of channels in order to support interoperation with the standard context package. (I did the Cond-to-channel conversion myself on a prototype of that package before it was open-sourced.)

In light of the above observations, I propose that we remove the Cond type from the sync package in Go 2.

Activity

  1. ianlancetaylor commented on Jul 25, 2017

    @ianlancetaylor
    Contributor

    The main thing that sync.Cond provides that (as far as I can see) channels do not is the ability to use both Signal and Broadcast on the same synchronization point. But if nobody does that then I agree that sync.Cond is not especially useful.

    cc @bradfitz since he decided to use sync.Cond in x/net/http2 in https://golang.org/cl/16310 .

  2. cespare commented on Jul 25, 2017

    @cespare
    Contributor

    I don't use sync.Cond often, but when I have, I've been happy it exists. I agree that it's hard to use in other languages where you only have, say, locks and condition variables as concurrency primitives. But in Go, where we have channels, one can reserve use of sync.Cond for those specific cases where it's a good fit.

    Besides the signal+broadcast case mentioned, another case where it's not obvious to me how to replace a Cond with a channel is where the mutex linked to the Cond protects some state. I typically want to lock, modify some state, and then cond.Wait -- which releases the lock. If I try to replace the Cond with a channel but keep the mutex, now I have two independent synchronization primitives, and that seems trickier to reason about.

  3. bcmills commented on Jul 25, 2017

    @bcmills
    ContributorAuthor

    @cespare

    another case where it's not obvious to me how to replace a Cond with a channel is where the mutex linked to the Cond protects some state.

    Indeed, that's the trickier type of usage I've seen in practice. (It's also, in my experience, the type more likely to have deadlocks, or spurious or missed signals.) That kind of usage is illustrated in syscall/net_nacl.go, where the Mutex guards two monotonic variables (r and m) and triggers based on the difference between them.

    I know of at least two techniques that can apply for that type of usage.

    1. If the state is always associated with a single condition, we can apply the technique illustrated in proposal: sync: mechanism to select on condition variables #16620 (comment) and store the data in the channel payload itself.

    2. Otherwise, we can replace the Cond with a list of channels instead of a single channel, and ensure that edits to that list only occur with the Mutex held. To cancel a wait, we acquire the Mutex, check whether the channel was signaled, and (if it wasn't) remove the entry from the list. This is the technique used in (*semaphore.Weighted).Acquire.

    (For the latter technique, there may be something we could factor out into a library that would harmonize with the rest of Go better than sync.Cond does today, but I'm not sure that it's a common enough use case to warrant being in the standard library.)

  4. cznic commented on Jul 25, 2017

    @cznic
    Contributor

    sync.Cond makes emulating pthreads simple. It seems to me semantics of sync.Cond is actually heavily inspired by pthreads.

  5. bcmills commented on Jul 25, 2017

    @bcmills
    ContributorAuthor

    @cznic

    sync.Cond makes emulating pthreads simple.

    Agreed, but is pthread really a good example for Go's concurrency model to emulate?

  6. added
    v2An incompatible library change
    on Jul 25, 2017
  7. changed the title [-]proposal (Go 2): sync: remove the Cond type[/-] [+]proposal: Go 2: sync: remove the Cond type[/+] on Jul 25, 2017
  8. added this to the Proposal milestone on Jul 25, 2017
  9. bradfitz commented on Jul 25, 2017

    @bradfitz
    Contributor

    I think Go2 could improve Cond but I think removing Cond is kinda crazy. Using channels is currently a super heavy replacement.

  10. kr commented on Jul 26, 2017

    @kr
    Contributor

    Note that a single Cond lets multiple waiters each wait on a slightly different condition, such as an incrementing counter reaching various thresholds. I mentioned a real-life example of this in #16620 (comment). Channels seem to be a poor fit for this.

  11. reusee commented on Jul 26, 2017

    @reusee

    Channels cannot be reopened to broadcast twice.

  12. tombergan commented on Aug 15, 2017

    @tombergan
    Contributor

    Since x/net/http2 was mentioned: I recently wrote a CL for x/net/http2 that was complicated by the use of Cond, because I wanted to select on set of channels or a Cond. I had to implement this by spinning a goroutine to wait on the channels, then broadcast to the Cond if one of those channels fired.
    https://go-review.googlesource.com/c/53250/8/http2/transport.go#955

    And FWIW, x/net/http2 uses Broadcast exclusively, never Signal.

    I don't have a position on removing Cond, per se, but more than once I have been annoyed by not being able to select on a Cond and a channel simultaneously. If that could be fixed I would be happy.

  13. glycerine commented on Aug 28, 2017

    @glycerine

    I find channels easier to use than sync.Cond, but channels don't provide for efficient, repeated broadcasting of more than one bit from 1:N receivers. I suggest adding a new type of channel, a broadcast channel. It would be very similar to a channel with buffer size 1, with some extra logic:

    a) if the channel has a value, then receiving on a broadcast channel doesn't consume the value in it. This allows an unlimited number of receivers to receive the same value.

    b) delete on the broadcast channel removes any value in it. This allows the sender to stop broadcasting any value at all.

    Notes

    1. an empty broadcast channel would still cause receivers to block, as usual.

    2. Senders then broadcast by sending into the channel, as usual. But each new send replaces the old value, and any receive after a replacement sees the new value. Just like a condition variable, receivers may miss values if a new value replaces the old before they awake. And just like a condition variable, a receiver may not receive a value at all if somebody deletes it from the broadcast channel before receipt.

    Alternatives:

    A2) a more general approach would be to allow the sending of values tagged as "sticky", and let sticky values be consumed an infinite number of times. This could be nice if you want the buffer of the channel to contain both sticky and non-sticky values. You would need a new operation to consume and eliminate a sticky value.

    A3) allow the size of the buffer attached to a channel to be resized without being re-allocated. Then, assuming you know how many subscribers you have, the sender can send exactly that many values on a regular buffered channel. Unfortunately this requires that the sender have "register a new client" logic so it knows when to expand the queue. Corresponding de-registration logic required as well. Hence I prefer the broadcast type channels suggested first.

    edit: A2 may have the advantage of being the most backwards compatible, and the advantage of having obviously novel syntax for sending sticky values. Suppose for instance that the operator <~ was chosen for sticky send. Example ch := make(chan int); ch <~ 1; would send the value 1 as a sticky value, that could be received many times. The new syntax would make the use of the sticky value very visually distinct.

  14. cespare commented on Sep 5, 2017

    @cespare
    Contributor

    Over in #16620 (comment) I described taking some of my sync.Cond-using code and altering it to use a channel instead. I found it to be fairly tricky to get right (though of course it's possible that I overlooked some simpler way).

  15. 63 remaining items

  16. moved this from Incoming to Active in Proposalson Jun 21, 2023
  17. rsc commented on Jun 21, 2023

    @rsc
    Contributor

    This proposal has been added to the active column of the proposals project
    and will now be reviewed at the weekly proposal review meetings.
    — rsc for the proposal review group

  18. rsc commented on Jun 28, 2023

    @rsc
    Contributor

    Even in a v2 of sync, I find it very difficult to believe we would delete Cond. Condition variables are a fundamental building block for many abstractions. It's true that most users should use higher-level abstractions, but sync also exists to provide these building blocks, both Cond and Mutex.

  19. rsc commented on Jun 28, 2023

    @rsc
    Contributor

    Worth noting that the sync.Cond doc comment is very good at pointing people at other things.

  20. moved this from Active to Likely Decline in Proposalson Jul 5, 2023
  21. rsc commented on Jul 5, 2023

    @rsc
    Contributor

    Based on the discussion above, this proposal seems like a likely decline.
    — rsc for the proposal review group

  22. rsc commented on Jul 5, 2023

    @rsc
    Contributor

    For what it's worth I wrote this code over the weekend. Maybe there is a nice way to write it with a channel instead of a sync.Cond, but the sync.Cond seems to say exactly what I mean. I started out trying to use a channel but got frustrated by having to think about channel synchronization despite the fact that I was already holding a mutex.

    Details
    
    // A WorkQ is a deduplicating unbounded work queue with a fixed number of workers.
    type WorkQ struct {
    	mu      sync.Mutex
    	queued  map[any]bool
    	work    map[any]func()
    	closed  bool
    	ready   sync.Cond // len(w.work) > 0 || w.closed && w.running == 0
    	done    int
    	running int
    	workers sync.WaitGroup
    	start   time.Time
    	next    time.Time
    }
    
    const reportPeriod = 1 * time.Minute
    
    // lock locks the queue and ensures that w.ready.L is initialized.
    func (w *WorkQ) lock() {
    	w.mu.Lock()
    	if w.queued == nil {
    		w.queued = make(map[any]bool)
    	}
    	if w.work == nil {
    		w.work = make(map[any]func())
    	}
    	if w.ready.L == nil {
    		w.ready.L = &w.mu
    	}
    	if w.start.IsZero() {
    		w.start = time.Now()
    		w.next = w.start
    	}
    }
    
    // unlock unlocks the queue and signals w.ready if a worker should be woken up.
    func (w *WorkQ) unlock() {
    	if len(w.work) > 0 || w.closed && w.running == 0 {
    		w.ready.Signal()
    	}
    	w.mu.Unlock()
    }
    
    // Start starts n workers.
    func (w *WorkQ) Start(n int) {
    	for i := 0; i < n; i++ {
    		w.workers.Add(1)
    		go w.run()
    	}
    }
    
    // run runs a single worker loop.
    func (w *WorkQ) run() {
    	defer w.workers.Done()
    	w.lock()
    	for {
    		for len(w.work) == 0 {
    			if w.closed && w.running == 0 {
    				w.unlock()
    				return
    			}
    			w.ready.Wait()
    		}
    		var do func()
    		for name, f := range w.work {
    			delete(w.work, name)
    			do = f
    			break
    		}
    
    		w.running++
    		w.unlock()
    		do()
    		w.lock()
    		w.running--
    
    		w.done++
    		now := time.Now()
    		if now.After(w.next) {
    			log.Printf("%v %d/%d done", time.Since(w.start).Round(1*time.Second), w.done, len(w.queued))
    			for w.next.Before(now) {
    				w.next = w.next.Add(reportPeriod)
    			}
    		}
    	}
    }
    
    // Wait closes the work queue and waits for all work to be completed.
    func (w *WorkQ) Wait() {
    	w.lock()
    	w.closed = true
    	w.unlock()
    	w.workers.Wait()
    }
    
    // Add adds work with the given name and function.
    // If work with the given id has been queued before, Add does nothing.
    // The id must be comparable so it can be used as a map key.
    func (w *WorkQ) Add(id any, f func()) {
    	w.lock()
    	if !w.queued[id] {
    		w.queued[id] = true
    		w.work[id] = f
    	}
    	w.unlock()
    }
    
    
  23. rsc commented on Jul 12, 2023

    @rsc
    Contributor

    No change in consensus, so declined.
    — rsc for the proposal review group

  24. moved this from Likely Decline to Declined in Proposalson Jul 12, 2023
  25. locked and limited conversation to collaborators on Jul 11, 2024
  26. removed this from Proposalson Jul 25, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    FrozenDueToAgeNeedsDecisionFeedback is required from experts, contributors, and/or the community before a change can be made.Proposal

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions