📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

๐Ÿ“š Interview question

What is a system call? How does a .NET call like File.ReadAllBytesAsync or Socket.SendAsync reach the kernel, and why can system calls be expensive?

The system call mechanism, the path from .NET APIs through SafeHandles and the PAL to Windows (IOCP) and Linux (epoll) interfaces, the cost of crossing into the kernel, and how buffering, batching and async I/O reduce that cost. Includes tracing system calls with strace and Process Monitor.

Mid-Level Theory Intermediate epoll io iocp operating-system performance system-calls

๐ŸŽฏ Key points

  • A system call is a controlled entry into the kernel to request a privileged service (file and network I/O, memory mapping, process and thread creation, time)
  • The program places a call number and arguments in registers and executes syscall (x64) or svc (Arm64); the CPU switches to kernel mode, the kernel validates every argument, does the work (possibly blocking the thread) and returns a result or error
  • In .NET, APIs go through SafeHandle-based wrappers to OS functions: FileStream/RandomAccess to ReadFile (Windows) or pread (Linux); Socket to WSASend/IOCP (Windows) or send plus epoll (Linux)
  • True asynchronous I/O differs by OS: Windows IOCP completes both socket and file operations in the kernel; on Linux the runtime uses epoll readiness for sockets but runs regular file reads synchronously on thread-pool threads
  • A syscall costs roughly 100 ns for a trivial call to a few µs for small I/O, plus cache/TLB pollution, speculative-execution mitigations and possible blocking with a context switch
  • Reduce calls with buffering (64-128 KB for sequential I/O), batching writes, larger reads, connection reuse (HttpClient pooling) and removing check-then-act calls like File.Exists before Open
  • Trace with strace -c or perf trace on Linux and Process Monitor or ETW (PerfView, WPR) on Windows, briefly and on a canary because tracing adds overhead

โฑ 30-second answer

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.

๐Ÿ—ฃ Full answer (senior)

The mechanism

  1. The app (or a library) prepares arguments: a file handle or descriptor, a buffer pointer, a length.
  2. It executes the system call instruction with a call number in a register.
  3. The CPU switches to kernel mode and jumps to the kernel's system call handler.
  4. The kernel validates every argument (is this handle yours, is the buffer in your address space), performs the operation, and may block the thread if data isn't ready.
  5. It returns a result or error code, and the CPU switches back to user mode.

On Windows, applications call documented Win32 APIs (ReadFile, WSASend) in user-mode DLLs, which issue the actual system calls via ntdll.dll. On Linux, the C library (libc) wraps system calls such as read, write, openat, epoll_wait.

From .NET API to kernel

File.ReadAllBytesAsync(path)
  -> FileStream / RandomAccess
    -> SafeFileHandle (owns the OS handle / file descriptor)
      -> Windows: ReadFile (overlapped)        Linux: pread on a thread-pool thread
        -> kernel: file system, page cache, disk driver

Socket.SendAsync(buffer)
  -> Socket / SocketAsyncEventArgs
    -> Windows: WSASend + I/O completion port   Linux: send; if it would block, register with epoll
      -> kernel: TCP stack, network driver
  • On Windows, true asynchronous I/O uses I/O completion ports (IOCP): the kernel completes the operation and queues a notification; a thread-pool I/O thread picks it up.
  • On Linux, sockets are non-blocking and the runtime runs a small number of epoll event-loop threads that wait for readiness and dispatch continuations. Regular file I/O on Linux is issued synchronously on thread-pool threads, because classic Linux file I/O doesn't support true async for buffered files.

Why system calls cost

Cost Why
Mode switch Saving and restoring state, entering and leaving the kernel; mitigations for speculative-execution attacks add more
Validation and bookkeeping Checking handles, copying arguments, locking kernel structures
Cache and TLB pollution Kernel code and data displace your hot data
Blocking If data isn't ready, the thread sleeps and a context switch follows
Physical I/O If the page cache misses, the disk or network adds micro- to milliseconds

Reducing system call overhead

  • Buffer: FileStream has a 4 KB buffer by default; larger buffers (64โ€“128 KB) help sequential throughput. Avoid Flush() per line.
  • Batch: write a whole message or response at once; use PipeWriter, BufferedStream, or gather writes.
  • Read bigger chunks: Stream.CopyToAsync with an appropriate buffer, RandomAccess.ReadAsync with large spans.
  • Avoid needless calls: File.Exists before File.Open is two calls and a race; just open and handle the exception. Cache directory listings.
  • Use async for waiting, not CPU: async I/O frees threads while the kernel waits, which reduces threads and context switches.
  • Reuse connections: HttpClient connection pooling avoids socket creation, connect and TLS handshake syscalls.

Production example

A CSV import read files line by line with StreamReader.ReadLine() over a FileStream opened with bufferSize: 1 (copied from an old sample to "avoid double buffering"). strace -c showed millions of 1-byte-ish read calls. Restoring a 64 KB buffer cut import time for a 2 GB file from 9 minutes to 70 seconds.

