Fork–Join Pattern #
There is an old principle in computing: work that can be split into independent parts can be finished faster if those parts are processed in parallel. An array of a million elements does not have to be summed one by one from left to right — it can be split into four parts, each summed on a different core, then the four results summed into one. This is the essence of the Fork–Join Pattern: fork breaks a large task into smaller sub-tasks and runs them in parallel, join waits for all sub-tasks to finish and combines their results. This pattern is the backbone of concurrent divide-and-conquer algorithms — parallel merge sort, per-region image processing, map-reduce, word count over large datasets — all boil down to the same idea: split, parallelize, combine.
What Is the Fork–Join Pattern? #
The Fork–Join Pattern is a concurrency pattern with two phases that always appear together and cannot be separated.
flowchart TD
Main([Main Task]) -->|Fork| S1[Sub-task 1]
Main -->|Fork| S2[Sub-task 2]
Main -->|Fork| S3[Sub-task 3]
Main -->|Fork| S4[Sub-task 4]
S1 -->|partial result| Join([Join & Aggregate])
S2 -->|partial result| Join
S3 -->|partial result| Join
S4 -->|partial result| Join
Join --> Result([Final Result])| Phase | What Happens |
|---|---|
| Fork | The parent task splits the work into N sub-tasks and launches them all in parallel |
| Join | The parent task waits for all N sub-tasks to finish, then aggregates their results |
What distinguishes Fork–Join from merely “running many goroutines” is the explicit synchronization point: there is a clear moment where all parallel work must finish before execution can continue. In Go, this synchronization point is expressed with sync.WaitGroup.
Fork–Join suits embarrassingly parallel problems — where sub-tasks can run completely independently without needing to communicate with each other during execution. If sub-tasks depend on each other, you need another pattern like Pipeline or Producer-Consumer.
Basic Implementation: Parallel Sum #
The easiest case for understanding Fork–Join is summing a large array in parallel. This is a classic example that directly shows both phases clearly.
package main
import (
"fmt"
"sync"
)
// sumChunk sums one slice segment and sends the result to the channel
func sumChunk(nums []int, resultCh chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
sum := 0
for _, n := range nums {
sum += n
}
resultCh <- sum
}
func parallelSum(numbers []int, numWorkers int) int {
chunkSize := (len(numbers) + numWorkers - 1) / numWorkers
resultCh := make(chan int, numWorkers) // buffered: one slot per sub-task
var wg sync.WaitGroup
// FORK: split the array and run each part on a separate goroutine
for i := 0; i < len(numbers); i += chunkSize {
end := i + chunkSize
if end > len(numbers) {
end = len(numbers)
}
wg.Add(1)
go sumChunk(numbers[i:end], resultCh, &wg)
}
// Close the channel after all goroutines finish
go func() {
wg.Wait() // JOIN: wait for all sub-tasks
close(resultCh)
}()
// Aggregation: collect all partial results
total := 0
for partial := range resultCh {
total += partial
}
return total
}
func main() {
numbers := make([]int, 1_000_000)
for i := range numbers {
numbers[i] = i + 1
}
result := parallelSum(numbers, 8) // use 8 parallel sub-tasks
fmt.Printf("Total: %d\n", result)
}
Note that resultCh is declared as a buffered channel with capacity equal to the number of workers. This is important: without a buffer, sub-task goroutines would block when trying to write to the channel, and if the closing goroutine (wg.Wait() + close()) has not run yet, a deadlock occurs.
Recursive Fork–Join #
The real power of Fork–Join appears when applied recursively — every sub-task can fork again into smaller sub-sub-tasks. This is the structure used by merge sort, parallel quick sort, and concurrent tree traversal.
package main
import (
"fmt"
"sync"
)
const threshold = 500 // below this, do it sequentially
// parallelMergeSort sorts a slice recursively with Fork–Join
func parallelMergeSort(data []int, wg *sync.WaitGroup) {
if wg != nil {
defer wg.Done()
}
n := len(data)
if n <= 1 {
return
}
// If the size is small, finish sequentially (avoid goroutine overhead)
if n <= threshold {
sequentialSort(data)
return
}
mid := n / 2
left := make([]int, mid)
right := make([]int, n-mid)
copy(left, data[:mid])
copy(right, data[mid:])
var childWG sync.WaitGroup
childWG.Add(2)
// FORK: two sub-tasks run in parallel
go parallelMergeSort(left, &childWG)
go parallelMergeSort(right, &childWG)
// JOIN: wait for both sub-tasks to finish
childWG.Wait()
// Combine the results
merge(data, left, right)
}
func merge(dst, left, right []int) {
i, j, k := 0, 0, 0
for i < len(left) && j < len(right) {
if left[i] <= right[j] {
dst[k] = left[i]; i++
} else {
dst[k] = right[j]; j++
}
k++
}
for i < len(left) { dst[k] = left[i]; i++; k++ }
for j < len(right) { dst[k] = right[j]; j++; k++ }
}
func sequentialSort(data []int) {
// simple insertion sort for small chunks
for i := 1; i < len(data); i++ {
key := data[i]
j := i - 1
for j >= 0 && data[j] > key {
data[j+1] = data[j]
j--
}
data[j+1] = key
}
}
func main() {
data := []int{9, 3, 7, 1, 5, 8, 2, 6, 4, 0}
parallelMergeSort(data, nil)
fmt.Println("Sorted:", data)
}
The recursion visualization forms a binary tree:
flowchart TD
A["[9,3,7,1,5,8,2,6,4,0]"] -->|Fork| B["[9,3,7,1,5]"]
A -->|Fork| C["[8,2,6,4,0]"]
B -->|Fork| D["[9,3]"]
B -->|Fork| E["[7,1,5]"]
C -->|Fork| F["[8,2]"]
C -->|Fork| G["[6,4,0]"]
D -->|Join| B2["[3,9]"]
E -->|Join| B3["[1,5,7]"]
B2 -->|Merge| BJ["[1,3,5,7,9]"]
B3 -->|Merge| BJ
F -->|Join| C2["[2,8]"]
G -->|Join| C3["[0,4,6]"]
C2 -->|Merge| CJ["[0,2,4,6,8]"]
C3 -->|Merge| CJ
BJ -->|Merge| Result["[0,1,2,3,4,5,6,7,8,9]"]
CJ -->|Merge| ResultError Propagation from Sub-Tasks #
In the basic implementation above, sub-tasks cannot fail. In the real world — for example when processing files, calling APIs, or reading databases — sub-tasks can produce errors. Errors must be propagated to the parent task correctly.
package main
import (
"fmt"
"sync"
)
type PartialResult struct {
Value int
Err error
}
// processChunk processes one data segment and can produce an error
func processChunk(id int, data []int, resultCh chan<- PartialResult, wg *sync.WaitGroup) {
defer wg.Done()
// Simulated error on a specific chunk
if id == 2 {
resultCh <- PartialResult{Err: fmt.Errorf("chunk %d failed to process: corrupted data", id)}
return
}
sum := 0
for _, v := range data {
sum += v
}
resultCh <- PartialResult{Value: sum}
}
func forkJoinWithErrors(data []int, numChunks int) (int, []error) {
chunkSize := (len(data) + numChunks - 1) / numChunks
resultCh := make(chan PartialResult, numChunks)
var wg sync.WaitGroup
// FORK
for i := 0; i < numChunks; i++ {
start := i * chunkSize
if start >= len(data) {
break
}
end := start + chunkSize
if end > len(data) {
end = len(data)
}
wg.Add(1)
go processChunk(i, data[start:end], resultCh, &wg)
}
// JOIN
go func() {
wg.Wait()
close(resultCh)
}()
// Aggregate results and collect all errors
total := 0
var errs []error
for result := range resultCh {
if result.Err != nil {
errs = append(errs, result.Err)
continue
}
total += result.Value
}
return total, errs
}
func main() {
data := make([]int, 100)
for i := range data {
data[i] = i + 1
}
total, errs := forkJoinWithErrors(data, 5)
if len(errs) > 0 {
fmt.Println("Some chunks failed:")
for _, e := range errs {
fmt.Println(" -", e)
}
}
fmt.Printf("Total from successful chunks: %d\n", total)
}
Do not ignore errors from sub-tasks. A Fork–Join pattern that only collects “successful values” and silently discards errors is very dangerous — the final result will be wrong and nobody will know why. Always collect errors from all sub-tasks and report them to the caller, even if you decide to keep using the partial results from the successful sub-tasks.
Limiting Concurrency with a Semaphore #
Unbounded Fork–Join can spawn millions of goroutines if the data is very large. For large-scale data processing, we need to limit the number of simultaneously running sub-tasks using a semaphore — a buffered channel acting as a “permit” to run.
package main
import (
"fmt"
"sync"
"time"
)
// forkJoinBounded runs parallel tasks but limits concurrency
func forkJoinBounded(tasks []func() int, maxConcurrent int) []int {
results := make([]int, len(tasks))
sem := make(chan struct{}, maxConcurrent) // semaphore: max N active goroutines
var wg sync.WaitGroup
var mu sync.Mutex
// FORK with a concurrency limit
for i, task := range tasks {
wg.Add(1)
go func(idx int, t func() int) {
defer wg.Done()
sem <- struct{}{} // take a slot — blocks if full
defer func() { <-sem }() // release the slot when done
result := t()
mu.Lock()
results[idx] = result
mu.Unlock()
}(i, task)
}
// JOIN
wg.Wait()
return results
}
func main() {
tasks := make([]func() int, 20)
for i := range tasks {
id := i
tasks[i] = func() int {
time.Sleep(100 * time.Millisecond)
return id * id // square of id
}
}
// Only 4 tasks run simultaneously, even though there are 20 tasks
results := forkJoinBounded(tasks, 4)
fmt.Println("Results (20 tasks, max 4 concurrent):", results[:5], "...")
}
Visualization of how the semaphore controls concurrency:
flowchart LR
T1[Task 1] --> SEM{Semaphore\\nmax 4}
T2[Task 2] --> SEM
T3[Task 3] --> SEM
T4[Task 4] --> SEM
T5[Task 5] -.->|waiting for slot| SEM
T6[Task 6] -.->|waiting for slot| SEM
SEM -->|slot available| W1[Worker 1]
SEM -->|slot available| W2[Worker 2]
SEM -->|slot available| W3[Worker 3]
SEM -->|slot available| W4[Worker 4]Choosing the Right Granularity #
One of the most important decisions in Fork–Join is how small sub-tasks should be split. This is called granularity — and there are two extremes to avoid.
// ANTI-PATTERN: too fine-grained — one goroutine per element
// the goroutine overhead (stack, scheduler) is far more expensive than the computation
func tooFineGrained(data []int) int {
resultCh := make(chan int, len(data))
var wg sync.WaitGroup
for _, v := range data { // 1 million elements = 1 million goroutines ✗
wg.Add(1)
go func(n int) {
defer wg.Done()
resultCh <- n // goroutine overhead >> cost of the addition
}(v)
}
go func() { wg.Wait(); close(resultCh) }()
total := 0
for v := range resultCh { total += v }
return total
}
// ANTI-PATTERN: too coarse-grained — only one goroutine
// no parallelism at all
func tooCoarseGrained(data []int) int {
resultCh := make(chan int, 1)
go func() { // only 1 goroutine — no better than sequential ✗
sum := 0
for _, v := range data { sum += v }
resultCh <- sum
}()
return <-resultCh
}
// CORRECT: appropriate granularity — number of chunks = number of CPU cores
func wellGrained(data []int) int {
numWorkers := runtime.NumCPU() // ✓ match the core count
chunkSize := (len(data) + numWorkers - 1) / numWorkers
resultCh := make(chan int, numWorkers)
var wg sync.WaitGroup
for i := 0; i < len(data); i += chunkSize {
end := i + chunkSize
if end > len(data) { end = len(data) }
wg.Add(1)
go func(chunk []int) {
defer wg.Done()
sum := 0
for _, v := range chunk { sum += v }
resultCh <- sum
}(data[i:end])
}
go func() { wg.Wait(); close(resultCh) }()
total := 0
for partial := range resultCh { total += partial }
return total
}
Practical guidelines for determining granularity:
| Factor | Guideline |
|---|---|
| Goroutine count | Start at runtime.NumCPU(), measure, then adjust |
| Data size | The larger the data, the more chunks can be created |
| Cost per item | The more expensive (I/O, heavy computation), the smaller the optimal chunk |
| Recursion threshold | Define the boundary where recursion stops and switches to sequential |
Fork–Join vs Other Concurrency Patterns #
flowchart TD
Q{Type of concurrency\\nproblem?} --> Q1{Can the data\\nbe split independently?}
Q1 -- Yes --> Q2{Need results\\ncombined?}
Q1 -- No --> PC[Producer-Consumer\\nor Pipeline]
Q2 -- Yes --> FJ[Fork–Join ✓]
Q2 -- No --> FF[Fire-and-Forget\\nplain goroutine]
FJ --> Q3{Need to limit\\nconcurrency?}
Q3 -- Yes --> FJW[Fork–Join\\n+ Semaphore]
Q3 -- No --> FJP[Pure\\nFork–Join]| Aspect | Fork–Join | Worker Pool | Pipeline |
|---|---|---|---|
| Structure | Tree (split & combine) | Flat pool | Linear chain |
| Synchronization | At the explicit join point | Continuous (channel) | Per stage |
| Ideal for | Divide & conquer, aggregation | Task queues, rate limiting | Staged data transformation |
| Sub-tasks | Completely independent | Independent | Depend on the previous stage |
Anti-Patterns to Avoid #
// ✗ Fork without join — goroutine leak, results not collected
func noJoin(data []int) {
for _, chunk := range splitData(data) {
go processChunk(chunk) // ✗ no WaitGroup, no join
}
// the function returns before sub-tasks finish — results are lost
}
// ✓ Always have an explicit join before using the results
func withJoin(data []int) int {
resultCh := make(chan int, len(data))
var wg sync.WaitGroup
for _, chunk := range splitData(data) {
wg.Add(1)
go func(c []int) {
defer wg.Done()
resultCh <- process(c) // ✓ result sent to the channel
}(chunk)
}
go func() { wg.Wait(); close(resultCh) }() // ✓ explicit join
total := 0
for r := range resultCh { total += r }
return total
}
// ✗ Shared mutable state without synchronization — data race
var sharedTotal int
func racyFork(data []int) {
var wg sync.WaitGroup
for _, chunk := range splitData(data) {
wg.Add(1)
go func(c []int) {
defer wg.Done()
for _, v := range c {
sharedTotal += v // ✗ race condition — multiple goroutines write simultaneously
}
}(chunk)
}
wg.Wait()
}
// ✓ Each sub-task produces a partial result, combined at the end
func safeFork(data []int) int {
resultCh := make(chan int, 8)
var wg sync.WaitGroup
for _, chunk := range splitData(data) {
wg.Add(1)
go func(c []int) {
defer wg.Done()
local := 0
for _, v := range c { local += v } // ✓ local variable, no race
resultCh <- local
}(chunk)
}
go func() { wg.Wait(); close(resultCh) }()
total := 0
for r := range resultCh { total += r } // ✓ combine here, not in the goroutine
return total
}
// ✗ No recursion threshold — goroutines for size-1 data
func noThreshold(data []int) int {
if len(data) <= 1 { // ✗ should have a much larger threshold
return data[0]
}
// ... recursive fork
return 0
}
// ✓ A threshold prevents goroutine overhead for small data
func withThreshold(data []int) int {
if len(data) <= 1000 { // ✓ do it sequentially for small chunks
sum := 0
for _, v := range data { sum += v }
return sum
}
// ... recursive fork only for large data
return 0
}
Fork–Join Review Checklist #
TASK DESIGN:
□ Sub-tasks are truly independent — no shared mutable state between sub-tasks
□ The chunk size is chosen based on the core count and cost per item
□ There is a threshold to stop recursion and switch to sequential
SYNCHRONIZATION:
□ WaitGroup.Add() is always called before goroutines launch
□ WaitGroup.Done() is always deferred at the start of the goroutine
□ The results channel is always buffered (capacity ≥ number of sub-tasks)
□ The channel is closed after WaitGroup.Wait() finishes
ERROR HANDLING:
□ Errors from sub-tasks are collected, not silently discarded
□ All errors are reported to the caller after the join
□ The program decides explicitly: stop everything or continue with partial results
RESOURCE CONTROL:
□ The number of concurrent goroutines is bounded (semaphore) for large-scale data
□ No goroutine can leak after the join completes
□ Memory allocation per sub-task is accounted for on large datasets
TESTING:
□ Tested with go test -race to detect data races
□ Results are compared with the sequential version to verify correctness
□ Benchmarks are run to confirm a real speedup
Summary #
- Fork–Join has two inseparable phases — fork splits the work and launches parallel sub-tasks, join waits for all of them and aggregates the results.
sync.WaitGroupis the join mechanism in Go —Add()before goroutines launch,Done()deferred at the start of the goroutine,Wait()blocks until all finish.- The results channel must be buffered with capacity at least equal to the sub-task count — an unbuffered channel causes a deadlock because sub-tasks block before the join runs.
- Sub-tasks must be truly independent — no shared mutable state between sub-tasks; each sub-task produces a partial value that is safe to combine at the join point.
- Granularity determines efficiency — too fine (one goroutine per element) causes scheduler overhead that outweighs the parallel benefit; start with
runtime.NumCPU()chunks as a baseline.- A threshold is mandatory in recursion — stop forking and switch to sequential computation when the data size is below a certain boundary; goroutine overhead is not worth it for small data.
- Errors from sub-tasks must be collected, not discarded — use a result struct carrying both the value and the error, and aggregate both at the join point.
- A semaphore limits concurrency for large datasets — a buffered channel as a semaphore prevents goroutine explosions when processing very large amounts of data.
- Verify with
go test -race— concurrency issues in Fork–Join often go undetected in normal testing and only appear in production; the race detector is a mandatory tool.- Fork–Join is the foundation of concurrent divide and conquer — parallel merge sort, parallel map-reduce, and per-region image processing all use the same structure.