Simple Semaphore With a Buffered Channel in Go

Alex Woods

Alex Woods

May 02, 2024


You can pretty easily use buffered channels as a semaphore to simulate a worker pool in Go.

Buffered Channels

Let's do a quick review of buffered channels.

  • With unbuffered channels, both sends and receives block execution. They will only accept sends (chan <-) if there is a corresponding receive (<- chan) ready to receive the sent value (source).
  • With a buffered channel, it will accept a number of values without a receiver waiting, up until the capacity of the buffer.

I found this example from Naveen Ramanathan to be very helpful (source here).

package main

import (
    "fmt"
    "time"
)

func write(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
        fmt.Println("successfully wrote", i, "to ch")
    }
    close(ch)
}

func main() {
    ch := make(chan int, 2)
    go write(ch)
    time.Sleep(2 * time.Second)
    for v := range ch {
        fmt.Println("read value", v,"from ch")
        time.Sleep(2 * time.Second)
    }
}
successfully wrote 0 to ch
successfully wrote 1 to ch
read value 0 from ch
successfully wrote 2 to ch
read value 1 from ch
successfully wrote 3 to ch
read value 2 from ch
successfully wrote 4 to ch
read value 3 from ch
read value 4 from ch

Example

To acquire the semaphore, you send to the buffered channel. To release the semaphore, you receive from the buffered channel.

The data actually stored in the buffered channel doesn't really matter.

semaphore.go
package main

type Semaphore struct {
	bc chan struct{}
}

func NewSemaphore(n int) *Semaphore {
	return &Semaphore{bc: make(chan struct{}, n)}
}

func (s *Semaphore) Acquire() {
	s.bc <- struct{}{}
}

func (s *Semaphore) Release() {
	<-s.bc
}

Then use it:

main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	s := NewSemaphore(3)

	for i := 0; i < 20; i++ {
		s.Acquire()
		go func(i int) {
			defer s.Release()
			expensiveThing(i)
		}(i)
	}

}

func expensiveThing(i int) {
	time.Sleep(1 * time.Second)

	fmt.Println("Expensive thing done", i)
}

asdf

There is, however, a problem here. The semaphore constrains the number of goroutines that are doing the expensive thing at once, but it doesn't make the main goroutine wait for all the worker goroutines to finish.

In fact if you watch the output gif closely, you'll realize it doesn't print out "Expensive thing done 18" (or 19).

Let's fix that.

main.go
package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	s := NewSemaphore(3)
	wg := sync.WaitGroup{}

	for i := 0; i < 20; i++ {
		wg.Add(1)
		s.Acquire()
		go func(i int) {
			defer wg.Done()
			defer s.Release()
			expensiveThing(i)
		}(i)
	}

	wg.Wait()
}

func expensiveThing(i int) {
	time.Sleep(1 * time.Second)

	fmt.Println("Expensive thing done", i)
}

Want to know when I write a new article?

Get new posts in your inbox