Worker Pool in Go: Building a Concurrent Bulk Email Sender
Producer/consumer with goroutines and channels: read a CSV, render templates and send emails with bounded concurrency and proper error handling.
On this page
Time to combine goroutines, channels and WaitGroups into a pattern you will use in real systems: a worker pool. We will build a bulk email sender that reads recipients from a CSV file and sends personalised emails using a fixed number of concurrent workers.
Why not one goroutine per email?
The first idea is to start a goroutine for every recipient:
for user := range users {
wg.Add(1)
go sendEmail(user, &wg)
}With 50,000 recipients this opens 50,000 SMTP connections at once. The mail server rate-limits or rejects you, memory spikes, and you may run out of file descriptors. You need bounded concurrency: a fixed number of workers pulling jobs from a queue.
The architecture
users.csv --> [producer] --jobs channel--> [worker 1] --+
--> [worker 2] --+--results channel--> [main: report]
--> [worker 3] --+jobs channel, then closes it.jobs, send the email, and report a result.The data and the template
name,email
User 1,user1@example.com
User 2,user2@example.comTo: {{.Email}}
Subject: Hello, {{.Name}}
Hi {{.Name}},
You have successfully received an email sent with Go goroutines and channels.Go's text/template fills in {{.Name}} and {{.Email}} from a struct. For a plain-text email use text/template; for HTML emails use html/template, which escapes values to prevent injection.
The producer
type User struct {
Name string
Email string
}
func produce(filename string, jobs chan<- User) error {
defer close(jobs) // tells workers there are no more jobs
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
records, err := csv.NewReader(f).ReadAll()
if err != nil {
return err
}
for _, rec := range records[1:] { // skip the header row
jobs <- User{Name: rec[0], Email: rec[1]}
}
return nil
}defer close(jobs) is essential. Without it, workers would wait for more jobs forever.
The workers
type Result struct {
Email string
Err error
}
func worker(id int, tmpl *template.Template, jobs <-chan User, results chan<- Result) {
for user := range jobs { // stops when jobs is closed and drained
var body bytes.Buffer
if err := tmpl.Execute(&body, user); err != nil {
results <- Result{user.Email, err}
continue
}
err := smtp.SendMail("localhost:1025", nil, "noreply@example.com",
[]string{user.Email}, body.Bytes())
results <- Result{user.Email, err}
log.Printf("worker %d handled %s", id, user.Email)
}
}Two important improvements over my original code:
main and share it. The original parsed email.tmpl from disk for every single email.Wiring it together
func main() {
tmpl := template.Must(template.ParseFiles("email.tmpl"))
jobs := make(chan User)
results := make(chan Result)
go func() {
if err := produce("users.csv", jobs); err != nil {
log.Println("producer:", err)
}
}()
const workers = 5
var wg sync.WaitGroup
for i := range workers {
wg.Add(1)
go func() {
defer wg.Done()
worker(i, tmpl, jobs, results)
}()
}
// Close results once every worker has finished
go func() {
wg.Wait()
close(results)
}()
var sent, failed int
for r := range results {
if r.Err != nil {
failed++
log.Printf("failed %s: %v", r.Email, r.Err)
continue
}
sent++
}
fmt.Printf("sent=%d failed=%d\n", sent, failed)
}The tricky part is who closes `results`. Workers cannot (there are several of them, and closing twice panics). So a small goroutine waits for all workers and then closes it, which ends the for r := range results loop in main.
I verified the pattern with a fake sender where one address fails:
sent: [a@example.com c@example.com d@example.com]
failed: [bad@example.com: mailbox unavailable]One failure no longer takes the whole batch down.
docker run -p 1025:1025 -p 8025:8025 axllent/mailpit) and open http://localhost:8025 to see every message.Production considerations
time.Ticker or golang.org/x/time/rate limiter shared by workers.context.Context so you can stop cleanly on shutdown (SIGTERM).csv.Reader.Read() row by row instead of ReadAll() keeps memory flat for huge files.Key takeaways
jobs; a separate goroutine closes results after wg.Wait().log.Fatal from a worker.html/template for HTML email.Last updated on