This is a post about a simple concept. In .NET, I want to know how many processors exist on a host/VM. However, as far as I can tell, there's no APIs for that in modern .NET. If you need that information, this post shows the only approach I could come up with, which involves a P/Invoke on some platforms, and parsing files on Linux!
If there's a better way, please tell me, I kind of hate what I've had to do here 😅
Why not Environment.ProcessorCount?
Hopefully someone is thinking "Why wouldn't you just use Environment.ProcessorCount". After all, it's been available since .NET Framework 2.0! Unfortunately, what this value actually means depends on which version of .NET you're using…
- .NET Framework—Returns the number of logical processors on the host machine (i.e. exactly what I want 🎉)
- .NET Core < 6—Returns the number of logical processors on the host machine, but is container aware (to an extent, though it is buggy)
- .NET Core 6+—Returns the number of logical processors on the host machine unless you're running with process affinity, or you're running in a container. Essentially it returns the number of processors available to the process.
Ironically, .NET Framework actually does exactly what I need and modern .NET doesn't 😅 That's generally understandable, as normally it's most useful to know how many processes a process has available, rather than how many the host has, but in this case, that's not what I want.
So what options do we have?
Could Microsoft.Extensions.Diagnostics.ResourceMonitoring be the answer?
Betteridge's law of headlines comes the for, the answer is "No" 😅
If you weren't aware, the Microsoft.Extensions.Diagnostics.ResourceMonitoring NuGet package provides a collection of APIs for monitoring the resource utilization (CPU, memory, network) of your .NET applications.
It provides two sets of APIs
- Deprecated services like
IResourceMonitorthat you can use to manually retrieve values of interest - System.Diagnostics.Metrics APIs that use
Meters to produce OpenTelemetry compatible metrics
You can use these metrics to emit a host of resource metrics:
container.cpu.limit.utilizationcontainer.cpu.request.utilizationcontainer.cpu.timecontainer.memory.limit.utilizationcontainer.memory.usageprocess.cpu.utilizationdotnet.process.memory.virtual.utilizationsystem.network.connections
However, you'll note that none of those metrics is the number of host processors. So we're out of luck.
Calling native APIs to get the details
I'll cut to the chase: the only way I found to retrieve the values I was after was to call native APIs:
- On Windows, we need to P/Invoke
GetActiveProcessorCount() - On macOS, we need to P/Invoke
sysctlbyname("hw.logicalcpu") - On Linux, we need to read and parse
/sys/devices/system/cpu/online
This is much messier than I had hoped, so if someone has a better approach, I'm all ears! Nevertheless, the following sections describe how to read each of these values on the three platforms.
Getting the total CPU count on Windows
I'll start with Windows, as it's one of the simplest. We simply make a call into the kernel, and invoke GetActiveProcessorCount passing in the "All processor groups" flag, so that we get the total number of processors on the system:
internal static class WindowsProcessorCount
{
private const ushort AllProcessorGroups = 0xFFFF;
internal static int? GetTotalProcessorCount(ILogger log)
{
var result = GetActiveProcessorCount(AllProcessorGroups);
if (result > 0)
{
return result;
}
var error = Marshal.GetLastPInvokeError();
log.LogWarning(
"GetActiveProcessorCount failed when getting total machine processor count. ErrorCode={ErrorCode}",
property: error);
return null;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern int GetActiveProcessorCount(ushort groupNumber);
}
This is very simple - the GetTotalProcessorCount method simply P/Invokes and returns the number of processors. If the returned value is 0 then we had an error, so we log it and return null.
Note that I've used
[DllImport]in all these examples, as I needed to support .NET 6, but if you can, you should probably use[LibraryImport]instead.
That's Windows covered, on to the next OS!
Getting the total CPU count on macOS
The code on macOS is similarly a single P/Invoke, however it uses the generic sysctlbyname library function which requires a bit more faffing with arguments than Windows. Ultimately, it has essentially the same pattern as the Windows code.
internal static class MacOsProcessorCount
{
private const string LogicalCpuName = "hw.logicalcpu";
internal static int? GetTotalProcessorCount(ILogger log)
{
var size = new IntPtr(sizeof(int));
var result = SysCtlByName(LogicalCpuName, out var value, ref size, IntPtr.Zero, IntPtr.Zero);
if (result == 0 && value > 0)
{
return value;
}
var error = Marshal.GetLastPInvokeError();
log.LogWarning(
"sysctlbyname failed when getting total machine processor count. ErrorCode={ErrorCode}",
property: error);
return null;
}
[DllImport("libSystem.dylib", EntryPoint = "sysctlbyname", CharSet = CharSet.Ansi, SetLastError = true)]
private static extern int SysCtlByName(
string name,
out int oldp,
ref IntPtr oldlenp,
IntPtr newp,
IntPtr newlen);
}
So we essentially have the same pattern here: make the P/Invoke, check the result and return the value. Nothing too bad (though you obviously have to make sure to get the P/Invoke API correct, thankfully something that LLMs are very good at these days).
The final platform we have is Linux, which is where things get a bit different.
Getting the total CPU count on Linux
On Linux, rather than making a P/Invoke into a library, we instead read from the /sys/devices/system/cpu/online file, parse the list of CPUs and return the result.
Note that you could make a P/Invoke into the C library and call
sysconf(_SC_NPROCESSORS_ONLN), but there were edge cases with callingsysconfI wanted to avoid, such as the fact the constant is difference on glibc vs musl etc. By reading the file directly, we avoid those issues.
internal static class LinuxProcessorCount
{
private const string OnlineCpusPath = "/sys/devices/system/cpu/online";
internal static int? GetTotalProcessorCount(ILogger log)
{
try
{
var contents = File.ReadAllText(OnlineCpusPath);
var result = TryParseOnlineCpuRanges(contents.AsSpan());
if (result is null)
{
Log.LogWarning(ex, "Parsing cpu-list failed: contents was not a valid cpu-list '{FileContents}'", contents);
}
}
catch (Exception ex)
{
Log.LogWarning(ex, $"Error reading '${OnlineCpusPath}' to determine total machine processor count");
return null;
}
}
// Parses the Linux cpu-list-format (see https://www.kernel.org/doc/Documentation/admin-guide/kernel-parameters.txt)
// This is a comma-separated list of either a single CPU index ("0") or an inclusive range ("0-7"), e.g. "0-3,4,8-11".
internal static int? TryParseOnlineCpuRanges(ReadOnlySpan<char> contents)
{
var trimmed = contents.Trim();
if (trimmed.IsEmpty)
{
return null;
}
var count = 0;
var remaining = trimmed;
while (!remaining.IsEmpty)
{
// Find the next token
var commaIndex = remaining.IndexOf(',');
var token = commaIndex < 0 ? remaining : remaining[..commaIndex];
if (!TryParseToken(token, out var tokenCount))
{
// Should never happen, means the file contained invalid data
return null;
}
// Increase the CPU count
count += tokenCount;
if (commaIndex < 0)
{
// All done
break;
}
// Cut off the values we just read
remaining = remaining[(commaIndex + 1)..];
if (remaining.IsEmpty)
{
// trailing comma with no following token
return null;
}
}
// If we didn't read any values, something weird happened
return count > 0 ? count : null;
// Parse either a single value like "4", or a range, like "3-7"
static bool TryParseToken(ReadOnlySpan<char> token, out int tokenCount)
{
tokenCount = 0;
var dashIndex = token.IndexOf('-');
if (dashIndex < 0)
{
// A single value
if (!int.TryParse(token, out var single) || single < 0)
{
return false;
}
tokenCount = 1;
return true;
}
// Parse each value in the range
var startSpan = token[..dashIndex];
var endSpan = token[(dashIndex + 1)..];
if (!int.TryParse(startSpan, out var start) || start < 0 ||
!int.TryParse(endSpan, out var end) || end < start)
{
return false;
}
// Count the number covered by the range, e.g 0-3 = 4 CPUs
tokenCount = end - start + 1;
return true;
}
}
}
As I said earlier, this is a little annoyingly convoluted, but it's not complicated, it's just reading a file and parsing the contents 🙂
Putting it all together
So we now have a method for reading the total CPUs on each platform we can put it all together into one convenience method, that calls the correct API based on the platform:
internal static class TotalProcessorCount
{
internal static int? GetTotalProcessorCount(ILogger log)
{
if (OperatingSystem.IsWindows())
{
return WindowsProcessorCount.GetTotalProcessorCount(log);
}
if (OperatingSystem.IsLinux())
{
return LinuxProcessorCount.GetTotalProcessorCount(log);
}
if (OperatingSystem.IsMacOS())
{
return MacOsProcessorCount.GetTotalProcessorCount(log);
}
return null;
}
}
For this post I created the helper as a simple static type, but you would likely want to cache the value returned from GetTotalProcessorCount() seeing as it won't change for the lifetime of the process (unless we've got something wrong!). I'll leave that as an exercise for the reader, but for completeness, this is the full type, with the helper types nested inside to encapsulate them away
internal static class TotalProcessorCount
{
internal static int? GetTotalProcessorCount(ILogger log)
{
if (OperatingSystem.IsWindows())
{
return WindowsProcessorCount.GetTotalProcessorCount(log);
}
if (OperatingSystem.IsLinux())
{
return LinuxProcessorCount.GetTotalProcessorCount(log);
}
if (OperatingSystem.IsMacOS())
{
return MacOsProcessorCount.GetTotalProcessorCount(log);
}
return null;
}
private static class WindowsProcessorCount
{
private const ushort AllProcessorGroups = 0xFFFF;
internal static int? GetTotalProcessorCount(ILogger log)
{
var result = GetActiveProcessorCount(AllProcessorGroups);
if (result > 0)
{
return result;
}
var error = Marshal.GetLastPInvokeError();
log.LogWarning(
"GetActiveProcessorCount failed when getting total machine processor count. ErrorCode={ErrorCode}",
property: error);
return null;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern int GetActiveProcessorCount(ushort groupNumber);
}
private static class MacOsProcessorCount
{
private const string LogicalCpuName = "hw.logicalcpu";
internal static int? GetTotalProcessorCount(ILogger log)
{
var size = new IntPtr(sizeof(int));
var result = SysCtlByName(LogicalCpuName, out var value, ref size, IntPtr.Zero, IntPtr.Zero);
if (result == 0 && value > 0)
{
return value;
}
var error = Marshal.GetLastPInvokeError();
log.LogWarning(
"sysctlbyname failed when getting total machine processor count. ErrorCode={ErrorCode}",
property: error);
return null;
}
[DllImport("libSystem.dylib", EntryPoint = "sysctlbyname", CharSet = CharSet.Ansi, SetLastError = true)]
private static extern int SysCtlByName(
string name,
out int oldp,
ref IntPtr oldlenp,
IntPtr newp,
IntPtr newlen);
}
internal static class LinuxProcessorCount
{
private const string OnlineCpusPath = "/sys/devices/system/cpu/online";
internal static int? GetTotalProcessorCount(ILogger log)
{
try
{
var contents = File.ReadAllText(OnlineCpusPath);
var result = TryParseOnlineCpuRanges(contents.AsSpan());
if (result is null)
{
Log.LogWarning(ex, "Parsing cpu-list failed: contents was not a valid cpu-list '{FileContents}'", contents);
}
}
catch (Exception ex)
{
Log.LogWarning(ex, $"Error reading '${OnlineCpusPath}' to determine total machine processor count");
return null;
}
}
// Parses the Linux cpu-list-format (see https://www.kernel.org/doc/Documentation/admin-guide/kernel-parameters.txt)
// This is a comma-separated list of either a single CPU index ("0") or an inclusive range ("0-7"), e.g. "0-3,4,8-11".
private static int? TryParseOnlineCpuRanges(ReadOnlySpan<char> contents)
{
var trimmed = contents.Trim();
if (trimmed.IsEmpty)
{
return null;
}
var count = 0;
var remaining = trimmed;
while (!remaining.IsEmpty)
{
// Find the next token
var commaIndex = remaining.IndexOf(',');
var token = commaIndex < 0 ? remaining : remaining[..commaIndex];
if (!TryParseToken(token, out var tokenCount))
{
// Should never happen, means the file contained invalid data
return null;
}
// Increase the CPU count
count += tokenCount;
if (commaIndex < 0)
{
// All done
break;
}
// Cut off the values we just read
remaining = remaining[(commaIndex + 1)..];
if (remaining.IsEmpty)
{
// trailing comma with no following token
return null;
}
}
// If we didn't read any values, something weird happened
return count > 0 ? count : null;
// Parse either a single value like "4", or a range, like "3-7"
static bool TryParseToken(ReadOnlySpan<char> token, out int tokenCount)
{
tokenCount = 0;
var dashIndex = token.IndexOf('-');
if (dashIndex < 0)
{
// A single value
if (!int.TryParse(token, out var single) || single < 0)
{
return false;
}
tokenCount = 1;
return true;
}
// Parse each value in the range
var startSpan = token[..dashIndex];
var endSpan = token[(dashIndex + 1)..];
if (!int.TryParse(startSpan, out var start) || start < 0 ||
!int.TryParse(endSpan, out var end) || end < start)
{
return false;
}
// Count the number covered by the range, e.g 0-3 = 4 CPUs
tokenCount = end - start + 1;
return true;
}
}
}
}
Should I use this code?
That's entirely up to you 😅 I haven't yet shipped this code into production, but I'm seriously considering it. I think it's pretty sound as best as I (and the 🤖) can tell, but obviously use your own judgement. As I said before, if you know of a better way to get these values, I'd be very interested to hear about it in the comments.
The one thing I would suggest changing if you're using modern .NET applications with dependency injection etc, is to nest all this code inside a little singleton wrapper that caches the value for the lifetime of the process and provides an ILogger instance to use etc. But otherwise, try it out, make sure it works for you!
Summary
In this post I talked about how to find the total number of CPUs available on a host, as opposed to the number of CPUs available to a process. Environment.ProcessorCount returns the former in .NET Framework, but in .NET Core, it returns the latter (and you can actually trust the values from about .NET 6+). However, in .NET 6+, if you actually want the total number of CPUs on the host, then there are no managed APIs I could find in the BCL to achieve that.
As a consequence, in this post, I show how to find the total processor count on Windows, macOS, and Linux. For Window and macOS, we can use a simple P/Invoke to read the value. This is theoretically available on Linux, but it's a bit harder than you might expect, so instead of using P/Invoke, I show how to read and parse the /sys/devices/system/cpu/online file instead. Finally, I put all three approaches into a helper that switches based on the current platform.
