Five Ways to Build a Broadcast Notifier in Go

This article examines five Go implementations of a broadcast notifier—using sync.Cond, channels, context, sync.WaitGroup, and sync.RWMutex—detailing their code, execution flow, and trade‑offs so readers can understand how each primitive achieves notification broadcasting.

BirdNest Tech Talk
BirdNest Tech Talk
BirdNest Tech Talk
Five Ways to Build a Broadcast Notifier in Go

1. sync.Cond implementation

The original Jaana Dogan library wraps sync.Cond to provide a simple broadcast mechanism. The implementation defines a Broadcaster struct holding a mutex, a condition variable, and a boolean flag signaled. NewBroadcaster creates the struct, initializing the mutex and condition variable. The Go method launches a goroutine that locks the condition, waits until signaled becomes true, then runs the supplied function. Broadcast locks the mutex, sets signaled to true, unlocks, and calls cond.Broadcast() to wake all waiting goroutines.

package main

import (
    "sync"
)

type Broadcaster struct {
    mu       *sync.Mutex
    cond     *sync.Cond
    signaled bool
}

func NewBroadcaster() *Broadcaster {
    var mu sync.Mutex
    return &Broadcaster{
        mu:       &mu,
        cond:     sync.NewCond(&mu),
        signaled: false,
    }
}

func (b *Broadcaster) Go(fn func()) {
    go func() {
        b.cond.L.Lock()
        defer b.cond.L.Unlock()
        for !b.signaled {
            b.cond.Wait()
        }
        fn()
    }()
}

func (b *Broadcaster) Broadcast() {
    b.cond.L.Lock()
    b.signaled = true
    b.cond.L.Unlock()
    b.cond.Broadcast()
}

A unit test creates a broadcaster, launches two goroutines via Go, and then calls Broadcast to unblock both, verifying that each function runs and logs its completion.

package main

import (
    "sync"
    "testing"
)

func TestNewBroadcaster(t *testing.T) {
    b := NewBroadcaster()
    var wg sync.WaitGroup
    wg.Add(2)
    b.Go(func() { t.Log("function 1 finished"); wg.Done() })
    b.Go(func() { t.Log("function 2 finished"); wg.Done() })
    b.Broadcast()
    wg.Wait()
}

The author notes that using sync.Cond directly works, but Jaana Dogan’s wrapper makes the API more convenient.

The blocking logic belongs in the Go method.

The Broadcast method notifies all waiting goroutines.

2. channel implementation

A channel can provide a more concise broadcast. The struct holds a signal channel of empty structs. NewBroadcaster creates the channel with make(chan struct{}). The Go method launches a goroutine that receives from signal (blocking until the channel is closed) and then runs the supplied function. Broadcast simply closes the channel, causing all receivers to unblock.

package main

type Broadcaster struct {
    signal chan struct{}
}

func NewBroadcaster() *Broadcaster {
    return &Broadcaster{signal: make(chan struct{})}
}

func (b *Broadcaster) Go(fn func()) {
    go func() {
        <-b.signal
        fn()
    }()
}

func (b *Broadcaster) Broadcast() {
    close(b.signal)
}

Because a closed channel always yields the zero value, every waiting goroutine receives immediately after close, achieving broadcast notification.

3. context implementation

Since Go 1.7, the context package provides cancellation propagation, which can be repurposed for broadcasting. The struct stores a context.Context and its CancelFunc. NewBroadcaster creates a cancellable context. The Go method launches a goroutine that waits on <-b.ctx.Done() before invoking the function. Broadcast calls the stored cancel function, unblocking all waiting goroutines.

package broadcaster

import (
    "context"
)

type Broadcaster struct {
    ctx    context.Context
    cancel context.CancelFunc
}

func NewBroadcaster() *Broadcaster {
    ctx, cancel := context.WithCancel(context.Background())
    return &Broadcaster{ctx: ctx, cancel: cancel}
}

func (b *Broadcaster) Go(fn func()) {
    go func() {
        <-b.ctx.Done()
        fn()
    }()
}

func (b *Broadcaster) Broadcast() {
    b.cancel()
}

The author judges context to be a reasonable alternative because it cleanly propagates cancellation without extra synchronization primitives.

4. sync.WaitGroup implementation

A sync.WaitGroup can also serve as a broadcast. The struct holds an int32 done flag and a sync.WaitGroup named trigger. NewBroadcaster adds one to the wait group. In Go, each goroutine first checks the done flag; if already broadcast, it returns immediately. Otherwise it calls trigger.Wait() to block until the broadcast occurs, then runs the function. Broadcast atomically swaps done from 0 to 1 and calls trigger.Done(), releasing all waiting goroutines.

package main

import (
    "sync"
    "sync/atomic"
)

type Broadcaster struct {
    done    int32
    trigger sync.WaitGroup
}

func NewBroadcaster() *Broadcaster {
    b := &Broadcaster{}
    b.trigger.Add(1)
    return b
}

func (b *Broadcaster) Go(fn func()) {
    go func() {
        if atomic.LoadInt32(&b.done) == 1 {
            return
        }
        b.trigger.Wait()
        fn()
    }()
}

func (b *Broadcaster) Broadcast() {
    if atomic.CompareAndSwapInt32(&b.done, 0, 1) {
        b.trigger.Done()
    }
}

The key insight is that WaitGroup.Wait blocks until the counter reaches zero; by initializing the counter to one and decrementing it in Broadcast, all waiting goroutines are released simultaneously.

5. sync.RWMutex implementation

A read‑write mutex can emulate broadcast because a write lock blocks readers. The struct holds a pointer to sync.RWMutex. NewBroadcaster creates the mutex, immediately acquires the write lock, and returns the struct. The Go method spawns a goroutine that attempts to acquire a read lock; it blocks until the write lock is released. Broadcast unlocks the write lock, allowing all pending read‑lock attempts to succeed and the associated functions to run.

package main

import (
    "sync"
)

type Broadcaster struct {
    mu *sync.RWMutex
}

func NewBroadcaster() *Broadcaster {
    var mu sync.RWMutex
    mu.Lock() // acquire write lock to block readers
    return &Broadcaster{mu: &mu}
}

func (b *Broadcaster) Go(fn func()) {
    go func() {
        b.mu.RLock()
        defer b.mu.RUnlock()
        fn()
    }()
}

func (b *Broadcaster) Broadcast() {
    b.mu.Unlock() // release write lock, unblocking all readers
}

The author explains that because a write lock prevents any read lock from succeeding, releasing the write lock effectively broadcasts to all waiting goroutines.

These five approaches illustrate different trade‑offs: sync.Cond offers explicit condition handling; channels provide a simple close‑based signal; context integrates cancellation semantics; WaitGroup leverages its built‑in barrier; and RWMutex exploits lock ordering. Readers are invited to try each method and share any superior solutions.

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.

ConcurrencyGocontextsyncbroadcastchannel
BirdNest Tech Talk
Written by

BirdNest Tech Talk

Author of the rpcx microservice framework, original book author, and chair of Baidu's Go CMC committee.

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.