Linux Through a C++ Developer's Eyes
A C++ program does not run directly on hardware. Between your code and the processor, memory, and disk stands the kernel — the core of the operating system. Everything beyond computation within your own memory — opening a file, creating a thread, allocating a page, sending data to a socket — is a request to the kernel, phrased as a system call.
It helps to distinguish the kernel from the operating system: the kernel is the central component (process management, memory, file systems, drivers); the operating system is the kernel plus the userland (libc, shell, init system, utilities). Linux, strictly speaking, is the kernel; a "Linux distribution" is that kernel bundled with a chosen environment.
Why does a C++ developer care? Because entire classes of bugs are invisible in C++ code but visible in process behavior: file descriptor exhaustion hits EMFILE, an abandoned child process (one the parent never reaped) becomes a zombie, checking "does the file exist?" before opening races against another process creating it in the meantime (TOCTOU). Understanding the OS transforms such failures from "mysterious" into "explainable".
Processes and Threads
A process is a running program with its own address space and resources (open files, signal handlers). Each process is identified by a PID. A thread is a context of execution within a process; threads of the same process share one address space.
A new Unix process is created via fork() — it duplicates the calling process. The child gets a copy of the parent's address space, but pages are not physically copied immediately: the kernel marks them copy-on-write (CoW) and duplicates a specific page only on first write to it.
pid_t pid = fork();
if (pid == 0) {
// child process — separate memory copy
execlp("ls", "ls", "-l", nullptr); // replaces the process image
_exit(127); // reached only if exec failed
} else {
int status;
waitpid(pid, &status, 0); // parent MUST reap the child
}
fork() and exec() always work as a pair: fork() creates a process, exec() replaces its program image. After fork(), the child's writes to inherited memory are not visible to the parent — CoW has split their copies; pages remain shared only until first write.
A thread is created via the clone() system call with flags to share the address space (on Windows: CreateThread, not portable). A thread is cheaper than a process precisely because a new address space and page tables are not needed — the thread reuses existing ones. But "cheaper" does not mean "free": thread creation is microseconds, and spawning a thread per request without pooling under load is expensive.
Context switching and the scheduler
A context switch is the switch of one thread on a CPU core. The OS saves the current thread's registers; on address space change, it flushes the TLB (if there is no ASID); the result is pipeline stalls — roughly 1–10 microseconds. That is why hundreds of threads do not scale linearly: at some threshold, switching overhead dominates.
The Linux scheduler (CFS) divides CPU time among ready threads by their weight. nice is not a percentage of CPU; it is a ratio of time shares among competing tasks. Real-time policies (SCHED_FIFO) without bounds can starve everything else.
One trap to watch — zombies: a process that has exited but whose parent has not called wait(). A zombie occupies a slot in the process table until reaped. PIDs are not infinite — the counter overflows (default: after 32768), so storing a PID and later assuming it refers to the same process is wrong.
System Calls
A system call is a request to the kernel. Unlike a normal function call, it crosses the privilege boundary: a special instruction (syscall) switches the CPU to kernel mode, the kernel performs the operation, and returns. This mode switch costs orders of magnitude more than a regular call — so calling the syscall per byte read is wasteful; read in large buffers.
// glibc wrapper ≠ the syscall itself: read() here is a thin wrapper,
// fread() adds buffering on top
char buf[65536];
ssize_t n = read(fd, buf, sizeof buf); // one syscall for 64 KB, not per byte
glibc wrappers are not the syscall itself: they add buffering, errno handling, sometimes caching. A syscall can be interrupted by a signal — it then returns EINTR, and the call must be retried.
This connects to reentrancy. Not every libc function is thread-safe: strtok stores state in a static variable, and two threads parsing different strings will corrupt each other's state. For such functions, historical variants with _r suffix exist (strtok_r). Inside a signal handler the constraint is tighter — only async-signal-safe functions are allowed.
Files and Descriptors
A file descriptor (FD) is a small integer — an index into the process's open file table. Standard descriptors: 0 is stdin, 1 is stdout, 2 is stderr. Do not assume they are always a terminal: a daemon started by systemd may have them redirected to /dev/null — check.
After fork() the child inherits all parent descriptors and shares the file offset with the parent — until one closes the descriptor.
int fd = open("data.bin", O_RDONLY);
pid_t pid = fork();
// both parent and child can read via fd —
// and they advance ONE shared offset until someone closes the descriptor
The O_CLOEXEC flag closes the descriptor on exec() (but not on fork()) — without it, a child that does not exec() will carry away all the parent's descriptors. File descriptor leaks in long-lived processes eventually exhaust the limit, and open() returns EMFILE; close descriptors on every error path too.
Several interview-favorite traps:
- Exclusive file creation. "Create only if not present" is
open(path, O_CREAT | O_EXCL). Doingstat()first and thenopen()opens a TOCTOU race: a concurrent process can create the file between check and open.O_CREATwithoutO_EXCLsimply opens an existing file;O_EXCLfails withEEXIST. statvslstat. On a symbolic link,stat()describes the target, whilelstat()describes the link itself (and its "size" is the length of the path string). When walking directory trees, uselstat()to avoid loops on symlinks.unlinkan open file.unlinkremoves a directory entry — but the inode and data live as long as there is at least one hard link ORat least one open descriptor. A process reading the file continues afterunlink; disk space is freed only after the lastclose.- Non-blocking mode. A descriptor is set non-blocking via
fcntl: read flags withF_GETFL, then bitwise-OR inO_NONBLOCK— do not overwrite, or you lose other flags. In this mode,EAGAINis not an error but a signal "try again later", andwrite()may write less than requested.
Permissions
Every file is owned by a user (UID) and a group (GID), and for three classes — owner, group, others — read, write, and execute permissions (rwx) are defined. A new file is created with permissions mode & ~umask: the umask masks bits off, and the standard umask 022 removes write access for group and others.
Standard Unix permissions allow exactly one user and one group per file; when you need rules for specific users, that is an ACL (getfacl / setfacl), a separate mechanism layered over the ordinary permissions. The setuid bit on an executable runs the process with the permissions of the file's owner rather than the invoker. It is a powerful but dangerous mechanism: setuid programs are a classic attack surface, and their privileges must be audited with great care.
Virtual Memory
Each process has its own virtual address space — on x86-64 that is 128 TB in userland. But a virtual address is not memory yet: each mapping must be backed by a physical page or swap. Virtual space is vast, physical memory is not.
Process memory metrics must not be confused:
- VSZ — total virtual size. Can grow without consuming RAM (e.g.,
mmapwithPROT_NONE), so VSZ growth is not proof of a leak. - RSS — resident set size: the physical pages actually occupied. Summing RSS across processes is wrong — shared libraries are counted multiply.
- PSS — proportional set size: a shared page is divided proportionally among all users. This is the honest metric for summing.
CoW pages (after fork()) are shared until first write — this is what saves memory at fork() time.
Signals
A signal is an asynchronous notification to a process (SIGTERM, SIGSEGV, SIGCHLD, and others). If no handler is installed, the default action runs. A common misconception is that an unhandled signal is ignored: the default action for most signals is to terminate the process. But not all: SIGCHLD and SIGURG default to ignore.
SIGKILL and SIGSTOP are special: they cannot be caught, blocked, or have handlers installed. This is the kernel's guarantee that a process can always be killed and stopped.
Code inside a signal handler is severely restricted — only async-signal-safe functions are allowed. A handler can interrupt execution at any arbitrary point:
void handler(int sig) {
printf("got signal\n"); // ⚠️ UB — printf is not async-signal-safe,
// it might have been interrupted mid-execution elsewhere
// mutex here — potential self-deadlock if we interrupted the lock holder
}
Interprocess Communication
Processes are isolated in memory, so explicit IPC mechanisms are needed for data exchange.
A pipe is a one-way byte stream through a kernel buffer (default ~64 KB). Unused ends must be closed: if the parent does not close its write end, the child's read() will never return EOF — the kernel sees a writer still available. For two-way communication, use two pipes or socketpair(). A named pipe (FIFO) is the same pipe but with a filesystem name, so unrelated processes can use it.
Shared memory is the fastest IPC: processes map the same physical memory region. The POSIX variant is shm_open() plus mmap(); System V is shmget() with a key from ftok(). Shared memory itself has no synchronization — you need atomics or process-shared mutexes. Forgetting shm_unlink() leaks the segment across runs.
I/O Multiplexing
To service many connections with one thread without blocking on each, multiplexing is used:
select()is portable but limited byFD_SETSIZEand on Linux overwrites the suppliedtimeval(you must reset it each loop iteration). Also,select()with an empty descriptor set is a precise sub-second timer, unlikesleep()which only takes whole seconds.poll()has noFD_SETSIZElimit and is also portable.epoll()scales to tens of thousands of connections but is Linux-specific (the BSD equivalent iskqueue). For portable code, usepoll()or an abstraction likelibevent.
Program Loading
An executable on Linux is ELF. ELF has two views of the same data: sections (link view — .text for code, .data for initialized data, .bss for zeroed data, etc.) and segments (load view — what the loader maps into memory). Confusing them is a common mistake. .bss takes no space in the file (only size is stored) but occupies memory at runtime — so large zero-initialized arrays live in .bss, not .data.
The dynamic loader (ld.so) at process startup finds and loads shared libraries, resolves symbols. Search order is set by RPATH, LD_LIBRARY_PATH, and RUNPATH (RPATH is checked before LD_LIBRARY_PATH, RUNPATH after). LD_PRELOAD forces a library to load first — its symbols override others (function interposition — used for profiling and mocking). For setuid binaries, LD_PRELOAD is disabled for security.
Container Primitives
A container is not a virtual machine: containers share the host kernel. They are built from two orthogonal kernel mechanisms:
- Namespaces isolate the view of the system. A process can have its own PID namespace, mount namespace, network namespace, user namespace, etc. — inside, it sees its own process tree, its own mount points, its own network.
- cgroups limit and account for resources — CPU, memory, I/O. cgroups v2 uses a unified hierarchy; mixing it with v1 is not allowed.
Namespaces answer "what does the process see", cgroups answer "how much can the process consume". Do not confuse them — they are separate mechanisms. On top sit resource limits per process (setrlimit / ulimit — e.g., RLIMIT_NOFILE for descriptor count).
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
No wait() after fork() | Child becomes a zombie in the process table |
stat()/access() before open() to check existence | TOCTOU race — a concurrent process can intervene between check and open |
O_CREAT without O_EXCL, expecting not to open existing files | Existing file will be silently opened |
| Unused pipe end not closed | read() never returns EOF |
| Descriptors not closed on error paths | FD leak → open() returns EMFILE |
Confusing O_CLOEXEC with inheritance via fork() | O_CLOEXEC affects exec, not fork |
printf or mutex in signal handler | UB / self-deadlock — function is not async-signal-safe |
Trying to catch SIGKILL / SIGSTOP | Impossible by kernel design |
| Summing RSS across processes | Shared libraries counted multiple times |
| VSZ growth mistaken for memory leak | VSZ grows even without RAM cost (mmap PROT_NONE) |
unlink treated as immediate deletion | Inode lives as long as a link or open descriptor exists |
| Confusing namespaces with cgroups | View isolation ≠ resource limiting |
nice understood as percentage of CPU | It is a time-share ratio among competitors |
Interview Relevance
OS and Linux are frequent topics in middle+ level C++ interviews, especially in backend, embedded, and systems work. The goal is not to test command memorization, but understanding of what the kernel does behind your call and at what cost.
What the interviewer is actually checking:
- Do you know the difference between a process and a thread at the OS level and why threads are cheaper?
- Do you understand
fork/exec, copy-on-write, and why a child's writes are not visible to the parent? - Can you explain file descriptors: inheritance via
fork, shared offset,O_CLOEXEC, leaks? - Do you know the TOCTOU race and atomic file operations (
O_EXCL)? - Can you walk through signals: default actions, async-signal-safety, untrappable signals (
SIGKILL)? - Do you understand memory metrics (RSS / VSZ / PSS) and why naive summation is wrong?
- Do you grasp containers: namespaces vs cgroups, shared kernel?
Typical questions:
- Why is a thread cheaper than a process at the OS level?
- What happens to memory after
fork()and what is copy-on-write? - Does a child inherit an open file after
fork(), and what about the offset? - How do you atomically create a file only if it does not exist?
- Process A is reading a file; process B
unlinks it. What happens? - How do namespaces differ from cgroups, and why is a container not a VM?
Common wrong answer: listing syscalls and commands without explaining the mechanics. The interviewer is looking for understanding of the user/kernel boundary, resource lifetime, and operation cost — not a paraphrase of the man pages.