Architecture · Performance Lab

Thread-per-Core Architecture

Thread-per-core is an ownership discipline, not a thread count: each core exclusively owns a partition of state, trading a shared lock for cross-core handoff — and neither one automatically balances load or survives an unpinned scheduler.

Performance question and hypothesis

Question: can ownership partitioning remove synchronization from the hot path, one core at a time?

Hypothesis: when each thread exclusively owns its partition of state, the hot path needs no lock and no atomic read-modify-write, so per-core throughput scales with core count where a shared worker pool's collapses — the costs don't vanish, they move to cross-core handoff and load imbalance.

What would disprove it: if the partitioned design's aggregate throughput failed to scale versus the shared-pool baseline, or if its hot-path per-operation cost still carried contention signatures (e.g. false sharing between partitions), or if a perfectly balanced load showed the same win as a skewed one, the model's claims would be wrong.

Learning objective

Explain thread-per-core as an ownership and execution model, contrast it against a shared worker pool, show how partitioned ownership eliminates lock contention but introduces cross-core handoff cost, demonstrate that partitioning alone does not fix load imbalance, explain the affinity/scheduler and NUMA caveats that determine whether the model's assumptions hold on real hardware, and show bounded-queue backpressure as the honest response to overload.

Prerequisites

  • The SPSC Ring Buffer lab — cross-core handoff below is exactly one SPSC ring buffer per core-to-core link.
  • The CAS Contention and Backoff lab — the shared worker pool's lock contention below is the same cost that lab describes, via a lock rather than a bare CAS loop.

Theory

Thread-per-core is an ownership model, not a thread count

"One thread per logical CPU" describes a number. Thread-per-core describes a discipline: each thread exclusively owns a partition of the application's state for its entire lifetime, and no other thread ever touches that partition directly. A program that spawns one thread per core but still lets any thread touch any shared data through a lock is a worker pool sized to the core count, not a thread-per-core architecture.

Shared worker pool vs. owned-state execution

Shared worker pool: N worker threads, all able to process any request, coordinating through shared state protected by a lock. Simple and flexible — but every request touching shared state pays the lock's cost, and only one can hold that lock at a time, regardless of how many workers exist.

Thread-per-core ownership: each core's thread owns a disjoint partition and only that thread ever touches it. No lock, because there is no sharing — the "single-writer alternative" from the CAS Contention lab, applied as the primary architecture. This buys real parallelism at the cost of needing an explicit mechanism for requests that arrive on the wrong core.

Cross-core handoff

A request for partition P arriving on a core that isn't P's owner must be handed off — never processed directly by the wrong core. The standard mechanism is one bounded SPSC channel per ordered pair of cores, using the same reservation → publication → read → acknowledgement discipline the SPSC Ring Buffer lab establishes. Handoff is not free: it costs at least one extra scheduling round through the channel.

Hot partitions: ownership does not equal balance

Partitioning by owner does not guarantee even traffic distribution. If one key is disproportionately popular, its owning core's inbox grows while every other core sits idle — the same total capacity exists but cannot spill onto underused cores the way it could in a shared pool. Repartitioning, splitting a hot key, or accepting bounded queueing/backpressure on the hot core are the standard responses.

Backpressure

Every core's inbox is bounded, exactly like the SPSC Ring Buffer lab's capacity. When full, reject (or have the sender block) rather than let the queue grow without bound — an unbounded queue under sustained overload only converts memory pressure into unbounded latency before failing anyway.

Affinity and scheduler caveats

Thread-per-core's zero-contention argument assumes each owning thread keeps running on (approximately) the same physical core. This is not automatic: CPU affinity support differs by OS — Linux exposes sched_setaffinity/taskset fairly directly; macOS does not expose hard affinity to user processes (THREAD_AFFINITY_POLICY is only a hint); Windows exposes SetThreadAffinityMask with its own quirks. Containers and cgroups add another layer — CPU quotas and cgroup CPU-sets interact with affinity, and orchestrators may migrate containers across hosts entirely outside the application's control. Even with affinity requested, the OS scheduler can still migrate a thread for priority, power, or thermal reasons. A migration does not break correctness — the thread still owns the same partition — it degrades performance by losing cache locality on the old physical core.

NUMA caveats

On multi-socket (NUMA) hardware, memory attached to one socket is more expensive for a core on a different socket to access. Thread-per-core's benefit compounds with NUMA-aware placement — pin a partition's thread to a core and allocate that partition's memory on the same NUMA node — but this lab's model does not simulate NUMA distance or memory placement. A production system needs an explicit memory allocation strategy (numactl, libnuma) in addition to CPU affinity.

Conceptual model

The interactive model below fixes 4 cores and a small, hand-authored sequence of request arrivals so each mechanism is reachable in a handful of steps. "Turns" are a step count, not measured time. The model does not simulate CPU affinity, NUMA distance, or the OS scheduler itself — the "Scheduler migration" scenario injects a single migration event to make its consequence concrete, not to reproduce real scheduler behaviour. See "Benchmark methodology" below for real, disclosed timing data.

Try it

Interactive core model

Conceptual model

A deterministic, hand-scripted sequence of request arrivals across 4 cores — not a scheduler or timing simulation. See "Limitations of this model" in the theory above for what this leaves out.

Four requests, four "workers" — but every request touches the same shared state, so each one still needs the shared-state lock.

Cores

Scenario
Shared worker pool
Step
0 of 4
Total requests
0
Shared-lock acquisitions
0
Cross-core handoffs
0
Rejected (backpressure)
0
Scheduler migrations
0
Shared queue depth
0

Event log

  1. Initial state — no requests have arrived yet.

Java and Rust

Owned partition (no synchronization)

public final class PartitionedCounter {
    private long value;
    public long increment() { return ++value; }
    public long get() { return value; }
}

One instance per core/partition — correct only because exactly one thread ever calls increment() on it.

Shared counter pool (the baseline being contrasted)

public final class SharedCounterPool {
    private final long[] counters;
    private final ReentrantLock lock = new ReentrantLock();

    public SharedCounterPool(int partitions) { counters = new long[partitions]; }

    public long increment(int partition) {
        lock.lock();
        try {
            return ++counters[partition];
        } finally {
            lock.unlock();
        }
    }
}

Any worker thread may increment any partition — but only one increment proceeds at a time, because they all serialize on the same lock, regardless of how many worker threads exist.

JMH benchmark

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(1)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class ThreadPerCoreBenchmark {
    private static final int PARTITIONS = 4;
    private final SharedCounterPool sharedPool = new SharedCounterPool(PARTITIONS);

    @Benchmark @Threads(4)
    public long sharedPoolIncrement() {
        int partition = ThreadLocalRandom.current().nextInt(PARTITIONS);
        return sharedPool.increment(partition);
    }

    @State(Scope.Thread)
    public static class OwnedState {
        final PartitionedCounter counter = new PartitionedCounter();
    }

    @Benchmark @Threads(4)
    public long ownedPartitionIncrement(OwnedState state) {
        return state.counter.increment();
    }
}

The executable benchmark and correctness suite are used internally to produce the evidence shown by this laboratory.

Benchmark methodology

Partitioned per-thread ownership is measured against a shared-pool baseline with JMH and a matching Rust harness; a correctness gate runs before any timing is trusted.

Awaiting native-Linux measurement

The benchmark implementation and correctness checks exist, but no canonical native-Linux evidence has been imported and reviewed. No publication-grade performance numbers are shown yet. Canonical results for this laboratory are collected on the dedicated native-Linux benchmark host with explicit physical-CPU placement, full environment capture, correctness gates, independent JVM forks, and profiler evidence where required.

Common mistakes

  • Calling a design "thread-per-core" because it spawns one thread per CPU, while still routing every request through shared, lock-guarded state — that's a worker pool sized to the core count, not ownership.
  • Assuming partitioning alone balances load — a skewed key distribution still overloads one partition's owning core while others idle (see "Hot partitions").
  • Processing a request directly on the "wrong" core to avoid handoff latency — this silently reintroduces the shared-mutable-state problem ownership was meant to eliminate.
  • Assuming CPU affinity is a hard guarantee on every platform — macOS in particular treats it only as a hint the scheduler may ignore.
  • Letting a core's inbox grow unboundedly under overload instead of rejecting — this converts memory pressure into unbounded latency rather than failing fast.

When thread-per-core is the right architecture

Use thread-per-core when

  • State can be genuinely partitioned by a stable key, and most operations only ever touch one partition.
  • The workload is latency-sensitive enough that lock contention's tail-latency cost is unacceptable.
  • You can operationally commit to CPU affinity, NUMA-aware allocation, and monitoring for hot partitions.

Don't reach for it when

  • Operations routinely need to touch multiple partitions atomically — cross-partition transactions reintroduce coordination the model is meant to avoid.
  • The key distribution is (or may become) highly skewed and repartitioning isn't feasible — see "Hot partitions."
  • The deployment environment (containers, oversubscribed hosts, platforms without hard affinity) can't actually guarantee core-level placement.
  • The operational complexity of per-core state, handoff channels, and topology-aware deployment isn't justified by the workload's latency requirements.

Investigation task

Using the lab's Java or Rust implementation :

  1. Run the benchmark and record the shared-pool vs. owned-partition throughput ratio on your own hardware.
  2. Vary the thread/partition count (2, 4, 8) and re-run — observe how the shared pool's throughput changes (or doesn't) as thread count grows, versus the owned-partition case.
  3. If your OS supports it, pin the benchmark process to specific cores (e.g. taskset on Linux) and re-run — note whether this changes the owned-partition result, and by how much.
  4. In the interactive model, work through "Hot partition" and record the step at which core 1 first rejects a request — then work through "Backpressure" and compare how quickly rejection occurs with a smaller burst against a single core.
  5. Write down your CPU model and core count, your measured numbers, and explain any difference from the disclosed numbers above.

Accessibility notes

  • The interactive model is fully keyboard operable: the scenario selector and the Java/Rust code tabs are roving-tabindex tablists (arrow keys, Home/End); every step/reset control is a native <button>.
  • Every state change is announced to screen readers via an aria-live="polite" region, in addition to the visible per-core status badges and the state inspector table — state is never colour-only.
  • This page has no auto-playing or looping animation; step transitions resolve instantly, so there is nothing beyond the site-wide CSS rule to gate on prefers-reduced-motion.
  • With JavaScript disabled, this page's theory, Java and Rust code, benchmark methodology, trade-offs and sources remain fully readable — only the interactive model and the graded quiz require script.

Review questions

Self-check before or after the graded quiz below:

  1. Why does a shared worker pool's throughput not scale with worker count, even though many workers are available?
  2. What specific cost does cross-core handoff add that direct, same-core dispatch does not?
  3. Why doesn't partitioning state by owner automatically balance load across cores?
  4. Give one reason CPU affinity requested by an application might not actually be honored by the OS.

Quiz

Exercises

One diagnosis exercise and one implementation exercise, each with success criteria. Hints and solutions are collapsed — attempt the exercise first. The executable benchmark and correctness suite are used internally to produce the evidence shown by this laboratory.

1. Diagnosis — partitioned, and still not scaling

Per-thread partitions store 3 × 8-byte stat slots contiguously in one big array; 8 threads barely reach 2× single-thread throughput with no locks anywhere and all cores at ~100%. Explain why per-thread slots are not per-thread cache lines, quantify the collision for this layout, and give two independent fixes preserving exact counts. Success criteria: you name the mechanism, count partitions per 64-byte line, and your fixes keep the final counts identical.

Solution sketch

24 bytes per partition means ~2.7 partitions — three different owner threads — share each 64-byte line: textbook false sharing, invisible to lock profiling and utilization graphs. Fixes: pad/align each partition's slots to a line boundary, or move hot slots into per-thread blocks aggregated only on read. Ownership must be partitioned in the layout, not just the index space.

2. Implementation — make imbalance visible, then bound it

Add a skewed-load scenario (80% of operations to one partition) and the simplest mitigation that doesn't reintroduce sharing: bounded queues with explicit backpressure between ingress and partition owners. Success criteria (measure, don't assert): nothing is silently lost (processed = offered − explicitly rejected); the skewed run shows the hot owner saturating while others idle (record the per-owner spread); queue depth is provably capped under overload; and you can state what partitioning cannot fix — one partition's ceiling is one core.

Sources