File descriptors and handles as the OS's per-process references to kernel resources, their limits, and how .NET wraps them with SafeHandle and IDisposable.
A file descriptor (Linux, a small integer) or handle (Windows, an opaque value) is the process's reference to a kernel object: an open file, socket, pipe, event, mutex, process or thread. The kernel owns the object, and the process holds an index into its own table.
They are limited (e.g. ulimit -n often 1,024 soft by default on Linux shells, much higher in many containers and under systemd), so leaks eventually break the app.
In .NET, SafeHandle wraps the raw value: it releases it in a critical finaliser if you forget, guarantees release even under thread aborts or async exceptions, and prevents handle recycling bugs by reference counting during P/Invoke calls. You still call Dispose (using) to release deterministically.
Process A Kernel
┌───────────────┐ ┌──────────────────────────┐
│ fd table │ │ open file description │
│ 0 ──────────────────────────► stdin (tty) │
│ 1 ──────────────────────────► stdout │
│ 2 ──────────────────────────► stderr │
│ 3 ──────────────────────────► /var/log/app.log (pos 812)│
│ 4 ──────────────────────────► TCP socket 10.0.0.5:443 │
└───────────────┘ └──────────────────────────┘
epoll instances, eventfd, and inotify watchers all use descriptors. New descriptors get the lowest free number.| Linux | Windows | |
|---|---|---|
| Name | File descriptor (int) | HANDLE (pointer-sized) |
| Per-process limit | ulimit -n (soft/hard), LimitNOFILE in systemd |
~16 million per process in theory; practical limits come from memory |
| See them | ls -l /proc/<pid>/fd, lsof -p <pid> |
Process Explorer, handle.exe, Task Manager "Handles" column |
| Close | close(fd) |
CloseHandle(h) |
FileStream, Socket, Process, MemoryMappedFile, Mutex, NamedPipeServerStream and others wrap a SafeHandle subclass (SafeFileHandle, SafeSocketHandle, SafeProcessHandle, SafeWaitHandle).IDisposable. Dispose closes the handle now. If you forget, the SafeHandle's finaliser closes it eventually, but "eventually" can be long after you have run out.A worker that read thousands of small files each minute created new StreamReader(path) without using. In tests it worked. In production, after about 20 minutes, it failed with IOException: Too many open files. ls /proc/<pid>/fd | wc -l showed 1,024 descriptors, nearly all pointing to the input folder. GC eventually ran the finalisers, so the count went up and down like a saw-tooth. The fix was using var reader = ... (or File.ReadAllTextAsync), plus a metric on open descriptors.
Tests whether the candidate links IDisposable and using to an actual OS resource and understands why the GC alone is not enough.
Green flags
ulimit, /proc/<pid>/fd, Process Explorer).Red flags
Dispose frees managed memory.On Linux there are three layers:
per-process fd table ──► open file description ──► inode / socket
(fd 3) (offset, flags O_APPEND) (the actual file)
dup(fd) or fork gives a second descriptor pointing to the same open file description, so they share the file offset.du says otherwise" puzzle).Windows is similar: the handle table entry points to a kernel object with a reference count, and the object goes away when the count reaches zero.
ulimit -n # soft limit for this shell
ulimit -Hn # hard limit
cat /proc/<pid>/limits | grep "open files"
cat /proc/sys/fs/file-max # system-wide
systemd services use LimitNOFILE= (modern systemd defaults the soft limit to 1,024 and the hard limit to 524,288 so programs can raise their own). Docker inherits the daemon's limit unless you pass --ulimit nofile=.... .NET raises its soft limit to the hard limit at startup on Linux, so the hard limit is what usually matters.
Early .NET used IntPtr for handles. Three problems:
CreateFile returning and storing the value, the handle leaked forever.Close on it; the OS reuses 0x44 for a new file; thread A's native call now writes to the wrong file. This is a security bug, not just a crash.SafeHandle fixes all three:
CriticalFinalizerObject, so its finaliser runs after normal finalisers and is prepared ahead of time.DangerousAddRef before a P/Invoke and DangerousRelease after, so Close during a call only marks it for release; the real close happens when the call finishes.ReleaseHandle() is the one place that calls close/CloseHandle.You rarely need to, but for a native library you would subclass SafeHandleZeroOrMinusOneIsInvalid and override ReleaseHandle. With LibraryImport (source-generated P/Invoke), you can return the SafeHandle type directly and the generator handles the ref counting.
open fds
▲
│ /| /| /| ← saw-tooth: leak cleaned by finalisers
│ / | / | / |
│ / | / | / |
│ / |/ |/ |
└──────────────────────────────► time
│ ______ ← plateau at the limit: errors begin
│ ___/
│ ___/
│ ___/ ← steady climb: true leak (still referenced)
└──────────────────────────────► time
dotnet run on a console app that opens 2,000 files without disposing them and holds them in a list.ls /proc/$(pgrep -f YourApp)/fd | wc -l.ulimit -n 256 in the shell before dotnet run and watch the IOException appear.using var fs = File.OpenRead(path); and confirm the count stays flat.// Bad: relies on the finaliser, leaks descriptors under load
public string ReadConfigBad(string path)
{
var reader = new StreamReader(path);
return reader.ReadToEnd(); // handle stays open until a GC runs the finaliser
}
// Good: deterministic release
public string ReadConfig(string path)
{
using var reader = new StreamReader(path);
return reader.ReadToEnd();
}
// Better for whole files: the framework opens and closes for you
public Task<string> ReadConfigAsync(string path, CancellationToken ct) =>
File.ReadAllTextAsync(path, ct);
// Wrapping a native handle from a C library safely
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
internal sealed class SafeDeviceHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeDeviceHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle() => NativeMethods.device_close(handle) == 0;
}
internal static partial class NativeMethods
{
[LibraryImport("mydevice", StringMarshalling = StringMarshalling.Utf8)]
internal static partial SafeDeviceHandle device_open(string name);
[LibraryImport("mydevice")]
internal static partial int device_close(nint handle);
[LibraryImport("mydevice")]
internal static partial int device_read(SafeDeviceHandle handle, Span<byte> buffer, int length);
}
# How many descriptors does the process hold, and what are they?
ls -l /proc/$(pgrep -f MyWorker)/fd | wc -l
lsof -p $(pgrep -f MyWorker) | awk '{print $5}' | sort | uniq -c # by type: REG, IPv4, sock, FIFO
cat /proc/$(pgrep -f MyWorker)/limits | grep -i "open files"
Mistake: Relying on the GC to close files and sockets.
Why it is wrong: The GC runs on memory pressure, not handle pressure, so you can exhaust descriptors long before a collection runs the finalisers.
Instead: Use using or await using for every IDisposable that owns a handle, or helpers such as File.ReadAllTextAsync that open and close for you.
Mistake: Storing raw IntPtr handles from P/Invoke.
Why it is wrong: It brings back leaks on exceptions and the handle-recycling bug: one thread closes handle 0x44, the OS reuses the number for another file, and a second thread writes to the wrong file.
Instead: Use a SafeHandle subclass as the P/Invoke return and parameter type so the marshaller adds and releases a reference around each call.
Mistake: Assuming the ulimit -n you see in your shell is the service's limit.
Why it is wrong: systemd units, the Docker daemon and Kubernetes container runtimes each set their own limits, and distributions ship different defaults.
Instead: Read /proc/<pid>/limits for the running process, and set LimitNOFILE or --ulimit nofile explicitly where you need more.
Mistake: Raising the limit to hide a leak. Why it is wrong: A higher limit only delays the failure. Instead: Graph open descriptors over time. A steady climb is a leak to fix; a high but flat number may just need a bigger limit.
Mistake: Thinking deleting a file frees its disk space straight away.
Why it is wrong: The inode and its blocks stay until the last descriptor to it closes.
Instead: Use lsof +L1 to find deleted-but-open files when df and du disagree.
I treat handles as a scarce, non-memory resource. The GC is a memory manager, so it cannot tell that 1,000 open descriptors are a crisis while 1,000 small objects are nothing. That is the reason IDisposable exists, and why a missing using around a stream, socket or Process is a real bug in review.
| Approach | Benefit | Risk | When to use it |
|---|---|---|---|
using / await using |
Deterministic release | Easy to forget without analysers | Every owned handle |
| Finaliser only | Safety net | Release timing depends on GC; can exhaust limits | Never as the main plan |
Raw IntPtr interop |
Simple | Leaks and handle recycling, a security bug | Avoid |
SafeHandle + LibraryImport |
Ref-counted, thread-safe close, critical finalisation | Slightly more code | All native interop |
Raise LimitNOFILE |
Room for many sockets | Hides leaks if done blindly | High-connection servers, after confirming a flat count |
Concurrency detail: if one thread calls Dispose while another is inside a P/Invoke using the same SafeHandle, the close is deferred until the reference count drops to zero, so the native call never sees a reused number.
Operations: I add an open-descriptor gauge (OpenTelemetry process.open_file_descriptor.count, or a count of /proc/self/fd) with an alert near 70% of the limit, and enable analyser CA2000 so undisposed objects are flagged at build time.
Two different limits produce similar errors:
EMFILE, "Too many open files"): the process hit its ulimit -n. Something is leaking files or sockets.EADDRNOTAVAIL, "Cannot assign requested address"): too many outbound connections to the same destination, usually closed sockets stuck in TIME_WAIT for 60 s.The classic .NET cause is new HttpClient() per request. Fix it with a shared HttpClient using SocketsHttpHandler { PooledConnectionLifetime = ... }, or IHttpClientFactory. Diagnose with ls /proc/<pid>/fd | wc -l, lsof -p, ss -s and ss -tan state time-wait.
A system call is how a user-mode program asks the kernel to do something privileged: read a file, send data, allocate memory, create a thread. The program puts arguments in registers and executes a special instruction (syscall on x64); the CPU switches to kernel mode, the kernel validates the request, does the work and returns. In .NET, File.ReadAllBytesAsync goes through FileStream/RandomAccess and a SafeFileHandle to ReadFile on Windows or pread on Linux; Socket.SendAsync uses I/O completion ports on Windows and epoll on Linux. Each call costs hundreds of nanoseconds to microseconds, more with security mitigations, so thousands of tiny calls add up. Buffering, batching and async I/O keep the count down.
How to tell it (STAR)
**S:** Our [ingest worker] crashed every [20 minutes] with "Too many open files". **T:** I owned the service and needed to stop the restarts. **A:** I listed `/proc/<pid>/fd` and saw [1,000] handles to the input folder, then found a `StreamReader` that was never disposed, so we were waiting on the GC to close files. I added `using`, a descriptor metric and the CA2000 analyser. **R:** Descriptor count stayed flat at [~40] and the worker ran for [weeks] without restarts.
Last updated 23 Oct 2026.