Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for sync.Cond in Go: Efficient Goroutine Signaling Without Channels
Fahim Faisaal
Fahim Faisaal

Posted on • Edited on

     

sync.Cond in Go: Efficient Goroutine Signaling Without Channels

Concurrency in Go often brings us to channels, but there’s another synchronization primitive that may be exactly what you need in some scenarios:sync.Cond. If you’ve ever wondered why you’d reach for async.Cond instead of using channels alone, this article is for you. By the end, you’ll see a simple custom implementation, understand how the realsync.Cond works under the hood, and know when to choose it in your own projects.

Why Usesync.Cond?

Most Go developers instinctively reach for channels to coordinate goroutines: sending values, waiting for results, and so on. However, channels also carry data. What if all you need is a simple “wake-up” signal, without any payload? That’s exactly wheresync.Cond shines. It’s a lightweight way to block one or more goroutines until a condition becomes true, without transferring actual data.

Think of it like a broadcast system: goroutines can callWait() and suspend until somebody callsSignal() (wake a single waiter) orBroadcast() (wake all waiters). Underneath,sync.Cond doesn’t allocate a channel for each goroutine; instead, it maintains a small linked list of waiting goroutines, making it more memory-efficient when you just need signaling.

To illustrate, let’s build our own “poor man’s” condition variable using channels. Once you see the analogy, switching tosync.Cond becomes straightforward.

Building a CustomCond with Channels

Here’s a minimal struct that mimicssync.Cond by using a slice of channels and a mutex:

typeMyCondstruct{chs[]chanstruct{}musync.Mutex}
Enter fullscreen modeExit fullscreen mode
  • chs holds one channel per waiting goroutine.
  • mu ensures that appending or removing fromchs is safe.

Below are three methodsWait(),Signal(), andBroadcast() that emulate the core behavior ofsync.Cond.

func(c*MyCond)Wait(){c.mu.Lock()ch:=make(chanstruct{})c.chs=append(c.chs,ch)c.mu.Unlock()// wait for a signal<-ch}func(c*MyCond)Signal(){c.mu.Lock()deferc.mu.Unlock()iflen(c.chs)==0{return}// pick the first channel and send signalch:=c.chs[0]ch<-struct{}{}close(ch)// remove that channel from the slicec.chs=c.chs[1:]}func(c*MyCond)Broadcast(){c.mu.Lock()deferc.mu.Unlock()for_,ch:=rangec.chs{ch<-struct{}{}close(ch)}// reset the slice so no stale channels remainc.chs=make([]chanstruct{},0)}
Enter fullscreen modeExit fullscreen mode

What’s happening here?

  1. Wait()
  • Lock the mutex.
  • Create a new “signal” channel (ch).
  • Append it toc.chs.
  • Unlock, then block on<-ch.
  • When someone callsSignal() orBroadcast(), that channel is closed (and a value is sent), letting this goroutine resume.
  1. Signal()
  • Lock the mutex.
  • If there’s at least one waiting channel, pick the first.
  • Send a dummystruct{}{} onto it, thenclose(ch) so that any extra<-ch receives don’t hang.
  • Remove that channel from the slice.
  1. Broadcast()
  • Lock the mutex.
  • Loop over every waiting channel: send a dummy signal and close it.
  • Reset the slice to empty, so future waiters start fresh.

This simple approach shows how condition variables signal “ready to go” without passing actual payloads just signals.

Testing Our CustomMyCond

To seeMyCond in action, imagine spawning multiple worker goroutines that all wait for a signal. Then, from another goroutine, send one signal at a time. Finally, switch to broadcasting to wake everyone at once.

funcmain(){cond:=&MyCond{}wg:=sync.WaitGroup{}tasks:=5wg.Add(tasks)// add tasks count to the wait groupforid:=rangetasks{// create separate go routine for each taskgofunc(){deferwg.Done()fmt.Println("Waiting",id)cond.Wait()fmt.Println("Done",id)}()}gofunc(){forrangetasks{time.Sleep(1*time.Second)cond.Signal()// send signal to one routine in every 1 second}}()// wait for all routines to finishwg.Wait()}
Enter fullscreen modeExit fullscreen mode

The output

Signal Outputs Preview

When you run that, each goroutine hangs oncond.Wait(). Every second,Signal() wakes exactly one goroutine, until all 5 finish.

Switching to Broadcast

Instead of signaling one by one, you can broadcast after a delay to wake all of them at once:

// just change the signal to broadcast go routinegofunc(){// - for range tasks {// -    time.Sleep(1 * time.Second)// -    cond.Signal() // send signal to one routine in every 1 second// - }time.Sleep(2*time.Second)cond.Broadcast()// send signal to all routines at once after 2 seconds}()
Enter fullscreen modeExit fullscreen mode

The output

Cond Broadcast Preview

With this modification, all 5 goroutines sleep incond.Wait(). After two seconds, a singleBroadcast() wakes everybody, and you’ll see all “Done” messages in rapid succession.

ReplacingMyCond withsync.Cond

Once you’ve verified the custom behavior, swapping in the realsync.Cond is straightforward. Anywhere you wrote:

//  cond := &MyCond{}cond:=sync.NewCond(&sync.Mutex{})// cond.Wait()cond.L.Lock()cond.Wait()cond.L.Unlock()
Enter fullscreen modeExit fullscreen mode

You’ll get the same “Waiting … Done” behavior as before, but now backed by the official, optimized implementation.

Under the hood,sync.Cond doesn’t spin up a channel per waiter. Instead it uses an internalnotifyList a small doubly linked list of waiting goroutines ) and uses low‐level runtime primitives to park and wake goroutines. Each call toWait() enqueues the goroutine on that list.Signal() removes one link and wakes its goroutine;Broadcast() traverses the whole list and wakes every waiter. Memory-wise, this is much cheaper than allocating a channel per waiter, especially if you have hundreds or thousands of goroutines occasionally blocking on the same condition.

For a deeper dive, check out the source code forsync.Cond.

When to Choosesync.Cond Over Channels

Here are a few scenarios wheresync.Cond makes sense:

  1. Simple Signaling
    If goroutines only need a “go now” notification no data passedsync.Cond provides a clearer, more intent-expressive API than channels filled with dummy values.

  2. Broadcast Semantics
    Channels lack a built-in “wake everyone” primitive. You could loop over a list of channels, but managing that list is extra boilerplate.sync.Cond.Broadcast() does exactly what it says: wake all waiters at once.

  3. Lower Memory Overhead
    Each Go channel has internal buffers, mutexes, and so on. If you merely need a “signal,” channels allocate more than necessary. Async.Cond maintains a minimal linked list of waiters, which is especially noticeable if you have thousands of goroutines waiting occasionally.

  4. Condition-Based Waiting
    Often you combinesync.Cond with a separate shared value. For example:

mu.Lock()for!conditionMet{cond.Wait()}// now the condition is true; proceedmu.Unlock()
Enter fullscreen modeExit fullscreen mode

This “wait in a loop” pattern is common in concurrent structures like pools, queues, or buffered buffers. Channels alone can’t you’d have to juggle extra variables or useselect, which can get messy.

In short, if your goroutines coordinate purely on a boolean or numerical condition and you want to wake either one waiter or all waiterssync.Cond shines. If you need to send actual data, channels remain the more idiomatic choice.

Open-Source Promotions

  1. VarMQ - A Simplest Storage-Agnostic and Zero-dep Message Queue for Your Concurrent Go Program
  2. Vizb - An interactive go benchmarks visualizer

Top comments(5)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
parag_nandy_roy profile image
Parag Nandy Roy
CEO & Founder at Think to Share. Empowering Businesses with tailored Artificial Intelligence solutions. AI Software Enthusiast.
  • Location
    Kolkata, West Bengal, India
  • Work
    CEO & Founder at Think to Share
  • Joined

Super clear breakdown of sync.Cond ..

CollapseExpand
 
dotallio profile image
Dotallio
  • Joined

This makes me rethink how I handle pure signaling - didn't realize memory impact of channels vs sync.Cond until now!
Have you run into practical bugs from using channels instead of sync.Cond before?

CollapseExpand
 
fahimfaisaal profile image
Fahim Faisaal
I build tools that make life easier for others.
  • Joined
• Edited on• Edited

I ran theMyCond implementation with 1M tasks using the same code example mentioned above to measure its maximum RSS (resident set size) usage.

Command used (on Linux):

/usr/bin/time-v go run main.go
Enter fullscreen modeExit fullscreen mode
  • MyCond used:2,737,792 KB
  • sync.Cond used:2,609,184 KB

For simple signaling, nil channels are still a good choice. But when we need to deal with N routines for signal, thensync.Cond would be good choice

CollapseExpand
 
acorello profile image
Alessandro
  • Joined

I understand one is supposed to callcond.L.Lock() beforecond.Wait(). That's not required by your original implementation and seems more error prone. Do you know why they designed the API that way?

Also, the official documentation suggests using channels "for simple cases"! Which also sounds odd to me as I would find it easier to understand code usingsync.Cond than an equivalent implementation using channels.

For many simple use cases, users will be better off using channels than a Cond (Broadcast corresponds to closing a channel, and Signal corresponds to sending on a channel).
--pkg.go.dev/sync#Cond

CollapseExpand
 
fahimfaisaal profile image
Fahim Faisaal
I build tools that make life easier for others.
  • Joined

I understand one is supposed to call cond.L.Lock() before cond.Wait(). That's not required by your original implementation and seems more error prone.

On my custom implementation, I used the internal MX lock inside theWait method in a different flow. since I used channels I just needed lock it when I am pushing that channel inside the waitlist. I don't know why it feels error-prone to you. curious to know.

Do you know why they designed the API that way?

As I understood, if you noticetheir implementation they callc.L.Unlock() first and thenc.L.Lock() later again after calling theruntime_notifyListWait func.

The flow is

  1. cond.L.Lock() -> call from outside
  2. cond.L.Unlock() -> called inside theWait method
  3. cond.L.Lock() -> called inside theWait method
  4. cond.L.UnLock() -> call from outside

This is the reason we have to lock in first and unlock last explicitly.

Also, the official documentation suggests using channels "for simple cases"! Which also sounds odd to me as I would find it easier to understand code using sync.Cond than an equivalent implementation using channels.

For simple signaling, nil channels are still a good choice. But when we need to deal with N routines for signal, then sync.Cond would be good choice

Some comments may only be visible to logged-in visitors.Sign in to view all comments.

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

I build tools that make life easier for others.
  • Joined

More fromFahim Faisaal

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp