Writing Efficient and Fair Multi-Threaded Programs

Arpit Bhayani

Arpit Bhayani

Mar 19, 2023 • 7 min read

Play

Writing Efficient and Fair Multi-Threaded Programs

A common instinct when optimizing compute-intensive code is to parallelize execution across multiple threads. While spinning up worker threads invariably speeds up processing compared to a single-threaded loop, simply adding concurrency does not mean the system is operating at maximum efficiency.

To write high-performance concurrent software, we must evaluate two core dimensions:

  1. Correctness: Ensuring that shared state mutations are free from race conditions and data corruption via appropriate synchronization primitives.
  2. Fairness: Ensuring that the computational workload is distributed evenly across all active threads, preventing stragglers from stretching the overall wall-clock runtime.

Here, we examine the problem of counting all prime numbers between 11 and 100,000,000100,000,000, analyzing three sequential and parallel architectures to demonstrate how work allocation models impact CPU saturation and throughput.


The Problem: Counting Primes up to 100 Million

Given an upper bound MAX_INT = 100,000,000, the objective is to count the total quantity of prime numbers.

Primality Check Logic

A standard trial-division algorithm checks whether a number NN is prime by attempting division against odd integers up to N\sqrt{N}:

func checkPrime(n int) bool {
    if n <= 1 {
        return false
    }
    for i := 3; i*i <= n; i += 2 {
        if n%i == 0 {
            return false
        }
    }
    return true
}

Computational Characteristics

  • Computational cost grows with N\sqrt{N}: Testing whether 101101 is prime takes far fewer iterations than testing whether 99,999,98999,999,989 is prime.
  • Prime density drops: Primes are dense in lower ranges and sparse in higher ranges. Non-prime composite numbers exit early as soon as a small factor divides them, while large prime numbers force the loop to run all the way to N\sqrt{N}.

Consequently, the compute workload is non-uniform across the input domain.


Approach 1: The Sequential Baseline

A naive single-threaded implementation tests numbers sequentially from 33 to 100,000,000100,000,000:

var totalPrimeNumbers int64 = 1 // accounting for 2

func countPrimesSequential() {
    start := time.Now()
    for i := 3; i <= maxInt; i += 2 {
        if checkPrime(i) {
            totalPrimeNumbers++
        }
    }
    fmt.Printf("Found %d primes in %v\n", totalPrimeNumbers, time.Since(start))
}

Performance Profile

  • Execution Time: ~3 minutes 55 seconds (up to 4+ minutes depending on background OS CPU contention).
  • Resource Utilization: Exactly 1 CPU core is saturated at 100%, leaving all other cores completely idle.

Approach 2: Naive Multi-Threading via Static Range Partitioning

To parallelize the search across C=10C = 10 threads, the natural initial solution is static range chunking. The range [1,100M][1, 100\text{M}] is divided into 10 equal batches of 10M10\text{M} numbers each:

  • Thread 0: [1,10,000,000][1, 10,000,000]
  • Thread 1: [10,000,001,20,000,000][10,000,001, 20,000,000]
  • Thread 9: [90,000,001,100,000,000][90,000,001, 100,000,000]

Implementation

Shared state (totalPrimeNumbers) is protected using atomic increments (atomic.AddInt64) to guarantee correctness:

func worker(start, end int, wg *sync.WaitGroup, threadID int) {
    defer wg.Done()
    t0 := time.Now()
    
    for i := start; i <= end; i += 2 {
        if checkPrime(i) {
            atomic.AddInt64(&totalPrimeNumbers, 1)
        }
    }
    
    fmt.Printf("Thread %d [%d - %d] finished in %v\n", threadID, start, end, time.Since(t0))
}

The Straggler Problem and Unfairness

Running this version reduces execution time from ~4 minutes down to approximately 1 minute (or ~42-60 seconds depending on machine load)—roughly a 4x to 5x speedup. However, reviewing per-thread runtimes reveals significant imbalance:

Thread IDSub-rangeApproximate Time Taken
Thread 01 – 10M~18 seconds
Thread 110M – 20M~30 seconds
Thread 220M – 30M~37 seconds
Thread 990M – 100M~60 seconds
Thread 0 [========>                                         ]  18s (Idles for 42s!)
Thread 1 [=============>                                    ]  30s (Idles for 30s!)
Thread 2 [=================>                                ]  37s (Idles for 23s!)
...
Thread 9 [==================================================]  60s (Bottleneck)

Why Static Chunking Fails Optimality

Because higher numbers require scanning factors up to a significantly larger N\sqrt{N}, Thread 9 performs vastly more CPU cycles than Thread 0.

When Thread 0 finishes in 18 seconds, its assigned CPU core goes idle for the remaining 42 seconds while Thread 9 continues grinding. The entire program can only finish as fast as its slowest thread.


Approach 3: Dynamic Work Distribution (Fair Allocation)

To achieve true fairness, threads must not be constrained by pre-allocated ranges. Instead, workers dynamically fetch the next available piece of work on demand.

Architecture: Atomic Task Pulling

Rather than static ranges, a globally shared atomic counter currentNumber tracks progress. Each thread loops continuously, atomically claiming the next number, verifying it, and repeating until the range is exhausted.

sequenceDiagram
    autonumber
    participant W1 as Worker Thread 1
    participant W2 as Worker Thread 2
    participant AC as Atomic Counter (currentNumber)
    participant Res as Atomic Total Counter

    W1->>AC: FetchAndAdd(1)
    AC-->>W1: Returns 1,000,001
    W2->>AC: FetchAndAdd(1)
    AC-->>W2: Returns 1,000,002
    W1->>W1: Run checkPrime(1,000,001)
    W2->>W2: Run checkPrime(1,000,002)
    W1->>Res: atomic.AddInt64 (if prime)
    W1->>AC: FetchAndAdd(1) (Next Job)

Go Implementation

var (
    currentNumber     int64 = 2
    totalPrimeNumbers int64 = 0
    maxInt            int64 = 100_000_000
)

func doWork(wg *sync.WaitGroup, id int) {
    defer wg.Done()
    t0 := time.Now()

    for {
        // Atomically claim the next number
        n := atomic.AddInt64(&currentNumber, 1)
        if n > maxInt {
            break
        }

        if checkPrime(int(n)) {
            atomic.AddInt64(&totalPrimeNumbers, 1)
        }
    }

    fmt.Printf("Thread %d completed in %v\n", id, time.Since(t0))
}

func main() {
    concurrency := 10
    var wg sync.WaitGroup
    wg.Add(concurrency)

    start := time.Now()
    for i := 0; i < concurrency; i++ {
        go doWork(&wg, i)
    }

    wg.Wait()
    fmt.Printf("Checked up to %d, total primes: %d in %v\n", 
        maxInt, atomic.LoadInt64(&totalPrimeNumbers), time.Since(start))
}

Fairness and Performance Results

  • Per-Thread Duration: Every thread executes for virtually the same duration (~50.0 to 51.0 seconds).
  • Total Program Wall-Clock Time: Drops from ~60 seconds to ~51 seconds (an immediate ~15–20% performance improvement over static partitioning).
  • Zero Idle Cycles: No thread idles while other threads are working. Fast iterations (composite numbers or smaller integers) finish quickly, enabling workers to take on more items automatically.

Comparison Summary

MetricApproach 1: SequentialApproach 2: Static BatchesApproach 3: Dynamic Atomic Pulling
Concurrency1 thread10 threads10 threads
Work AllocationLinear sequenceStatic split (10M10\text{M} numbers/thread)Dynamic self-scheduling (atomic.Add)
Thread WorkloadsMonolithicUnfair (Thread 0: 18s, Thread 9: 60s)Fair (All threads finish in ~51s)
CPU UtilizationPoor (1 Core)Uneven (Cores drop out progressively)Maximum (All cores 100% busy throughout)
Total Runtime~235 seconds~60 seconds~51 seconds
Correctness GuardNone neededatomic.AddInt64 for resultsatomic.AddInt64 for counter + results

Key Architectural Takeaways

  1. Parallelism \neq Automatic Optimality: Adding threads prevents sequential bottlenecks, but naive batching often introduces tail latency due to the “slowest worker” constraint.
  2. Identify Asymmetric Workloads: If task duration depends on dynamic input characteristics (e.g., query complexity, document sizes, mathematical properties like N\sqrt{N}), static domain decomposition leads to worker starvation.
  3. Work Stealing and Dynamic Pulling: Adopting a pull-based or work-stealing pattern using low-overhead synchronization (such as atomic operations or concurrent queues) balances resource usage across all available CPU cores.
  4. Balance Contention with Granularity: While pulling individual numbers via atomic.AddInt64 worked well here, workloads with microsecond-level tasks should pull small batches (e.g., batches of 1,000 numbers) to amortize atomic bus-locking overhead while preserving fairness.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses