Thread Affinity in .NET: When CPU Pinning Helps and How to Use TPL

Thread affinity means restricting a thread to run only on certain CPU cores. Without it, the operating system’s scheduler can move a thread from one core to another as it balances the load across the machine. The idea behind pinning a thread is cache locality: a thread that stays on one core keeps its working data in that core’s L1 and L2 caches, while a thread that moves has to fetch that data again from L3 or main memory. In theory, that makes affinity an easy performance win for parallel code. In practice, it rarely is in .NET. The OS already tries to keep threads where their caches are, and most of the cache gains come from how you lay out and access data, not from where threads run. Several of the .NET APIs that come up when people search for “thread affinity” don’t do what their names suggest, either. This post covers what affinity actually is in .NET, when pinning is worth it, how to set it correctly, and what to do first.

Two meanings of “thread affinity” in .NET

The term means two different things, and mixing them up causes a lot of confusion:

  • CPU affinity is which processor cores a thread or process is allowed to run on. Most of this post is about CPU affinity.
  • Thread affinity of an object means an object can only be used from the thread that created it. WinForms and WPF controls, and COM objects in a single-threaded apartment, work this way. That’s why UI code has to marshal calls back to the UI thread. TaskScheduler.FromCurrentSynchronizationContext() exists for this kind of affinity. It runs tasks on the UI thread’s synchronization context and has nothing to do with CPU cores.

What the operating system already does

If you don’t set anything, the OS scheduler decides where each thread runs. This is sometimes called implicit binding. Windows and Linux both apply soft affinity. They prefer to keep a thread on the core where it last ran, or on a core that shares the same cache, and they move it only when that core is busy and another one is idle. The .NET runtime doesn’t move threads between cores itself. It schedules work items onto thread pool threads, and the OS decides which core runs those threads.

Explicit binding, also called hard affinity, replaces those decisions with a fixed mask. The thread may run only on the cores you list, even when all of them are busy and the rest of the machine is idle. That trade-off is the whole story. You get predictable placement and warm caches, and you give up the scheduler’s load balancing. On a machine shared with other work, that can make things slower, not faster.

When explicit pinning is worth it

Pinning makes sense in a small number of situations:

  • Latency-sensitive loops. Audio processing, market data handlers, game or simulation loops and real-time control code often pin a few dedicated threads to cores that nothing else uses. They care about consistent latency more than throughput.
  • Isolating workloads on one machine. You can keep a background batch job off the cores that serve user requests, or split a large server between two processes.
  • Benchmarking. Pinning reduces run-to-run variance when you compare two implementations. BenchmarkDotNet can set processor affinity for a benchmark job.
  • NUMA systems. On a multi-socket server, keeping a thread on the same node as the memory it uses avoids slower cross-socket memory access.

Pinning also has costs that are easy to miss:

  • A pinned thread can’t escape a core that’s busy with an interrupt or another process.
  • Two hot threads pinned to sibling hyper-threads share one physical core and its L1 and L2 caches, so they compete instead of helping each other. Environment.ProcessorCount counts logical processors, not physical cores.
  • An affinity mask is a 64-bit value, so on Windows machines with more than 64 logical processors you also have to deal with processor groups.
  • In containers, the cores you see may be limited by CPU quotas or cpusets that you don’t control.

Setting affinity in .NET

For a whole process

Process.ProcessorAffinity is a bitmask of the logical processors the process’s threads may run on. It’s supported on Windows and Linux, and it throws PlatformNotSupportedException on macOS:

using System.Diagnostics;

Process process = Process.GetCurrentProcess();
process.ProcessorAffinity = (IntPtr)0b_0000_1111;   // logical processors 0-3

Often you don’t need code at all. taskset -c 0-3 dotnet MyApp.dll on Linux, start /affinity F MyApp.exe on Windows and docker run --cpuset-cpus="0-3" all set the same mask from the outside. The runtime takes notice: Environment.ProcessorCount, the thread pool and the GC size themselves to the cores the process is allowed to use. You can also override the count the runtime sees with the DOTNET_PROCESSOR_COUNT environment variable.

Server GC creates one heap per logical processor and pins its GC threads to those processors by default. If you run several server-GC processes on one machine, configure them so they don’t fight over the same cores. The System.GC.HeapAffinitizeMask, System.GC.HeapCount and System.GC.NoAffinitize runtime settings control this.

For a single thread

The managed Thread class has no property for CPU affinity. ProcessThread.ProcessorAffinity and ProcessThread.IdealProcessor exist, but they work on OS threads, and .NET gives you no supported way to find the ProcessThread behind a given managed thread. The reliable approach is to have the thread pin itself with a native call:

using System.ComponentModel;
using System.Runtime.InteropServices;

static class NativeAffinity
{
    // Windows
    [DllImport("kernel32.dll")]
    private static extern nint GetCurrentThread();

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern nuint SetThreadAffinityMask(nint hThread, nuint mask);

    // Linux: pid 0 means the calling thread
    [DllImport("libc", SetLastError = true)]
    private static extern int sched_setaffinity(int pid, nint cpusetsize, ref ulong mask);

    public static void PinCurrentThread(int processor)
    {
        if (OperatingSystem.IsWindows())
        {
            if (SetThreadAffinityMask(GetCurrentThread(), (nuint)1 << processor) == 0)
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
        }
        else if (OperatingSystem.IsLinux())
        {
            ulong mask = 1UL << processor;   // simplified: covers processors 0-63
            if (sched_setaffinity(0, sizeof(ulong), ref mask) != 0)
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
        }
    }
}

Only pin threads you created yourself. Thread pool threads run work from the whole application, and the affinity stays on a pool thread after your work item finishes. Pinning one quietly restricts every unrelated task that runs on it later.

A small set of dedicated, pinned worker threads looks like this:

var work = new BlockingCollection<Action>();

for (int i = 0; i < 2; i++)
{
    int processor = i;
    var thread = new Thread(() =>
    {
        NativeAffinity.PinCurrentThread(processor);
        foreach (Action item in work.GetConsumingEnumerable())
        {
            item();
        }
    })
    {
        IsBackground = true,
        Name = $"pinned-worker-{processor}"
    };
    thread.Start();
}

work.Add(() => ProcessMarketData(batch));

Call work.CompleteAdding() when you shut down, and the threads finish once the queue is empty. If you’d like to schedule Tasks onto these threads, wrap the same loop in a custom TaskScheduler. Don’t build the worker loop out of TryDequeue and Thread.Sleep(1). That keeps every worker waking up about a thousand times a second while there’s no work, and it adds up to a millisecond of delay to every item.

What Thread.BeginThreadAffinity actually does

Thread.BeginThreadAffinity() and Thread.EndThreadAffinity() sound like they pin a thread to a core, but they don’t. They tell a CLR host, such as SQL Server’s CLR integration, that the code between the two calls depends on the identity of the physical operating system thread, for example because it holds a Win32 lock or uses thread-local OS state. The host should therefore not switch the managed thread to a different OS thread in the meantime. They have no effect on which CPU core the thread runs on, and in a normal .NET application they do nothing at all.

Task schedulers in the TPL

None of the built-in schedulers control CPU affinity. These are the ones that exist, and what they do:

  • TaskScheduler.Default runs tasks on the thread pool. Each worker thread has its own local queue, and idle workers steal work from busy ones. The scheduler that some articles call “WorkStealingTaskScheduler” is the behavior you already get by default.
  • ConcurrentExclusiveSchedulerPair gives you two schedulers that work together. Tasks on the concurrent scheduler can run in parallel, and tasks on the exclusive scheduler run one at a time with nothing else from the pair, much like a reader/writer lock for tasks. Its maxConcurrencyLevel parameter also makes it the built-in way to limit concurrency. “LimitedConcurrencyLevelTaskScheduler” was only a code sample in the documentation and in the old ParallelExtensionsExtras library, never part of .NET.
  • TaskScheduler.FromCurrentSynchronizationContext() runs tasks on the current synchronization context, which is normally the UI thread. This is the object-affinity meaning from the start of this post.
