A goroutine is a lightweight concurrent function managed by the Golang runtime. You start one by placing the go keyword before a function call. The function then runs independently while the current function continues.
Goroutines are much cheaper than operating system threads, so servers can run many concurrent tasks such as request handling, background jobs, timers, queue consumers, and streaming work. They are lightweight, but they are not magic. Every goroutine still needs clear ownership, synchronization, and a way to finish.
package main
import (
"fmt"
"time"
)
func sendEmail(user string) {
time.Sleep(500 * time.Millisecond)
fmt.Println("email sent to", user)
}
func main() {
go sendEmail("asha@example.com")
fmt.Println("request finished")
time.Sleep(1 * time.Second)
}
The main function or caller does not automatically wait for goroutines. If the program exits before a goroutine finishes, that goroutine is stopped with the process. Use sync.WaitGroup when the caller must wait for a known number of tasks.
The pattern is simple: call Add before starting the goroutine, call Done when the goroutine finishes, and call Wait where the caller should block until all work is complete.
| Method | Purpose |
|---|---|
Add(n) | Increase the number of goroutines or tasks to wait for. |
Done() | Mark one task as finished. It is commonly deferred. |
Wait() | Block until the counter returns to zero. |
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id, "finished")
}(i)
}
wg.Wait()
fmt.Println("all workers complete")
}
When starting goroutines inside a loop, pass loop values into the goroutine as parameters. This makes each goroutine receive the value intended for that iteration.
Modern Golang versions improved loop variable behavior, but passing values explicitly is still clear, portable, and easy for learners to reason about.
for _, user := range users {
wg.Add(1)
go func(email string) {
defer wg.Done()
sendEmail(email)
}(user.Email)
}
A race condition happens when goroutines access the same data at the same time and at least one access writes. Race bugs are hard to reproduce because the result depends on timing.
Protect shared state with channels, mutexes, atomic operations, or by designing ownership so only one goroutine modifies a value. During development, run go test -race to catch many unsafe shared-memory mistakes.
package main
import "sync"
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
Do not start goroutines without knowing how they stop. Long-running services should use context cancellation, closed channels, deadlines, or worker shutdown signals.
A leaked goroutine can hold memory, file handles, network connections, or locks longer than expected. Design the stop path when you design the start path, especially in web servers and background workers.
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return
case job := <-jobs:
process(job)
}
}
}
Use goroutines for independent work that can truly run at the same time: handling connections, processing jobs, fetching multiple resources, or watching for events. Do not add goroutines just because a function looks slow; first understand the work, ownership, and cancellation rules.
A clean concurrent design usually has a small number of obvious coordination points. If many goroutines are writing shared state from many places, the design may need a worker, channel, mutex, or queue to make ownership clearer.
go to start a goroutine.
sync.WaitGroup to wait for a known number of concurrent tasks.
Explore 500+ free tutorials across 20+ languages and frameworks.