Context switches are not free. But how expensive are they? I wrote a small program to find out, and I’m sharing the program and its results here.

I focused on purely context switches (no work is actually performed between context switches). So it’s not a real-world scenario, but it really brings out the hidden costs. Below are the results 500,000 context switches performing no work between each one.

Executing 500000 work cycles of 0 iterations each, in different ways...
Scenario            Total time (ms)     Time per unit (µs)
No-switch           0                   0.0002
Async w/o yield     67                  0.0353
Async w/ yield      664                 0.349
Thread switches     5215                2.7412

Notice how with each kind, the order of magnitude of the overhead increases. The code below will help you understand what each each scenario name actually means. Then we add a bit of work (counting to 500) per context switch, which is closer to a possible real-world work load (although relatively lightweight) that might occur for a given context:

Executing 500000 work cycles of 500 iterations each, in different ways...
Scenario            Total time (ms)     Time per unit (µs)
No-switch           380                 0.1998
Async w/o yield     368                 0.1935
Async w/ yield      832                 0.4374
Thread switches     5185                2.7257

Suddenly no context switch and async methods all share an order of magnitude, while thread switches still takes significantly longer. In fact closely comparing shows that Async w/o yield is faster than no switch at all. This of course is ludicrous and can be written off as noise. But several runs produced the same result, so we can glean from this that when doing even a small amount of work per context switch, that the no-yield async method adds insignificant overhead.

Following is the application that produced the above results.

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

class Program {
	const int unitSize = 500;
	const int workSize = 500000;
	const string spacing = "{0,-20}{1,-20}{2,-20}";

	private static void Main(string[] args) {
		Console.WriteLine("Executing {0} work cycles of {1} iterations each, in different ways...", workSize, unitSize);

		Console.WriteLine(spacing, "Scenario", "Total time (ms)", "Time per unit (μs)");
		Scenario("No-switch", DoSync);
		Scenario("Async w/o yield", DoAsyncNoYield);
		Scenario("Async w/ yield", DoAsyncWithYield);
		Scenario("Thread switches", ThreadSwitch);
	}

	static void Scenario(string name, Action operation) {
		GC.Collect();
		operation(); // warm it up
		var timer = Stopwatch.StartNew();
		operation();
		timer.Stop();
		Console.WriteLine(spacing, name, timer.ElapsedMilliseconds, MicroSecondsPerItem(timer));
	}

	static void ThreadSwitch() {
		int workRemaining = workSize;
		var evt = new AutoResetEvent(true);
		ThreadStart worker = () => {
			while (workRemaining > 0) {
				evt.WaitOne();
				workRemaining--;
				WorkUnit();
				evt.Set();
			}
		};

		var threads = new Thread[Environment.ProcessorCount];
		for (int i = 0; i < threads.Length; i++) {
			threads[i] = new Thread(worker);
			threads[i].Start();
		}

		for (int i = 0; i < threads.Length; i++) {
			threads[i].Join();
		}
	}

	static void DoAsyncNoYield() {
		var tcs = new TaskCompletionSource<object>();
		tcs.SetResult(null);
		var task = tcs.Task;
		Task.Run(
			async delegate {
				int workRemaining = workSize;
				while (--workRemaining >= 0) {
					await NoYieldHelper(task);
				}
			}).Wait();
	}

	static async Task NoYieldHelper(Task task) {
		WorkUnit();
		await task;
	}

	static void DoAsyncWithYield() {
		Task.Run(
			async delegate {
				int workRemaining = workSize;
				while (--workRemaining >= 0) {
					WorkUnit();
					await Task.Yield();
				}
			}).Wait();
	}

	static void DoSync() {
		int workRemaining = workSize;
		while (--workRemaining >= 0) {
			WorkUnit();
		}
	}

	private static double MicroSecondsPerItem(Stopwatch timer) {
		var ticksPerItem = (double)timer.ElapsedTicks / workSize;
		var microSecondsPerItem = TimeSpan.FromTicks((long)(ticksPerItem * 1000)).TotalMilliseconds;
		return microSecondsPerItem;
	}

	static void WorkUnit() {
		for (int i = 0; i < unitSize; i++) {
		}
	}
}