I'm attempting to write a simple worker pool with goroutines.
- Is the code I wrote idiomatic? If not, then what should change?
- I want to be able to set the maximum number of worker threads to 5 and block until a worker becomes available if all 5 are busy. How would I extend this to only have a pool of 5 workers max? Do I spawn the static 5 goroutines, and give each the
work_channel
?
code:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
func worker(id string, work string, o chan string, wg *sync.WaitGroup) {
defer wg.Done()
sleepMs := rand.Intn(1000)
fmt.Printf("worker '%s' received: '%s', sleep %dms\n", id, work, sleepMs)
time.Sleep(time.Duration(sleepMs) * time.Millisecond)
o <- work + fmt.Sprintf("-%dms", sleepMs)
}
func main() {
var work_channel = make(chan string)
var results_channel = make(chan string)
// create goroutine per item in work_channel
go func() {
var c = 0
var wg sync.WaitGroup
for work := range work_channel {
wg.Add(1)
go worker(fmt.Sprintf("%d", c), work, results_channel, &wg)
c++
}
wg.Wait()
fmt.Println("closing results channel")
close(results_channel)
}()
// add work to the work_channel
go func() {
for c := 'a'; c < 'z'; c++ {
work_channel <- fmt.Sprintf("%c", c)
}
close(work_channel)
fmt.Println("sent work to work_channel")
}()
for x := range results_channel {
fmt.Printf("result: %s\n", x)
}
}
You can implement a counting semaphore to limit goroutine concurrency.
This is the general design used to limit the number of workers. You can of course change location of releasing/acquiring of tokens to fit your code.
Your solution is not a worker goroutine pool in any sense: your code does not limit concurrent goroutines, and it does not "reuse" goroutines (it always starts a new one when a new job is received).
Producer-consumer pattern
As I posted at Bruteforce MD5 Password cracker, you can make use of the producer-consumer pattern. You could have a designated producer goroutine that would generate the jobs (things to do / calculate), and send them on a jobs channel. You could have a fixed pool of consumer goroutines (e.g. 5 of them) which would loop over the channel on which jobs are delivered, and each would execute / complete the received jobs.
The producer goroutine could simply close the
jobs
channel when all jobs were generated and sent, properly signalling consumers that no more jobs will be coming. Thefor ... range
construct on a channel handles the "close" event and terminates properly. Note that all jobs sent before closing the channel will still be delivered.This would result in a clean design, would result in fixed (but arbitrary) number of goroutines, and it would always utilize 100% CPU (if # of goroutines is greater than # of CPU cores). It also has the advantage that it can be "throttled" with the proper selection of the channel capacity (buffered channel) and the number of consumer goroutines.
Note that this model to have a designated producer goroutine is not mandatory. You could have multiple goroutines to produce jobs too, but then you must synchronize them too to only close the
jobs
channel when all producer goroutines are done producing jobs - else attempting to send another job on thejobs
channel when it has already been closed results in a runtime panic. Usually producing jobs are cheap and can be produced at a much quicker rate than they can be executed, so this model to produce them in 1 goroutine while many are consuming / executing them is good in practice.Handling results:
If jobs have results, you may choose to have a designated result channel on which results could be delivered ("sent back"), or you may choose to handle the results in the consumer when the job is completed / finished. This latter may even be implemented by having a "callback" function that handles the results. The important thing is whether results can be processed independently or they need to be merged (e.g. map-reduce framework) or aggregated.
If you go with a
results
channel, you also need a goroutine that receives values from it, preventing consumers to get blocked (would occur if buffer ofresults
would get filled).With
results
channelInstead of sending simple
string
values as jobs and results, I would create a wrapper type which can hold any additional info and so it is much more flexible:Note that the
Job
struct also wraps the result, so when we send back the result, it also contains the originalJob
as the context - often very useful. Also note that it is profitable to just send pointers (*Job
) on the channels instead ofJob
values so no need to make "countless" copies ofJob
s, and also the size of theJob
struct value becomes irrelevant.Here is how this producer-consumer could look like:
I would use 2
sync.WaitGroup
values, their role will follow:The producer is responsible to generate jobs to be executed:
When done (no more jobs), the
jobs
channel is closed which signals consumers that no more jobs will arrive.Note that
produce()
sees thejobs
channel as send only, because that's what the producer needs to do only with that: send jobs on it (besides closing it, but that is also permitted on a send only channel). An accidental receive in the producer would be a compile time error (detected early, at compile time).The consumer's responsibility is to receive jobs as long as jobs can be received, and execute them:
Note that
consume()
sees thejobs
channel as receive only; consumer only needs to receive from it. Similarly theresults
channel is send only for the consumer.Also note that the
results
channel cannot be closed here as there are multiple consumer goroutines, and only the first attempting to close it would succeed and further ones would result in runtime panic!results
channel can (must) be closed after all consumer goroutines ended, because then we can be sure no further values (results) will be sent on theresults
channel.We have results which need to be analyzed:
As you can see, this also receives results as long as they may come (until
results
channel is closed). Theresults
channel for the analyzer is receive only.Please note the use of channel types: whenever it is sufficient, use only a unidirectional channel type to detect and prevent errors early, at compile time. Only use bidirectional channel type if you do need both directions.
And this is how all these are glued together:
Example output:
Here is an example output:
As you can see, results are coming and getting analyzed before all the jobs would be enqueued:
Try the complete application on the Go Playground.
Without a
results
channelCode simplifies significantly if we don't use a
results
channel but the consumer goroutines handle the result right away (print it in our case). In this case we don't need 2sync.WaitGroup
values (the 2nd was only needed to wait for the analyzer to complete).Without a
results
channel the complete solution is like this:Output is "like" that of with
results
channel (but of course execution/completion order is random).Try this variant on the Go Playground.