var pair = new ConcurrentExclusiveSchedulerPair(TaskScheduler.Default, maxConcurrencyLevel: 4);
var factory = new TaskFactory(pair.ConcurrentScheduler);

Task task = factory.StartNew(() => ProcessChunk(chunk));

There is no ThreadAffinityScheduler in .NET. System.Threading.Tasks.Extensions is the package that provides ValueTask for older target frameworks. If you need tasks to run on pinned cores, the dedicated-thread approach above is how to do it.

Better cache locality without pinning: matrix multiplication

Matrix multiplication is the classic example used to justify thread affinity, and it shows clearly where the real gains are. Here’s the textbook version:

for (int i = 0; i < n; i++)
    for (int j = 0; j < n; j++)
        for (int k = 0; k < n; k++)
            c[i, j] += a[i, k] * b[k, j];   // b is read down a column

The inner loop walks down a column of b. Each step jumps a whole row ahead in memory, so almost every read misses the cache, no matter which core the thread runs on. Pinning can’t fix that. Swapping the two inner loops makes every access go through memory in order. Using flat arrays instead of int[,], which is slower to index, and splitting the work across rows with Parallel.For finishes the job:

static int[] Multiply(int[] a, int[] b, int n)
{
    var c = new int[n * n];

    Parallel.For(0, n, i =>
    {
        int rowA = i * n;
        int rowC = i * n;
        for (int k = 0; k < n; k++)
        {
            int aik = a[rowA + k];
            int rowB = k * n;
            for (int j = 0; j < n; j++)
            {
                c[rowC + j] += aik * b[rowB + j];   // a, b and c are all read in order
            }
        }
    });

    return c;
}

Each iteration writes only its own row of c, so no locks are needed. On large matrices, the loop order alone often makes this several times faster than the textbook version. Measure it on your hardware. For real numerical work, use System.Numerics.Tensors (TensorPrimitives) or a BLAS library, which add SIMD and cache blocking on top.

The other common cache problem is false sharing. Two threads writing to different variables that happen to share a 64-byte cache line keep invalidating each other’s caches. A long[] of per-thread counters is the usual way this happens. Give each thread a local variable to accumulate in and combine the results at the end, which is what the localInit/localFinally overloads of Parallel.For are for. If the data really has to be shared, pad it so each thread’s slot sits on its own cache line.

Measure before you pin

Before setting any affinity, check whether thread migration is actually a problem. On Linux, perf stat -e cpu-migrations,context-switches dotnet MyApp.dll counts how often threads move between cores. On Windows, PerfView or Windows Performance Analyzer shows CPU usage and context switches per thread. Run a BenchmarkDotNet comparison with and without affinity on the same hardware you deploy to. If the migration count is low, or the benchmark shows no difference, the scheduler is already doing its job, and your time is better spent on data layout, allocations and algorithms.

Further reading

Process.ProcessorAffinity | Microsoft Learn

Thread.BeginThreadAffinity | Microsoft Learn

ConcurrentExclusiveSchedulerPair | Microsoft Learn

Runtime configuration options for garbage collection | Microsoft Learn

SetThreadAffinityMask | Win32 API

sched_setaffinity(2) | Linux manual page

Share...
 

Hamid Mosalla

Hi, I'm Hamid ("Arman"). I'm a software developer with 8+ years of experience in C#, .NET Core, Software Architecture and Web Development. I enjoy creating dev tools, contributing to open-source projects, and sharing insights on my blog. Outside of tech, I’m into indie cinema, classical music and abstract art.

 

2 thoughts on “Thread Affinity in .NET: When CPU Pinning Helps and How to Use TPL”

  1. Hey Hamid,
    Nice and comprehensive article! Just wondering though as I see that in the last example there is no usage of Thread.BeginThreadAffinity, was that missed or intended?
    Thanks!

    1. You’re right, I’ve modified the code, just remember that if you’re using managed code in .NET, thread affinity is usually not necessary. The Task Parallel Library (TPL) is more than capable of efficiently handling the parallel execution of tasks without the need for manual thread affinity management.

Leave a Reply

Your email address will not be published. Required fields are marked *