ismile@portfolio:~$ cat notes/0006-linux-for-backend-engineers-part-5.md
Linux for Backend Engineers Part 5
Linux for Backend Engineers
- Part 1: The Foundation
- Part 2: Files, Filesystems, and Permissions
- Part 3: Processes and Threads
- Part 4: Memory Management
- Part 5: Signals and Communication ← current
- Part 6: IPC, Networking, and Containers
11. Signals: How Processes Communicate with the Kernel
A process doesn't run completely on its own. The Linux kernel needs a way to notify processes about important events:
- A user wants a process to stop.
- A child process exits.
- A process receives an interrupt.
- A timer expires.
- A serious error occurs.
Linux provides signals for this purpose. Signals are not general-purpose data channels. They are primarily a mechanism for notification and control.
11.1 What Is a Signal?
A signal is a small asynchronous notification from the kernel (or another process) to a process or thread.
A signal is identified by a number and a name. When a signal is delivered to a process, one of three things happens:
- Default action - the kernel does what it normally does (terminate, stop, ignore)
- Custom handler - the process runs its own code
- Ignore - the signal is discarded (if allowed)
| Signal | Number | Default Action | Description | Common Use |
|---|---|---|---|---|
SIGINT |
2 | Terminate | Interrupt from keyboard | Ctrl+C |
SIGKILL |
9 | Terminate (uncatchable) | Force kill | Emergency termination |
SIGTERM |
15 | Terminate | Graceful termination request | Normal shutdown |
SIGSTOP |
19 | Stop (uncatchable) | Suspend process | Job control |
SIGCONT |
18 | Continue | Resume stopped process | Job control |
SIGHUP |
1 | Terminate | Hangup | Terminal closed, reload config |
SIGCHLD |
17 | Ignore | Child status changed | Child process management |
SIGSEGV |
11 | Core dump | Invalid memory access | Segmentation fault |
SIGPIPE |
13 | Terminate | Write to closed pipe | Broken pipe |
You can see the available signals with:
kill -l11.2 How Signals Work: The Delivery Mechanism
Signals go through the kernel. You can't send a signal directly to another process:
Process A Linux Kernel Process B
│ │ │
│ kill(pid, SIGTERM) │ │
├────────────────────────────►│ │
│ │ Check permissions │
│ │ Queue signal │
│ │ │
│ │ Deliver SIGTERM │
│ ├─────────────────────────────►│
│ │ │
│ │ │
│ │ Process B handles signal │
│ │◄─────────────────────────────┤For example:
kill -TERM 1234Doesn't directly terminate PID 1234 from user space. Instead, the request goes through the kernel, which delivers SIGTERM to the target process.
11.3 Sending Signals
The kill command (and kill() syscall) sends signals. Despite the name, kill doesn't always terminate. It just sends a signal.
# General syntax
kill -SIGNAL_NAME PID
kill -SIGNAL_NUMBER PID
# Examples
kill -TERM 1234 # Graceful termination
kill -INT 1234 # Same as Ctrl+C
kill -STOP 1234 # Suspend process
kill -CONT 1234 # Resume process
kill -KILL 1234 # Force kill (uncatchable)
kill -HUP 1234 # Reload configurationSIGTERM vs SIGKILL
This distinction is extremely important for backend applications.
-
SIGTERM: Please terminate gracefully. The process can handle the signal and perform cleanup.SIGTERM │ ▼ Application │ ├── Stop accepting new requests ├── Finish active requests ├── Close database connections ├── Close files └── Exit -
SIGKILL: Terminate the process immediately. The process cannot catch, ignore, or handle this.
SIGINT: Ctrl+C
When you run node server.js from the terminal and then press Ctrl + C the terminal normally causes an interrupt signal SIGINT to be delivered to the foreground process.
Keyboard
│
Ctrl+C
│
▼
Terminal
│
▼
SIGINT
│
▼
Node.js Process11.4 Signal Handlers: Custom Responses
A process can register code to run when certain signals arrive. For example, a Node.js application can listen for SIGTERM:
process.on("SIGTERM", () => {
console.log("Shutdown requested");
// cleanup
process.exit(0);
});Now:
kill -TERM <PID>Causes Node.js to execute the registered handler. This is the foundation of graceful shutdown.
11.5 Graceful Shutdown in Node.js
Production services must handle SIGTERM and SIGINT to finish in‑flight requests and close database connections.
const server = app.listen(3000);
function shutdown(signal) {
console.log(`${signal} received – starting graceful shutdown`);
server.close(() => {
console.log("No new connections accepted");
// close DB pools, then:
process.exit(0);
});
// Force exit after 10s
setTimeout(() => process.exit(1), 10000);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));SIGKILL cannot be handled – your code will never run.
11.6 Signals Have Default Actions
A process doesn't necessarily need to install a handler for every signal. Many signals have a default action defined by the operating system.
For example:
SIGTERM → terminate
SIGSTOP → stop
SIGCONT → continue
SIGKILL → terminate immediatelySo when a signal arrives:
Signal
│
▼
┌────────────────┐
│ Custom Handler?│
└───────┬────────┘
│
┌─────┴─────┐
Yes No
│ │
▼ ▼
Handler Default ActionSome signals can be ignored or handled, while special signals such as SIGKILL and SIGSTOP cannot be caught or overridden.
11.7 Signals and Child Processes
Signals are also important for process trees.
Consider:
Node Process
│
├── Worker
├── Worker
└── Child Process
When a process manages children, it often needs to coordinate their lifecycle. The SIGCHLD signal can notify a parent that a child has changed state, such as exiting.
Conceptually:
Child
│
│ exit()
▼
Kernel
│
│ SIGCHLD
▼
Parent
│
▼
wait()/waitpid()This connects signals with the process lifecycle discussed in Section 9.
11.8 Signals Are Not IPC Data Streams
A common misunderstanding is to think:
"Signals are how processes communicate."
That's only partially true. Signals are primarily useful for notifications and control. They are not designed to transfer arbitrary amounts of data.
For example:
Signal
│
└── "Something happened."
Whereas IPC mechanisms such as pipes, sockets, and shared memory can carry actual data:
Pipe
│
└── "Here are 50 KB of data."So think of them as:
Signals → notification/control
Pipes → byte stream
Sockets → communication/data
Shared Memory → shared data
We'll explore IPC mechanisms in a later section.
11.9 Signals and Permissions
A process cannot simply send signals to any process it wants. The kernel checks permissions.
For example:
kill -TERM 1234Requires appropriate permission to signal PID 1234. This is another example of the kernel enforcing process isolation and security.
11.10 Practical Experiment: Stopping and Resuming a Process
Start a background process:
# 1000 seconds
sleep 1000 &Find its PID:
pgrep sleepExample output:
1234Stop the process using SIGSTOP:
kill -STOP 1234Check its state:
ps -o pid,stat,cmd -p 1234The process should show a stopped state, usually marked with T.
Resume the process using SIGCONT:
kill -CONT 1234Finally, terminate it gracefully:
kill -TERM 1234This demonstrates the process state transitions:
Running
│
│ SIGSTOP
▼
Stopped
│
│ SIGCONT
▼
Running
│
│ SIGTERM
▼
Terminated11.11 The Mental Model
Linux Kernel
│
┌────────┴────────┐
│ │
Process A Process B
│ │
│ signal │
└────────────────►│
│
┌──────┴──────┐
│ │
Signal Handler Default Action
│
▼
ApplicationThe key ideas are:
A signal is an asynchronous notification delivered through the kernel.
SIGTERMrequests graceful termination;SIGKILLforces immediate termination.
Signals are primarily for notification and control, not transferring large amounts of data.
Node.js can handle signals to implement graceful shutdown.
Signals connect Linux process management directly to production application lifecycle.
12. File Descriptors & I/O: How Processes Interact with the Outside World
A process is isolated in its own virtual memory sandbox. But real programs need to reach outside that sandbox:
- Read configuration files
- Write logs
- Accept HTTP connections
- Talk to databases
- Communicate with other processes
Linux provides a single, uniform mechanism for all of this:
A file descriptor (FD) is a small non-negative integer that identifies an open resource belonging to a process.
This single concept unifies files, pipes, sockets, devices, and more under one interface. Understanding it is essential for backend engineering.
12.1 The File Descriptor Model
When a process wants to interact with the outside world, it asks the kernel to open a resource:
int fd = open("data.txt", O_RDONLY);The kernel returns a small integer, say, 3. This number is the file descriptor. The process then uses this number for all subsequent operations:
read(3, buffer, size); // Read from the file
write(3, buffer, size); // Write to it
close(3); // Release the resourceThe process never touches the actual file directly. It only ever sees the integer. The kernel maintains the mapping between that integer and the real resource.
Application Kernel
│ │
│ open("data.txt") │
├────────────────────────────►│──► Open File (kernel object)
│ │
│ return 3 │
│◄────────────────────────────┤
│ │
│ read(3, buf, 1024) │
├────────────────────────────►│──► Disk / Page Cache
│ │
│ return bytes │
│◄────────────────────────────┤
│ │
│ close(3) │
├────────────────────────────►│──► Resource freed
│ │
12.2 Standard File Descriptors
Every Unix process starts with three file descriptors already open:
| FD | Name | Purpose | Typical Target |
|---|---|---|---|
0 |
stdin | Standard input | Terminal, pipe, file |
1 |
stdout | Standard output | Terminal, pipe, file |
2 |
stderr | Standard error | Terminal, pipe, file |
This is why shell redirection works so elegantly:
Think of them as:
# Redirect stdout to a file (FD 1)
node app.js > output.log
# Redirect stderr separately (FD 2)
node app.js 2> error.log
# Redirect both (FD 1 and FD 2 to same file)
node app.js > all.log 2>&1
# Pipe stdout of one process into stdin of another (FD 0)
cat access.log | grep "500"12.3 The Per-Process FD Table
Each process has its own file descriptor table maintained by the kernel:
Process A (PID 1000)
├── FD 0 → /dev/pts/0 (terminal)
├── FD 1 → /dev/pts/0 (terminal)
├── FD 2 → /dev/pts/0 (terminal)
├── FD 3 → /var/log/app.log
├── FD 4 → socket (listening on :3000)
└── FD 5 → pipe (to child process)
Process B (PID 1001)
├── FD 0 → /dev/null
├── FD 1 → /tmp/output.txt
├── FD 2 → /tmp/errors.txt
├── FD 3 → socket (connected to database)
└── FD 4 → pipe (to Process C)Key insights:
- FD numbers are reused — FD 3 means completely different things in different processes
- FD numbers are allocated lowest-first — the kernel gives you the smallest unused number
- FDs are inherited — child processes can receive copies of the parent's FDs
12.4 Inspecting File Descriptors
Linux exposes everything through /proc. Let's see it in action:
# Start a Node.js server in the background
node -e "
const net = require('net');
const server = net.createServer(() => {});
server.listen(3000);
console.log('PID:', process.pid);
setTimeout(() => {}, 60000);
" &
# Find the PID (it was printed, or use pgrep)
pgrep -f "node -e"
# Inspect its file descriptors
ls -l /proc/<PID>/fdYou'll see something like:
lrwx------ 1 user user 64 Dec 1 10:00 0 -> /dev/pts/0
lrwx------ 1 user user 64 Dec 1 10:00 1 -> /dev/pts/0
lrwx------ 1 user user 64 Dec 1 10:00 2 -> /dev/pts/0
lrwx------ 1 user user 64 Dec 1 10:00 3 -> socket:[123456]
lrwx------ 1 user user 64 Dec 1 10:00 4 -> /dev/pts/0
lrwx------ 1 user user 64 Dec 1 10:00 5 -> /proc/1234/fdNotice FD 3; that's the listening socket. This is the "everything is a file" philosophy in action.
12.5 "Everything Is a File”: The Real Meaning
The phrase is often misunderstood. It doesn't mean everything is literally stored as a regular file on disk. It means:
Linux exposes many different kinds of resources through the same file-descriptor interface.
| Resource | FD Operations | Example |
|---|---|---|
| Regular file | read(), write(), lseek() |
/etc/config.json |
| Directory | readdir() |
/home/user |
| Terminal | read(), write() |
/dev/pts/0 |
| Pipe | read(), write() |
pipe() |
| Network socket | read(), write(), send(), recv() |
TCP connection |
| Device | ioctl(), read(), write() |
/dev/sda |
| Process info | read() |
/proc/1234/status |
This uniformity means:
- One API to learn —
read()andwrite()work for almost everything - Composable tools —
grepworks on files, pipes, and sockets the same way - Simple mental model — if you understand FDs, you understand I/O
12.6 Opening, Reading, Writing, Closing
The complete FD lifecycle:
At a low level, an application can ask the kernel to open a file:
// 1. Open — kernel allocates an FD
int fd = open("data.txt", O_RDONLY);
// fd = 3 (or lowest available)
// 2. Read — kernel fetches data into your buffer
char buffer[1024];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
// 3. Write — kernel writes your buffer to the resource
ssize_t bytes_written = write(fd, buffer, bytes_read);
// 4. Close — kernel releases the FD and underlying resource
close(fd);The simplified lifecycle is:
open()
│
▼
FD returned
│
▼
read()/write()
│
▼
close()
Higher-level languages usually hide these system calls behind their own APIs.
Key details:
- Failing to close → FD leak (more on this below)
- Reading past EOF →
read()returns 0 - Writing to closed FD →
EBADFerror - Concurrent access → kernel handles locking
12.7 File Descriptors and Node.js
Node.js wraps FDs in higher-level abstractions, but they're always there underneath:
Low-Level FD API
const fs = require("fs");
// Open returns an FD (integer)
const fd = fs.openSync("data.txt", "r");
console.log("FD:", fd); // Typically 3 (after stdin/out/err)
// Read using FD
const buffer = Buffer.alloc(1024);
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
console.log("Read", bytesRead, "bytes");
// Always close
fs.closeSync(fd);Stream API (Recommended)
const fs = require("fs");
// Streams manage FDs internally
const readStream = fs.createReadStream("data.txt");
const writeStream = fs.createWriteStream("output.txt");
// Pipe uses FDs under the hood
readStream.pipe(writeStream);
// Streams auto-close their FDs
readStream.on("close", () => console.log("FD released"));Why Streams?
Streams provide:
- Automatic FD management
- Backpressure handling
- Error propagation
- Memory efficiency (no need to load entire file)
12.8 Pipes: One-Way Data Channels
A pipe provides a unidirectional byte stream between processes. Data written to one end comes out the other, exactly in the order it was sent. The kernel stores the data in an internal buffer until the reader fetches it.
Anonymous Pipes (Parent-Child)
Created with the pipe() syscall, which returns two FDs:
int fd[2];
pipe(fd);
// fd[0] = read end
// fd[1] = write endTypical usage in shells:
# ls writes to pipe, grep reads from pipe
ls -l | grep ".txt"Under the hood:
Shell Process
│
├── create pipe() → fd[0] (read), fd[1] (write)
│
├── fork() → Child 1
│ ├── dup2(fd[1], stdout) // Redirect stdout to pipe write end
│ └── exec("ls")
│
├── fork() → Child 2
│ ├── dup2(fd[0], stdin) // Redirect stdin from pipe read end
│ └── exec("grep")
│
└── close all pipe FDs in parentData flow:
ls stdout ──► pipe buffer ──► grep stdin
Process A
│
│ write()
▼
┌──────────┐
│ Pipe │
└──────────┘
│
│ read()
▼
Process BThe first process writes bytes into the pipe. The second process reads those bytes. This is one of the simplest forms of IPC.
Pipes in Node.js
const { spawn } = require("child_process");
// Create child process
const child = spawn("grep", ["error"]);
// Write to child's stdin (pipe)
child.stdin.write("error: database failed\n");
child.stdin.write("success: operation complete\n");
child.stdin.end();
// Read from child's stdout (pipe)
child.stdout.on("data", (data) => {
console.log("Filtered:", data.toString());
// Output: Filtered: error: database failed
});12.9 Backpressure: When Pipes Fill Up
Pipes have limited buffer capacity (typically 64KB on Linux). If the writer produces faster than the reader consumes:
Writer Pipe Buffer Reader
│ ┌───────┐ │
│ write 1 KB │ │ │
├─────────────────────────►│ 1 KB │ │
│ write 1 KB │ │ │
├─────────────────────────►│ 2 KB │ │
│ write 1 KB │ │ │
├─────────────────────────►│ 3 KB │ │
│ ... │ ... │ │
│ write 1 KB │ │ │
├─────────────────────────►│ 64 KB │ │
│ │ FULL! │ │
│ write 1 KB │ │ │
├─────────────────────────►│ │ │
│ BLOCKS! │ │ │
│ │ │◄──── read 1 KB ───────┤
│ │ 63 KB │ │
│ │ │ │
│ resumes writing │ │ │
├─────────────────────────►│ 64 KB │ │
│ └───────┘ │This blocking is backpressure; the consumer is indirectly telling the producer to slow down.
Backpressure in Node.js: Streams implement this natively. When you
pipe()a fast readable stream to a slow writable stream, the readable stream automatically pauses until the writable stream catches up.
const fs = require("fs");
const zlib = require("zlib");
// Read a large file, compress it, write it
// Backpressure is handled automatically
fs.createReadStream("large-file.txt")
.pipe(zlib.createGzip())
.pipe(fs.createWriteStream("compressed.gz"));12. 10 Sockets: File Descriptors for Networking
A socket is a communication endpoint, also accessed through a file descriptor. But unlike files, sockets are designed for ongoing, bidirectional communication.
Socket Types
| Type | Protocol | Characteristics | Use Case |
|---|---|---|---|
SOCK_STREAM |
TCP | Reliable, ordered, connection-oriented | HTTP, databases |
SOCK_DGRAM |
UDP | Unreliable, unordered, connectionless | DNS, streaming, metrics |
SOCK_RAW |
Raw | Direct access to IP packets | Network tools, VPNs |
Network Socket Lifecycle
Server Client
│ │
│ socket() ── create FD │ socket() ── create FD
│ │
│ bind() ── attach to port │
│ │
│ listen() ── wait for connections │
│ │
│ accept() ── blocks │ connect() ── attempts connection
│ │
│ ┌── TCP handshake ─────────┤
│ │ │
│ └──────────────────────────┘
│ │
│ accept() returns new FD │
│ │
│ read()/write() on connection FD │ read()/write() on connection FD
│ │
│ close() │ close()
│ │Key insight: The listening socket is different from the connection socket. The listening socket only accepts connections. Each accepted connection gets its own FD.
12.11 Blocking vs Non-Blocking I/O
When you call read() on a file descriptor, what happens if there's no data yet?
Blocking I/O (Default)
The thread waits until data is available:
Thread ──► read(fd) ──► WAITING ──► data arrives ──► continue
│
└── thread is blocked, does nothing
Non-Blocking I/O
The call returns immediately, even if no data is ready:
Thread ──► read(fd) ──► EAGAIN (no data) ──► continue doing other work
│
└── thread stays free
Why This Matters for Node.js
Node.js uses non-blocking I/O for everything. When you await a network operation:
const data = await fetch("<https://api.example.com/data>");
// While waiting, Node.js handles other requests
// The thread is not blockedBut for some operations (filesystem, DNS lookups), Node.js uses a thread pool because the OS doesn't provide async versions:
// This blocks a libuv worker thread (not the main thread)
const data = await fs.promises.readFile("huge-file.bin");12.12 File Descriptor Leaks: A Real Production Problem
If you open resources but never close them, FDs accumulate:
// BAD: FD leak
function readFiles() {
const files = [];
for (let i = 0; i < 10000; i++) {
const fd = fs.openSync("config.json", "r");
// Never closed! FD leaks.
files.push(fd);
}
return files;
}
// Each open fd consumes kernel memory
// Eventually: EMFILE: too many open filesDetecting FD Leaks
# Count open FDs for a process
ls /proc/<PID>/fd | wc -l
# Monitor FD usage over time
watch -n 1 "ls /proc/<PID>/fd | wc -l"
# Check the limits
cat /proc/<PID>/limits | grep "Max open files"Preventing FD Leaks in Node.js
// GOOD: Always close
function readFileSafe(path) {
let fd;
try {
fd = fs.openSync(path, "r");
// ... use fd
return fs.readFileSync(fd, "utf8");
} finally {
if (fd !== undefined) {
fs.closeSync(fd); // Always closes, even on error
}
}
}
// BETTER: Use streams or promises (auto-close)
async function readFileBetter(path) {
return await fs.promises.readFile(path, "utf8");
}You can inspect the process's open descriptors:
ls /proc/<PID>/fd | wc -l12.13 File Descriptor Limits
Linux limits how many FDs a process can hold. This matters for high-concurrency
| Type | Default Limit | Common Production Setting |
|---|---|---|
| Soft limit (per-process) | 1024 | 65535 or higher |
| Hard limit (kernel) | 1048576 | Depends on system memory |
# Check current limits
ulimit -n
# 1024 (typical default)
# Check hard limit
ulimit -Hn
# 1048576
# Increase temporarily (current shell)
ulimit -n 65535
# For a specific process
ulimit -n 65535 && node server.js**Why This Matters**A web server handling 10,000 concurrent connections needs:
- 10,000 socket FDs for connections
- Plus FDs for logs, configs, database connections
- Plus FDs for internal pipes and communication
12.14 The Mental Model
PROCESS
│
┌──────┴──────┐
│ FD Table │
└──────┬──────┘
│
┌────────────────┼────────────────┐
│ │ │
FD 0 FD 3 FD 7
stdin file.log socket:3000
│ │ │
▼ ▼ ▼
Terminal Disk File Listening Socket
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
Client A Client B Client C
│ │ │
▼ ▼ ▼
Connection Connection Connection
FD 8 FD 9 FD 10The important ideas are:
A file descriptor is a small integer representing an open resource for a process.
stdin,stdout, andstderrare file descriptors 0, 1, and 2.
Files, pipes, sockets, and many other resources can be represented through file descriptors.
Pipes provide a simple mechanism for process-to-process communication and introduce backpressure.
Sockets provide communication endpoints for networking and other forms of IPC.
Blocking I/O waits; non-blocking I/O allows the application to continue while waiting for readiness/completion.
File descriptors are finite resources, so failing to close them can cause resource leaks and eventually
EMFILE.