Implement a bounded blocking queue (producer-consumer) using sync.Cond, then using channels. Compare the two.
The Cond version uses a ring buffer and two condition variables that share one mutex. Put waits while the queue is full and Take waits while it is empty. Close broadcasts to both so that nobody blocks forever.
var ErrClosed = errors.New("queue closed")
type BlockingQueue[T any] struct {
mu *sync.Mutex
notEmpty, notFull *sync.Cond
buf []T
head, size int
closed bool
}
func NewBlockingQueue[T any](capacity int) *BlockingQueue[T] {
if capacity <= 0 {
panic("capacity must be > 0")
}
mu := &sync.Mutex{}
return &BlockingQueue[T]{mu: mu, notEmpty: sync.NewCond(mu),
notFull: sync.NewCond(mu), buf: make([]T, capacity)}
}
func (q *BlockingQueue[T]) Put(v T) error {
q.mu.Lock()
defer q.mu.Unlock()
for q.size == len(q.buf) && !q.closed {
q.notFull.Wait()
}
if q.closed {
return ErrClosed
}
q.buf[(q.head+q.size)%len(q.buf)] = v
q.size++
q.notEmpty.Signal()
return nil
}
func (q *BlockingQueue[T]) Take() (T, bool) {
q.mu.Lock()
defer q.mu.Unlock()
for q.size == 0 && !q.closed {
q.notEmpty.Wait()
}
var zero T
if q.size == 0 { // closed and drained
return zero, false
}
v := q.buf[q.head]
q.buf[q.head] = zero // drop reference for GC
q.head = (q.head + 1) % len(q.buf)
q.size--
q.notFull.Signal()
return v, true
}
func (q *BlockingQueue[T]) Close() {
q.mu.Lock()
q.closed = true
q.mu.Unlock()
q.notEmpty.Broadcast()
q.notFull.Broadcast()
}
The channel version is simply q := make(chan T, capacity). q <- v is Put, v, ok := <-q is Take, and close(q) is Close, which only the producer side may call. Wrap the operations in a select with ctx.Done() to get cancellation.
Comparison: channels are shorter, correct by construction, and composable with select, so timeouts and cancellation come for free. Cond.Wait cannot be cancelled or given a timeout. With channels, however, sending on a closed channel panics, and you cannot peek, drain in batches, or re-size. Cond gives you those extras: Len, priority ordering, "wait until the queue drops below a watermark", and closing from either side. Performance is similar. Channels are themselves a mutex plus wait queues inside the runtime. What the interviewer is looking for: Wait in a for loop, two separate conditions so that Signal wakes the right kind of waiter, and a correct close/drain semantic.
More on Classic Concurrency Coding Problems
- Q519Implement the dining philosophers problem in Go without deadlock or starvation. Compare resource ordering with an arbiter.
- Q520Bank transfer: two goroutines transfer money between accounts A->B and B->A with per-account mutexes. Why does it deadlock, and how does consistent lock ordering fix it?
- Q522Implement a reusable barrier (CyclicBarrier) that N goroutines wait on before moving to the next phase.
- Q523Implement a Future/Promise type in Go with Get(ctx) and error propagation using generics.
- Q524Implement a concurrent prime sieve with a daisy chain of goroutines. How many goroutines does it create, and how do you stop it without leaks?
- Q525Concurrently crawl URLs (the Tour of Go web crawler) with depth limits, deduplication and bounded parallelism.