๐Ÿ” What the interviewer is checking

Checks understanding of the OS interface and I/O performance.

Green flags: explains the mechanism and validation, maps .NET APIs to OS calls, knows IOCP versus epoll, gives realistic costs, proposes buffering and batching, and knows how to trace calls.

Red flags: thinks .NET talks to hardware directly; disables buffering for speed; no idea how to see what system calls an app makes.

๐Ÿ“š Learn in depth

1. One read, step by step (Linux)

user mode                                  kernel mode
---------                                  -----------
RandomAccess.Read(handle, span, offset)
  pread(fd, buf, len, off)  -- syscall -->  sys_pread64
                                              fd -> struct file (per-process table)
                                              page cache lookup
                                                hit: copy pages to buf
                                                miss: issue disk I/O, sleep thread,
                                                      wake on completion, copy
                           <-- return n --

2. Async models compared

Windows IOCP Linux epoll Linux io_uring
Model Completion: "this operation finished" Readiness: "this socket is readable" Completion via shared ring buffers
.NET use Sockets and files Sockets (event-loop threads) Not used by default by the runtime
Syscalls per operation Issue + completion dequeue (batched) epoll_wait (batched) + the actual recv/send Very few; submissions batched

Knowing the readiness versus completion difference explains why, on Linux, a large number of tiny socket operations still means many recv/send calls.

3. Rough cost numbers

Operation Order of magnitude
Trivial syscall (getpid) ~100 ns
read from page cache, small ~0.5โ€“2 ยตs
write to a socket, small ~1โ€“5 ยตs
fsync on SSD ~0.1โ€“10 ms
Blocking read that waits on network Network latency plus a context switch

At 100,000 requests per second, an extra 10 ยตs of syscalls per request is a whole core.

4. Tracing system calls

# Linux: summary of calls, counts and time for a running process
strace -c -f -p <pid>

# Linux: watch file opens only
strace -f -e trace=openat -p <pid>

# Lower overhead tracing with perf
perf trace -s -p <pid>

Windows: Process Monitor (Sysinternals) shows file, registry and network activity with stacks; ETW via PerfView or WPR/WPA shows syscalls and I/O with low overhead.

strace slows the traced process significantly; use it briefly, on a canary instance, never as a permanent fixture.

5. Hidden system calls in .NET code

Code System calls
File.Exists + File.ReadAllText stat + openat + reads + close
Directory.GetFiles on a big directory Many getdents64 calls
DateTime.Now Usually none (vDSO on Linux), but time zone conversion may read files once
new HttpClient() per request socket, connect, TLS, close per request
Console.WriteLine in a hot loop A write per call when output is unbuffered (common for console)

6. Try it yourself

  1. Read a 500 MB file with FileStream buffer sizes of 1, 4,096 and 131,072 bytes. Time each and count calls with strace -c.
  2. Replace File.Exists + File.ReadAllText with a single try/catch (FileNotFoundException) and compare the traced calls.
  3. Write 100,000 lines to the console in a loop, then via a StreamWriter wrapping Console.OpenStandardOutput() with AutoFlush = false, and compare.

๐Ÿ’ป Code

namespace SystemCalls;

using System.Diagnostics;

public static class Program
{
    public static async Task Main(string[] args)
    {
        string path = args.Length > 0 ? args[0] : "big.csv";

        foreach (var bufferSize in new[] { 1, 4_096, 128 * 1024 })
        {
            var sw = Stopwatch.StartNew();
            long lines = await CountLinesAsync(path, bufferSize);
            Console.WriteLine($"buffer {bufferSize,7}: {lines} lines in {sw.ElapsedMilliseconds} ms");
        }
    }

    private static async Task<long> CountLinesAsync(string path, int bufferSize)
    {
        var options = new FileStreamOptions
        {
            Mode = FileMode.Open,
            Access = FileAccess.Read,
            BufferSize = bufferSize,              // 0 or 1 disables FileStream buffering
            Options = FileOptions.SequentialScan | FileOptions.Asynchronous,
        };
        await using var stream = new FileStream(path, options);
        using var reader = new StreamReader(stream, bufferSize: Math.Max(bufferSize, 1024));
        long count = 0;
        while (await reader.ReadLineAsync() is not null)
        {
            count++;
        }
        return count;
    }
}
# Compare system call counts for each run
strace -c -f -e trace=read,pread64 dotnet SystemCalls.dll big.csv

โš  Common mistakes

  • Mistake: disabling buffering "for performance". bufferSize: 0 or 1 turns every small read or write into a system call, which is dramatically slower for typical access patterns. Instead: keep buffering on, and increase the buffer (64โ€“128 KB) for large sequential I/O.

  • Mistake: flushing after every write. Each Flush() forces a system call, and Flush(flushToDisk: true) forces a slow fsync too. Instead: flush at logical boundaries (end of a batch or file), and use fsync only where durability truly requires it.

  • Mistake: checking before acting (File.Exists then Open). It doubles the calls and introduces a race: the file can disappear between the check and the open. Instead: open directly and handle FileNotFoundException or use FileMode.OpenOrCreate.

  • Mistake: assuming async file I/O is free or always truly async. On Linux, regular file reads run on thread-pool threads, so async file APIs mainly free the caller, not the system. Instead: use async for scalability, but tune buffer sizes and avoid huge numbers of small file operations.

  • Mistake: leaving strace attached in production. It can slow the process by an order of magnitude and affect users. Instead: trace briefly on a canary instance, or use lower-overhead tools such as perf trace or ETW.

๐Ÿ› Senior insight: trade-offs

System calls are where your code meets the OS, and they are often the hidden cost behind "the CPU is busy but the managed profiler shows nothing".

Symptom Likely syscall pattern Fix
High system CPU, low disk throughput Millions of tiny read/write Restore or enlarge buffers, batch writes
Latency spikes on writes fsync per message Flush at batch boundaries; group commit
Socket churn, TLS CPU socket/connect/close per request Reuse HttpClient via IHttpClientFactory or a static SocketsHttpHandler
Thread-pool growth during file I/O on Linux Blocking pread on pool threads Bound concurrency, larger reads, avoid many small files
Slow directory scans Many getdents64/stat calls Enumerate lazily (EnumerateFiles), cache listings, avoid per-file metadata calls

Seniors know the path from .NET APIs down to IOCP or epoll, which explains why async sockets scale, why file I/O on Linux behaves differently, and why buffer sizes matter. They gather evidence (system call counts, user versus kernel CPU) before optimising, and they fix patterns rather than micro-tuning: buffer and batch, reuse connections, remove check-then-act calls, and let the kernel do the waiting.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: Candidate says a blocking system call lets another thread run Why: Checks understanding of context switches and their cost
    How to answer

    A context switch is the OS stopping one thread and starting another on a core: it saves the registers, instruction pointer and stack pointer of the old thread and restores those of the new one. Switching between processes also swaps the address space, which is more expensive. The direct cost is a few microseconds, but the indirect cost is often bigger: the new thread finds caches and TLBs cold. Switches are voluntary when a thread blocks, and involuntary when its time slice ends. Too many threads in a .NET service, usually caused by blocking code that makes the thread pool inject more threads, means more switching, more cache misses, more memory for stacks and more lock contention, so throughput falls and latency rises. The fix is async I/O, bounded parallelism around the core count, and less lock contention.

  2. When: Candidate mentions open() or socket() returning a value Why: Probes file descriptors and handles as the result of system calls
    How to answer

    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.

  3. When: Candidate is vague about why system calls are expensive Why: Goes back to the user/kernel mode boundary
    How to answer

    The kernel is the core of the operating system. It schedules threads on CPUs, manages memory, talks to devices through drivers, and implements file systems, networking and security. The CPU has privilege levels: in kernel mode (ring 0 on x64, EL1 on Arm64) code can access all memory and hardware; in user mode (ring 3, EL0) it can't touch hardware or other processes' memory directly. Every .NET app runs in user mode. When it needs the kernel (to read a file, send on a socket, allocate memory), it makes a system call, the CPU switches to kernel mode, the kernel does the work and returns. Interrupts and faults also switch modes. The split protects the system: a bug in your app crashes your process, not the machine.

๐Ÿ›  Real-world problems: how you handle them

CSV import 8x faster after restoring FileStream buffering
Situation
A nightly import loaded 2 GB CSV files from a partner into SQL Server. It took about 9 minutes per file and was starting to collide with the morning batch window.
Signals
High system CPU (about 60% `sy` in `top`) with low disk throughput; the managed profiler showed time inside `StreamReader.ReadLine` without an obvious culprit.
Investigation
Ran `strace -c -f` for 30 seconds on a test run and saw millions of tiny `read` calls. Code review found the `FileStream` created with `bufferSize: 1`, copied from an old sample intended to avoid double buffering.
Resolution
Used `FileStreamOptions` with a 128 KB buffer and `SequentialScan`, and gave `StreamReader` a matching buffer. Import time dropped to about 70 seconds and system CPU to a few percent.
Prevention
Added a performance test for the import with a 500 MB file and a time budget, and a code-review note against disabling buffering without a measured reason.

How to tell it (STAR)

**Situation:** At [company], our nightly CSV import of [2 GB] files took [9 minutes] each and was about to overrun the batch window. **Task:** I had to speed it up without rewriting the import. **Action:** System CPU was very high while disk throughput was low, so I traced system calls with `strace -c` and found millions of tiny reads. The stream had been created with a one-byte buffer. I set a [128 KB] buffer with sequential-scan hints and matched the reader's buffer. **Result:** The import dropped to about [70 seconds] per file, freeing the batch window, and a performance test now guards it.

โ†ฉ Usually asked after

More in Operating System

Last updated 23 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me