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])
PhaseWhat Happens
ForkThe parent task splits the work into N sub-tasks and launches them all in parallel
JoinThe 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| Result

Error 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:

FactorGuideline
Goroutine countStart at runtime.NumCPU(), measure, then adjust
Data sizeThe larger the data, the more chunks can be created
Cost per itemThe more expensive (I/O, heavy computation), the smaller the optimal chunk
Recursion thresholdDefine 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]
AspectFork–JoinWorker PoolPipeline
StructureTree (split & combine)Flat poolLinear chain
SynchronizationAt the explicit join pointContinuous (channel)Per stage
Ideal forDivide & conquer, aggregationTask queues, rate limitingStaged data transformation
Sub-tasksCompletely independentIndependentDepend 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.WaitGroup is the join mechanism in GoAdd() 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.

← Previous: Async Callback   Next: Read–Write Lock →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact