In the previous lesson we arrived at the idea of paging and left it hanging: if all the pieces are the same size, fitting them together stops being a problem. Now it is time to deliver on that promise. This is the densest lesson in the module and probably the most profitable one in the whole course, because virtual memory is the mechanism that explains an enormous number of things you will see in production: why a process reserves 400 MB and only uses 31, why the first request to meteo-api after a deployment is slow and the following ones are not, why a server that starts paging stops responding all at once, and why the OOM killer picks what it picks.
You are going to see address translation with concrete numbers, calculate the impossible size of a flat 64-bit table, measure how much performance depends on the TLB, follow a page fault step by step, work out the replacement algorithms over one and the same reference string, and map the file 2026-08-31.dat into memory with mmap(). By the end you will be able to read free -h, vmstat and the OOM killer's trail with proper judgement.
Contents
- Pages and frames
- Address translation, with a complete numerical example
- The page table and its control bits
- Why a flat table is impossible in 64 bits
- Multilevel tables
- The TLB and the effective access time
- Huge pages
- Demand paging and the page fault
- Page replacement algorithms
- Frame allocation, thrashing and the working set
- Mapped files and shared memory with
mmap() - Copy-on-write revisited
- Swap,
swappinessand the OOM killer - Measuring:
VSZversusRSS,/proc/meminfo,free -h,vmstat
Pages and frames
These two terms are the foundation of the whole vocabulary and it is worth pinning them down with no ambiguity:
| Page | Frame | |
|---|---|---|
| Where it lives | The process's logical space | Physical memory |
| Size | 4 KB (typical) | 4 KB (the same, necessarily) |
| How many there are | Up to 2^36 on x86-64 | RAM / 4 KB |
| Numbered from | 0, per process | 0, globally |
Check your system's page size:
And meteo-01's numbers:
Logical space per process: 2^47 bytes = 128 TB → 2^35 pages Installed RAM: 8 GB = 8,589,934,592 bytes → 2,097,152 frames
There are thirty-four billion times more possible pages than available frames. That disproportion is exactly what makes virtual memory possible: the vast majority of pages will never exist, and the ones that do exist do not all have to be in RAM at the same time.
Address translation, with a complete numerical example
A logical address is split into two fields:
┌───────────────────┬──────────────┐ │ Page number (p) │ Offset (d) │ └───────────────────┴──────────────┘
- The offset takes as many bits as are needed to address inside a page. With 4 KB pages = 2^12 bytes, that is 12 bits.
- The page number takes the rest.
The translation has three steps:
- Extract
panddfrom the logical address. - Look up entry
pin the page table, which holds the frame numberf. - The physical address is
f × page_size + d.
The offset is never translated. A page and a frame are the same size, so the position inside the page is identical to the position inside the frame. Only which block changes, not where inside the block.
A complete example
Let us work with a small logical space so that everything fits on screen: 16-bit addresses and 4 KB pages.
Logical address: 16 bits Page: 4 KB = 2^12 → 12-bit offset Page number: 16 − 12 = 4 bits → 16 pages (0 to 15)
The ingestor's page table:
| Page | Frame | Present |
|---|---|---|
| 0 | 5 | Yes |
| 1 | 9 | Yes |
| 2 | 2 | Yes |
| 3 | — | No |
| 4 | 7 | Yes |
Translate the logical address 0x2A5C.
Step 1, decompose it:
0x2A5C in binary: 0010 1010 0101 1100
└──┘ └────────────┘
p d
p = 0010₂ = 2
d = 1010 0101 1100₂ = 0xA5C = 2652The arithmetic check, which is usually faster:
Step 2, consult the table: page 2 → frame 2, present.
Step 3, assemble the physical address:
It matched the logical address by pure coincidence (page 2 happens to be in frame 2). Let us try another one.
Translate 0x105C:
p = 0x105C / 4096 = 4188 / 4096 = 1 d = 4188 − 4096 = 92 Table: page 1 → frame 9 physical = 9 × 4096 + 92 = 36864 + 92 = 36956 = 0x905C
Notice a revealing detail: 0x105C → 0x905C. The three right-hand hexadecimal digits do not change (05C), because they are the offset. Only the left-hand digit changes: 1 → 9, from page to frame. In hexadecimal the translation is visually obvious whenever the page size is a power of 16.
Translate 0x3200:
Here no translation is possible. The MMU raises an exception and the kernel takes over. What it does then is section 8.
The page table and its control bits
Each page table entry (PTE) takes 8 bytes on x86-64 and holds far more than a frame number:
| Bit | Name | What it means | Who sets it |
|---|---|---|---|
| 0 | P (Present) | The page is in RAM | The kernel |
| 1 | R/W | 0 = read-only, 1 = read/write | The kernel |
| 2 | U/S | 0 = kernel mode only, 1 = accessible in user mode | The kernel |
| 3 | PWT | Write-through cache policy | The kernel |
| 4 | PCD | Cache disabled (for memory-mapped I/O) | The kernel |
| 5 | A (Accessed) | The page has been accessed | The hardware |
| 6 | D (Dirty) | The page has been written to | The hardware |
| 7 | PS (Page Size) | It is a huge page (2 MB or 1 GB) | The kernel |
| 8 | G (Global) | It is not invalidated on a process switch | The kernel |
| 12-51 | Frame number | The 40 bits of the physical frame | The kernel |
| 63 | NX (No eXecute) | The page cannot be executed | The kernel |
The ones that really matter, and why:
Bit P (present). It is the master switch of all virtual memory. If it is 0, any access causes a page fault. The kernel uses it for three different situations, and tells them apart with the remaining bits of the entry, which are free when P=0: page never loaded, page evicted to swap, or simply invalid address.
Bits R/W and NX. This is where the per-region protection we saw in /proc/<pid>/maps is implemented. The r-xp code region has R/W=0 (not writable); the rw-p stack and heap regions have NX=1 (not executable). That pair of bits is the W^X policy.
Bits A and D. They are special because the hardware writes them, not the operating system. Every time the CPU accesses a page it sets A=1; every time it writes it sets D=1. The kernel reads them and clears them periodically. Without them it would be impossible to implement the replacement algorithms: the kernel has no way of observing every memory access, so it needs the hardware to leave it that clue.
Bit D (dirty). It determines the cost of evicting the page. If D=0, the page has not changed since it was loaded, so it can be discarded outright (if it is needed again it is re-read from the file). If D=1, it has to be written to swap before the frame can be reused. It is exactly the distinction of pmap's Dirty column that we saw in 02-03, and the cost difference is zero versus milliseconds.
Why a flat table is impossible in 64 bits
Let us do the calculation that justifies all the complexity that follows.
On x86-64, 48 bits of virtual address are currently in use (the top 16 are a sign extension). With 4 KB pages:
Bits for the offset: 12 Bits for the page number: 48 − 12 = 36 Number of possible pages: 2^36 = 68,719,476,736 Size of a flat table: 68,719,476,736 entries × 8 bytes = 549,755,813,888 bytes = 512 GB
512 GB of page table. Per process. On an 8 GB machine with 180 processes, you would need 92 TB just for the tables.
It is absurd, and it is absurd for a very concrete reason: a flat table reserves an entry for every possible page, including the ones that will never exist. Remember the ingestor's memory map: the code sits at 0x400000 and the stack at 0x7ffd8b3a1000. Between them lies a chasm of 140 TB of addresses that will never be used, and a flat table would need an entry for every one of them.
The real process uses about 4,500 pages out of the 68 billion possible ones: 0.0000065 %. The data structure has to exploit that extreme sparsity.
Multilevel tables
The solution is to make the table hierarchical and sparse: split the page number into several fields, each one indexing a level, and create only the lower-level tables that are actually needed.
x86-64 uses four levels (five on the most recent CPUs). The 36-bit page number is split into four 9-bit fields:
┌────────┬────────┬────────┬────────┬──────────────┐ │ PML4 │ PDPT │ PD │ PT │ Offset 12 │ │ 9 bits │ 9 bits │ 9 bits │ 9 bits │ │ └────────┴────────┴────────┴────────┴──────────────┘
Each level has 2^9 = 512 entries of 8 bytes = exactly 4,096 bytes, one page. That fit is no accident: each table takes up precisely one page, which makes managing them enormously simpler.
flowchart LR
CR3["CR3 register<br/>(per process)"] --> PML4
PML4["PML4<br/>512 entries"] -->|9-bit index| PDPT
PDPT["PDPT<br/>512 entries"] -->|9-bit index| PD
PD["Directory<br/>512 entries"] -->|9-bit index| PT
PT["Page table<br/>512 entries"] -->|9-bit index| FRAME["Physical frame<br/>+ offset"]
The saving is spectacular. For a process with code at the bottom and a stack at the top:
1 PML4 table: 4 KB
2 PDPT tables (one per area): 8 KB
2 PD tables: 8 KB
~10 PT tables (for ~5,000 pages): 40 KB
───────
Total: 60 KB60 KB against 512 GB. A factor of eight million.
The price: one translation requires four memory accesses (one per level) plus the access to the data itself. Five accesses where there used to be one. At ~100 ns per RAM access, that would be 500 ns for every memory read: the system would be 5 times slower than with no paging at all. Unacceptable.
That is the exact reason the TLB exists.
The TLB and the effective access time
The TLB (Translation Lookaside Buffer) is an associative cache, inside the MMU, that stores the page→frame translations used recently. It is small (between 64 and 1,536 entries on modern CPUs) and blazingly fast (under 1 ns).
The flow:
- The CPU generates a logical address.
- The MMU looks up the page number in the TLB.
- TLB hit: the frame is obtained directly. Cost ≈ 0.
- TLB miss: the four levels of tables in memory have to be walked, and then the result is inserted into the TLB.
Calculating the effective access time (EAT) is the formula you need to be able to work out:
where h is the hit rate, t_TLB the TLB lookup time, t_mem the memory access and n the number of levels.
With realistic values (t_TLB = 1 ns, t_mem = 100 ns, n = 4):
| Hit rate | Calculation | EAT | Degradation |
|---|---|---|---|
| 100 % | 1 + 100 | 101 ns | 1.00× |
| 99 % | 0.99×101 + 0.01×501 | 105 ns | 1.04× |
| 95 % | 0.95×101 + 0.05×501 | 121 ns | 1.20× |
| 90 % | 0.90×101 + 0.10×501 | 141 ns | 1.40× |
| 70 % | 0.70×101 + 0.30×501 | 221 ns | 2.19× |
| 50 % | 0.50×101 + 0.50×501 | 301 ns | 2.98× |
The 99 % case in detail:
Hit (99 %): 1 ns (TLB) + 100 ns (data) = 101 ns Miss (1 %): 1 ns + 4×100 ns (tables) + 100 ns (data) = 501 ns EAT = 0.99 × 101 + 0.01 × 501 = 99.99 + 5.01 = 105.0 ns
The practical conclusion is blunt: at a 99 % hit rate you lose only 4 %; at 70 % you lose more than half your performance. The TLB is not a minor optimization, it is what makes paging viable at all.
And that is why the cost of a context switch we calculated in 02-01 matters so much: when cr3 changes, the previous process's TLB entries stop being valid and are invalidated. The incoming process starts with an empty TLB and a burst of misses. PCIDs (process context identifiers) mitigate this by tagging each entry with the process it belongs to, so the whole TLB does not have to be flushed.
You can see the real TLB misses:
$ sudo perf stat -e dTLB-load-misses,dTLB-loads -p 1877 sleep 10
Performance counter stats for process id '1877':
12,847,331 dTLB-load-misses # 0.84% of all dTLB cache accesses
1,529,204,882 dTLB-loads
10.003 seconds time elapsedA 0.84 % miss rate, that is to say 99.16 % hits. Perfectly healthy. If you saw a 15 % miss rate, you would have a process with a very scattered access pattern, and that is where huge pages come in.
Huge pages
The problem appears when a process works with enormous data sets. Take the aggregator processing a whole day of readings:
One day of data: 17 MB 4 KB pages needed: 17,000,000 / 4,096 ≈ 4,150 pages TLB entries available (typical L2 TLB): 1,536
They do not fit. Walking those 17 MB causes continuous TLB misses, because the translations evict each other. And with 30 days of history it would be 124,000 pages: the TLB would be useless.
Huge pages solve this by using a larger page size: 2 MB or 1 GB on x86-64. It is achieved by stopping the hierarchy one level early (a page directory entry points directly at a 2 MB block instead of at a table).
| 4 KB | 2 MB | 1 GB | |
|---|---|---|---|
| Pages for 17 MB | 4,150 | 9 | 1 |
| TLB entries used | 4,150 | 9 | 1 |
| Translation levels | 4 | 3 | 2 |
| Average internal fragmentation | 2 KB | 1 MB | 512 MB |
| Eviction granularity to swap | Fine | Coarse | Unworkable |
The aggregator's 17 MB go from 4,150 TLB entries to 9. That fits with room to spare, and the TLB misses practically vanish.
On Linux there are two ways of using them:
$ cat /sys/kernel/mm/transparent_hugepage/enabled [always] madvise never $ grep -i huge /proc/meminfo AnonHugePages: 215040 kB HugePages_Total: 0 HugePages_Free: 0 Hugepagesize: 2048 kB
- THP (Transparent Huge Pages): the kernel automatically promotes large regions to 2 MB pages. The 215 MB of
AnonHugePagesshow that it is already happening. It is convenient, but it has a known cost: thekhugepageddaemon compacts memory in the background and can introduce pauses. That is why many databases recommend setting it tomadviseornever. - Explicit huge pages: they are reserved at boot and the application asks for them expressly. More control, less convenience.
The rule of thumb: huge pages help when the working set is large and is traversed in a scattered way; they get in the way when memory is scarce and paging is needed, because evicting 2 MB in one go is very expensive.
Demand paging and the page fault
Here comes the piece that turns paging into virtual memory: not all of a process's pages need to be in RAM.
Demand paging means loading nothing until it is needed. When meteo-api starts, the kernel does not read its 820 KB of code: it creates the entries with P=0 and lets page faults bring in whatever is required. This explains the figures we saw in 02-03: libssl with 12 KB out of 1,024 KB loaded.
When the CPU accesses a page with P=0:
sequenceDiagram
participant P as Process
participant M as MMU
participant K as Kernel
participant D as Disk
P->>M: access to address 0x3200
M->>M: consults the table: bit P = 0
M->>K: page fault exception (#PF)<br/>address in CR2
K->>K: is it a valid address for this process?
alt Invalid address
K->>P: SIGSEGV → segmentation fault
else Valid address
K->>K: looks for a free frame
alt No free frames
K->>K: picks a victim (replacement algorithm)
K->>D: if it is dirty, writes it to swap
end
K->>D: reads the page from the file or from swap
D-->>K: data (0.1 - 10 ms)
K->>K: updates the PTE: frame and P = 1
K->>P: retries the instruction that faulted
end
One essential detail at the end: the instruction that faulted is executed again from scratch. The process notices nothing at all. As far as it is concerned, that memory access simply took a long time.
The real cost of a page fault
This is where the numbers hurt. There are two very different kinds of fault:
| Type | What happens | Cost | Example |
|---|---|---|---|
| Minor fault | The page is in RAM but not mapped into this process | 1-3 µs | Page cache, COW, library already loaded |
| Major fault | It has to be read from disk | 0.1-10 ms | First read of a file, page in swap |
The difference is three to four orders of magnitude:
Minor fault with an NVMe SSD: ~2 µs Major fault with an NVMe SSD: ~100 µs (50 times more) Major fault with a hard disk: ~8 ms (4,000 times more)
And now the calculation that explains why the major fault rate has to be minuscule. With a normal memory access of 100 ns and a major fault of 8 ms:
Fault rate p |
EAT | Degradation |
|---|---|---|
| 0 | 100 ns | 1× |
| 1 in 1,000,000 | 108 ns | 1.08× |
| 1 in 100,000 | 180 ns | 1.8× |
| 1 in 10,000 | 900 ns | 9× |
| 1 in 1,000 | 8,100 ns | 81× |
With one major fault per thousand accesses, the system runs 81 times slower. To keep the degradation below 10 % you need fewer than one fault per million accesses.
This is not an academic curiosity: it is exactly what happens to a server that starts paging. It does not degrade smoothly, it falls off a cliff. And it is the reason for the thrashing we will see in section 10.
You can see your processes' faults:
$ ps -eo pid,min_flt,maj_flt,comm -u meteora
PID MINFL MAJFL COMMAND
1842 84213 3 ingestor
1877 291045 112 aggregator
1901 138922 8 meteo-apiMinor faults are normal and plentiful: they are part of ordinary operation. The major ones are the ones that matter: 112 in the aggregator after 22 hours is perfectly healthy. If you saw 400,000, the process would be reading constantly from swap and that would be the problem to solve.
Page replacement algorithms
When a page fault occurs and there are no free frames left, somebody has to be evicted. The choice determines how many faults there will be afterwards.
We will work out all the algorithms over the same reference string, which is the only honest way of comparing them:
Optimal (OPT)
It evicts the page that will not be used again for the longest time. It is impossible to implement (it requires knowing the future), but it serves as a lower bound to compare against.
| Ref | Frames | Fault | Victim and why |
|---|---|---|---|
| 7 | [7,-,-] | ● | — |
| 0 | [7,0,-] | ● | — |
| 1 | [7,0,1] | ● | — |
| 2 | [2,0,1] | ● | 7 (does not come back until the end) |
| 0 | [2,0,1] | already there | |
| 3 | [2,0,3] | ● | 1 (comes back at position 14) |
| 0 | [2,0,3] | already there | |
| 4 | [2,4,3] | ● | 0 (comes back later than 2 and 3) |
| 2 | [2,4,3] | ||
| 3 | [2,4,3] | ||
| 0 | [2,0,3] | ● | 4 (never comes back) |
| 3 | [2,0,3] | ||
| 2 | [2,0,3] | ||
| 1 | [2,0,1] | ● | 3 (does not come back) |
| 2 | [2,0,1] | ||
| 0 | [2,0,1] | ||
| 1 | [2,0,1] | ||
| 7 | [7,0,1] | ● | 2 (does not come back) |
| 0 | [7,0,1] | ||
| 1 | [7,0,1] |
9 faults.
FIFO
It evicts the page that has been loaded the longest, regardless of how much it is used.
| Ref | Frames (arrival order) | Fault |
|---|---|---|
| 7 | [7] | ● |
| 0 | [7,0] | ● |
| 1 | [7,0,1] | ● |
| 2 | [0,1,2] | ● 7 leaves |
| 0 | [0,1,2] | |
| 3 | [1,2,3] | ● 0 leaves |
| 0 | [2,3,0] | ● 1 leaves |
| 4 | [3,0,4] | ● 2 leaves |
| 2 | [0,4,2] | ● 3 leaves |
| 3 | [4,2,3] | ● 0 leaves |
| 0 | [2,3,0] | ● 4 leaves |
| 3 | [2,3,0] | |
| 2 | [2,3,0] | |
| 1 | [3,0,1] | ● 2 leaves |
| 2 | [0,1,2] | ● 3 leaves |
| 0 | [0,1,2] | |
| 1 | [0,1,2] | |
| 7 | [1,2,7] | ● 0 leaves |
| 0 | [2,7,0] | ● 1 leaves |
| 1 | [7,0,1] | ● 2 leaves |
15 faults. 67 % more than the optimum.
Belady's anomaly
FIFO has a flaw that defies intuition: giving it more memory can increase the number of faults. With this string:
With 3 frames:
| Ref | 1 | 2 | 3 | 4 | 1 | 2 | 5 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Fault | ● | ● | ● | ● | ● | ● | ● | ● | ● |
9 faults.
With 4 frames:
| Ref | 1 | 2 | 3 | 4 | 1 | 2 | 5 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Fault | ● | ● | ● | ● | ● | ● | ● | ● | ● | ● |
10 faults. More memory, more faults.
The cause: FIFO does not have the stack property (that the set of pages held with n frames is contained in the set held with n+1 frames). When you add a frame, the eviction order changes completely and it can throw out precisely what was about to be needed. LRU and OPT do have that property and are therefore free of the anomaly.
It is the main reason why pure FIFO is not used in any real system: an algorithm you cannot promise will do better with more RAM is unacceptable.
LRU (least recently used)
It evicts the page that has gone longest without being used. It rests on the principle of temporal locality: what has been used recently will probably be used again.
| Ref | Frames (most recent on the right) | Fault |
|---|---|---|
| 7 | [7] | ● |
| 0 | [7,0] | ● |
| 1 | [7,0,1] | ● |
| 2 | [0,1,2] | ● 7 leaves |
| 0 | [1,2,0] | |
| 3 | [2,0,3] | ● 1 leaves |
| 0 | [2,3,0] | |
| 4 | [3,0,4] | ● 2 leaves |
| 2 | [0,4,2] | ● 3 leaves |
| 3 | [4,2,3] | ● 0 leaves |
| 0 | [2,3,0] | ● 4 leaves |
| 3 | [2,0,3] | |
| 2 | [0,3,2] | |
| 1 | [3,2,1] | ● 0 leaves |
| 2 | [3,1,2] | |
| 0 | [1,2,0] | ● 3 leaves |
| 1 | [2,0,1] | |
| 7 | [0,1,7] | ● 2 leaves |
| 0 | [1,7,0] | |
| 1 | [7,0,1] |
12 faults. Between the optimum (9) and FIFO (15).
LRU's problem is the cost of implementing it. It requires keeping the pages ordered by last access, and that means updating a data structure on every memory access. You would need hardware that, on every read, moved an entry to the front of a list of thousands of elements. No CPU does this, because it would be ruinously expensive.
Approximations to LRU: second chance and the clock algorithm
Since exact LRU is unworkable, it is approximated using the A bit (accessed), which the hardware does maintain for free.
The clock algorithm arranges the frames in a circle with a pointer:
- The pointer points at a candidate frame.
- If its A bit is 0, it is evicted and the pointer advances.
- If its A bit is 1, it gets a second chance: A is set to 0 and the pointer moves on to the next one, evicting nothing.
- Repeat until a frame with A = 0 is found.
┌───────┐
┌───→│ P3 A=1│───┐
│ └───────┘ ↓
┌───────┐ ┌───────┐
│ P0 A=0│ │ P4 A=1│
└───────┘ └───────┘
↑ ┌───────┐ │
└────│ P2 A=0│←──┘
└───────┘
↑ pointerThe intuition is exactly right: a page with A=1 has been used since the pointer's last lap, so it will probably keep being used. One with A=0 has not been touched in a whole lap: it is a good candidate.
A better variant uses two bits, A and D, which combine recent use and eviction cost:
| A | D | Interpretation | Eviction priority |
|---|---|---|---|
| 0 | 0 | Neither used nor modified | 1st: the best victim, discarded for free |
| 0 | 1 | Not used but modified | 2nd: it has to be written out, but nobody wants it |
| 1 | 0 | Used, not modified | 3rd: discarded for free but in active use |
| 1 | 1 | Used and modified | 4th: the worst victim |
Linux uses a refined variant: two LRU lists (active and inactive) per memory zone, with promotion between them according to accesses, plus the A bit for ageing. It is approximate LRU, with the cost amortized down to almost nothing.
Final comparison
| Algorithm | Faults | Against the optimum | Implementable | Belady's anomaly |
|---|---|---|---|---|
| Optimal | 9 | — | No | No |
| LRU | 12 | +33 % | Only approximately | No |
| Clock (second chance) | ~13 | +44 % | Yes, cheaply | No |
| FIFO | 15 | +67 % | Yes, trivially | Yes |
And here is the practical conclusion that is usually overlooked: the difference between the best possible algorithm and a decent one is 33 %; the difference between having enough RAM and not having it is a factor of 81. Optimizing the replacement algorithm matters far less than sizing memory correctly.
Frame allocation, thrashing and the working set
With 180 processes and 2 million frames, how many frames does each process get?
Equal allocation: frames / processes. Simple and unfair: meteo-api with 116 MB of data would get the same as an sshd using 9 MB.
Proportional allocation: hand out frames according to the size of each process.
And an important distinction:
- Local replacement: a process that faults can only steal frames from itself. Its performance is predictable but it does not take advantage of other processes' idle memory.
- Global replacement: it can steal from anybody. Better overall use, but one process's performance depends on how the others behave. Linux uses global replacement.
Thrashing
Here is the most important phenomenon in this section. If a process does not have enough frames for its working set, it enters a destructive cycle:
flowchart TD
A["Process with too few frames"] --> B["Page fault"]
B --> C["Evicts a page<br/>it will need straight away"]
C --> D["Blocks waiting for the disk"]
D --> E["The CPU goes idle"]
E --> F["The system thinks it can<br/>admit more processes"]
F --> G["Fewer frames per process"]
G --> B
The loop feeds itself: the less CPU is used, the more processes are admitted, and the less memory is left for each one.
The symptoms are unmistakable and worth memorizing:
| Metric | Value under thrashing | Why |
|---|---|---|
| CPU usage | Very low (5-15 %) | Every process is waiting on the disk |
%iowait |
Very high (60-90 %) | There is nothing but disk activity |
| Major faults | Thousands per second | Every access faults |
vmstat's si/so columns |
Hundreds of MB/s | Constant swapping in both directions |
| Load average | Sky-high | Many processes in state D |
| The feeling | The system "is not responding" | It will not even accept an ssh |
The combination CPU at 10 % with a load average of 40 is the diagnosis. If you saw this on meteo-01:
$ vmstat 1 3 procs -----------memory---------- ---swap-- -----io---- --system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 1 24 4194300 22140 1024 81920 48932 51204 62104 51988 8421 21044 4 9 2 85 0 0 27 4194300 19008 1024 79872 52108 49872 64220 50104 9102 23811 3 11 1 85 0
The data speaks for itself: 85 % %wa (I/O wait), 27 processes blocked in the b column, and si/so at around 50,000 KB/s in both directions. The system is fetching and shipping out the same pages over and over. No configuration tweak saves this: memory is missing.
The working set model
The conceptual solution was formulated by Peter Denning in 1968. The working set W(t, Δ) is the set of pages referenced by a process in the last Δ references.
Δ = 10,000 references
References: ...2 6 1 5 7 7 7 5 1 6 2 3 4 1 2 3 4 4 4 3 4 4 4 1 3 2 3 4 4 4 4 3...
└──────── Δ window ─────────┘
W = {1, 2, 5, 6, 7} W = {3, 4}The rule is simple and powerful:
And when that happens, the only correct solution is to suspend processes — to reduce the degree of multiprogramming — so that the ones left have their complete working set. It is counter-intuitive but true: running fewer processes makes the system get more done.
Linux does not implement Denning's model literally, but its memory pressure handling and the OOM killer pursue the same goal: when the system cannot sustain everyone, it kills somebody instead of letting nobody make progress.
Mapped files and shared memory with mmap()
mmap() is one of the most powerful system calls in UNIX: it makes a file appear as memory.
Instead of open + read + copying into a buffer, the file is mapped into the address space and accessed as if it were an array.
/* read_readings.c — compile with: gcc -Wall -o read_readings read_readings.c */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdint.h>
struct Reading {
uint32_t station_id;
uint32_t timestamp;
float temperature;
float humidity;
float pressure;
uint32_t _reserved; /* rounds the struct up to 24 bytes */
};
int main(void) {
const char *path = "/var/lib/meteora/readings/2026-08-31.dat";
int fd = open(path, O_RDONLY);
if (fd == -1) { perror("open"); return 1; }
struct stat st;
if (fstat(fd, &st) == -1) { perror("fstat"); return 1; }
size_t n = st.st_size / sizeof(struct Reading);
printf("File of %ld bytes = %zu readings\n", (long)st.st_size, n);
/* Map the whole file into memory */
struct Reading *readings = mmap(NULL, st.st_size,
PROT_READ, MAP_PRIVATE, fd, 0);
if (readings == MAP_FAILED) { perror("mmap"); return 1; }
close(fd); /* the mapping outlives the descriptor being closed */
/* Walk it as if it were an ordinary array */
double sum = 0.0;
float max_temp = -100.0f;
for (size_t i = 0; i < n; i++) {
sum += readings[i].temperature;
if (readings[i].temperature > max_temp)
max_temp = readings[i].temperature;
}
printf("Mean temperature: %.2f °C\n", sum / n);
printf("Maximum temperature: %.2f °C\n", max_temp);
munmap(readings, st.st_size);
return 0;
}$ ./read_readings File of 17280000 bytes = 720000 readings Mean temperature: 18.43 °C Maximum temperature: 34.70 °C
What each part does and why it matters:
mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0)asks the kernel to associate the file with a region of the address space.NULLlets the kernel pick the address;PROT_READmakes it read-only;MAP_PRIVATEmeans that writes (were there any) would be copy-on-write and would never reach the file.- Nothing is read from disk at this point. The kernel merely creates page table entries with P=0. The 17 MB arrive on demand, as the loop touches them: every access to a new page causes a page fault that brings it in.
close(fd)does not invalidate the mapping. The mapping keeps its own reference to the file. It is a detail that surprises people, and it lets you close descriptors without losing access.readings[i].temperatureis ordinary pointer arithmetic. The compiler emits a memory access; the MMU and the kernel do the rest. There is not a single system call inside the loop.
That last point is the underlying reason for using mmap. Compare it with the read() alternative, applying what we calculated in 01-06:
read() in 4 KB blocks |
mmap() |
|
|---|---|---|
| System calls | 17,280,000 / 4,096 = 4,219 | 1 |
| Cost of syscalls at 1 µs | 4,219 µs = 4.2 ms | 1 µs |
| Copies of the data | 2 (disk→cache→user buffer) | 1 (disk→cache) |
| Extra memory | The process's buffer | None |
| If two processes read the same file | Two copies in RAM | They share the same pages |
| Random access | lseek + read |
Direct indexing |
The last row is especially valuable for Meteora: if the aggregator and meteo-api both map 2026-08-31.dat, the kernel's cache pages are physically shared. A single set of 17 MB in RAM serves both processes.
When mmap is not the right choice: for single-pass sequential reads of very large files, read() with a large buffer can be just as good or better, because mmap causes one page fault per 4 KB (thousands of exceptions), and because a file bigger than RAM mapped in its entirety can cause memory pressure.
mmap for shared memory
With MAP_SHARED instead of MAP_PRIVATE, writes do propagate: to the file and to every process that maps it.
int fd = open("/dev/shm/meteora-cache", O_RDWR | O_CREAT, 0640);
ftruncate(fd, 8 * 1024 * 1024);
void *cache = mmap(NULL, 8 * 1024 * 1024,
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);This is exactly the rw-s- region we saw in meteo-api's pmap in 02-03: the four workers share a single 8 MB cache. The inter-process communication mechanisms and the synchronization this demands belong to Inter-Process Communication (IPC) and Synchronization and Mutual Exclusion.
Copy-on-write revisited
Now that you know the page table bits, the COW behind fork() that we saw in 02-01 can be explained precisely:
fork()copies the parent's page tables into the child.- In both copies, every writable page is marked R/W = 0 (read-only), and the kernel notes internally that they are COW.
- Both processes point at the same physical frames. Zero data copied.
- When either of them writes, the MMU detects R/W=0 and raises a protection page fault.
- The kernel tells it apart from a genuine error (it checks that the region is COW rather than truly read-only), copies that single 4 KB page, assigns it exclusively to the writer and sets R/W=1 on it.
- If the original frame's reference count drops to 1, the other process also gets R/W=1 back: there is nothing left to protect.
The numbers for meteo-api with its 148 MB:
Naive copy: 148 MB / 20 GB/s = 7.4 ms
COW: ~37,000 page table entries ≈ 300 KB
0.3 MB / 20 GB/s + bookkeeping ≈ 0.5 ms
Improvement factor: ~15×
If the child calls execve() immediately: ~0 pages are copiedAnd now you also understand why fork() can look cheap and then turn expensive: if the child writes all over the inherited memory, you will end up paying for the copy page by page, with one page fault per 4 KB. That is the classic complaint against fork() in huge processes, and the reason alternatives such as posix_spawn() and vfork() exist.
Swap, swappiness and the OOM killer
The swap area is the disk space where evicted anonymous pages are stored.
$ swapon --show
NAME TYPE SIZE USED PRIO
/dev/sda3 partition 4G 512M -2
$ free -h
total used free shared buff/cache available
Mem: 7.8Gi 3.1Gi 412Mi 528Mi 4.3Gi 3.9Gi
Swap: 4.0Gi 512Mi 3.5GiHow to read free -h, which is where almost everybody goes wrong:
| Column | What it is | The usual trap |
|---|---|---|
total |
Installed RAM | — |
used |
Used by processes | — |
free |
Completely unused | 412 MB does not mean memory is short |
buff/cache |
Page cache and buffers | Freed instantly if needed |
available |
What a new process can obtain | This is the column that matters |
The 412 MB of free alarm a lot of people for no reason. The 4.3 GB of buff/cache are file pages the kernel keeps around in case they are needed again, and they are disposable on the spot. The memory that is really available is the 3.9 GB of available.
Free RAM is not RAM well spent. A system with free memory is wasting the chance to cache. What you want is a low
freeand a highavailable.
swappiness
It controls how aggressively the kernel evicts anonymous pages rather than discarding file cache:
| Value | Behavior | Suitable for |
|---|---|---|
| 0 | Swap only to avoid the OOM killer | Databases with enough RAM |
| 1-10 | Very reluctant to swap | Latency-sensitive servers |
| 60 | Default, balanced | Desktop, general use |
| 100 | Treats anonymous and file pages alike | Workloads with heavy file reading |
For meteo-01 a value of 10 would be reasonable: it is preferable to discard page cache (recoverable with a fast sequential read) rather than send meteo-api's response cache to swap (which would cause major faults right on the critical path of the requests).
$ sudo sysctl -w vm.swappiness=10 $ echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.d/99-meteora.conf
An important warning: swappiness=0 does not disable swap, and disabling swap altogether is not a good idea either. With no swap, inactive anonymous pages — and on any system there are a lot of them — sit in RAM forever, and under memory pressure the kernel goes straight to the OOM killer with no intermediate options.
The OOM killer
When there is no memory left and no frames to evict, the kernel triggers the Out Of Memory killer: it picks a process and kills it.
The choice is based on a score:
oom_score is proportional to the memory consumed, with adjustments: it penalizes large processes and those of ordinary users, and it protects root's processes and PID 1. oom_score_adj runs from −1000 (never kill it) to +1000 (kill it first) and is the part you can tune yourself:
# Protect the ingestor: losing readings is irreversible $ echo -900 | sudo tee /proc/1842/oom_score_adj # Sacrifice the backup first $ echo 800 | sudo tee /proc/3901/oom_score_adj
The trail it leaves in the log:
$ sudo dmesg -T | grep -A4 'Out of memory' [Sun Aug 31 04:12:33 2026] aggregator invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0 [Sun Aug 31 04:12:33 2026] Mem-Info: [Sun Aug 31 04:12:33 2026] active_anon:1842103 inactive_anon:204118 isolated_anon:0 [Sun Aug 31 04:12:33 2026] Tasks state (memory values in pages): [Sun Aug 31 04:12:33 2026] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name [Sun Aug 31 04:12:33 2026] [ 1877] 998 1877 1204832 1180221 9539584 0 0 aggregator [Sun Aug 31 04:12:33 2026] [ 1901] 998 1901 37080 33785 274432 0 0 meteo-api [Sun Aug 31 04:12:33 2026] Out of memory: Killed process 1877 (aggregator) total-vm:4819328kB, anon-rss:4720884kB, file-rss:0kB, shmem-rss:0kB, UID:998 pgtables:9316kB oom_score_adj:0
How to read it line by line, which is what you need to be able to do when you are on call:
aggregator invoked oom-killer: the one that triggered the OOM was theaggregatorwhen it asked for memory. That does not necessarily make it the culprit, although here it is.- The task table lists every process with its
rssin pages. Theaggregatorhas 1,180,221 pages × 4 KB = 4.5 GB.meteo-apihas 33,785 × 4 KB = 132 MB. Killed process 1877 (aggregator)withanon-rss:4720884kB: the one consuming the most anonymous memory was chosen, which is the usual criterion.anon-rssversusfile-rss: those 4.7 GB are anonymous, that is, dynamic memory with no file backing it. Killing the process frees them immediately. If it had beenfile-rss, killing it would have freed almost nothing.
That figure of 4.5 GB in an aggregator that under normal conditions uses 31 MB is the complete diagnosis: it is processing a whole day by loading everything into memory instead of block by block. The fix is a design fix — mmap or block-wise reading — not a configuration one.
Remember too what we saw in 02-01: when the OOM killer kills a process, that process dies from SIGKILL (signal 9), which your supervisor would detect with WIFSIGNALED(status) and WTERMSIG(status) == 9.
Measuring: VSZ versus RSS, /proc/meminfo, free -h, vmstat
We close with the tools and how to read them without going wrong.
$ ps -eo pid,vsz,rss,comm -u meteora
PID VSZ RSS COMMAND
1842 408212 18204 ingestor
1877 412308 31456 aggregator
1901 486300 148320 meteo-api| Metric | What it measures | When you use it | The trap |
|---|---|---|---|
| VSZ | Reserved address space | Almost never | Includes what was never touched; reserving is free |
| RSS | Pages actually in RAM | A quick estimate | Counts shared pages in every process |
| PSS | RSS with shared pages divided up | Adding up the consumption of several processes | Only in smaps |
| USS | Memory exclusive to the process | How much is freed by killing it | Only in smaps |
The problem with RSS, with a concrete example: if meteo-api has 4 workers and each one reports 148 MB of RSS, adding them gives 592 MB, but the real consumption may be 200 MB because they share code, libc and the /dev/shm cache. PSS solves this by dividing each shared page among those using it:
94 MB in reality against 148 MB of RSS. To account for memory on a server whose processes share a great deal, PSS is the correct metric.
/proc/meminfo gives the global picture:
$ grep -E 'MemTotal|MemAvailable|Cached|Dirty|Writeback|AnonPages|Mapped|Slab|SwapTotal|SwapFree' /proc/meminfo MemTotal: 8122448 kB MemAvailable: 4089216 kB Cached: 4198400 kB Dirty: 28160 kB Writeback: 0 kB AnonPages: 3021312 kB Mapped: 412160 kB Slab: 298432 kB SwapTotal: 4194300 kB SwapFree: 3670012 kB
The lines that really tell you something:
MemAvailable: 3.9 GB. The only one that answers "how much memory can I use?".AnonPages2.9 GB: the processes' anonymous memory. It can only go to swap.Cached4 GB: file pages. Disposable on the spot.Dirty28 MB: modified but not yet written to disk. If this figure climbs a lot, there is a write bottleneck. Forcing it to be flushed is whatfsync()does.Slab298 MB: the kernel's own data structures (inodes, dentries,task_struct). It is not process memory.
And vmstat to see the dynamics:
$ vmstat 2 3 procs -----------memory---------- ---swap-- -----io---- --system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 2 0 524288 421904 1024 4198400 0 0 142 288 4211 8877 12 4 83 1 0 1 0 524288 419872 1024 4199424 0 0 88 412 4402 9104 14 5 80 1 0 3 1 524288 418112 1024 4200448 0 16 204 1128 5108 10944 16 6 76 2 0
The critical columns for memory:
si/so(swap in / swap out, in KB/s): the most important metric. Aswpdof 512 MB withsi/soat zero is harmless: they are old pages evicted long ago that nobody is asking for. What is serious is sustainedsi/so: that really is thrashing.b: processes blocked in uninterruptible I/O (theDstate from 02-01).wa: percentage of CPU waiting on I/O.
The diagnostic rule that sums up the lesson: look at si/so, not at swpd. Having swap occupied is normal; having swap in constant motion means RAM is missing.
Common Mistakes and Tips
Panicking because free is low. The right column is available. A healthy system has little free memory and a lot of cache, because idle RAM is wasted RAM.
Confusing swpd with actually paging. The fact that there are 512 MB in swap says nothing on its own: they may have been sitting there for days with nobody asking for them. What signals a problem is sustained si/so in vmstat.
Disabling swap "so the system does not slow down". With no swap, the kernel loses its intermediate tool and under memory pressure it goes straight to the OOM killer. The sensible configuration is to have swap and lower swappiness, not to remove it.
Adding up the RSS of several processes. It gives an inflated total because shared pages are counted several times. To add them up, use PSS.
Believing that the OOM killer kills the culprit. It kills whoever has the highest score, which is usually the biggest one. If the aggregator causes the shortage and meteo-api is the one holding the most memory, meteo-api dies. Protect what is critical with a negative oom_score_adj.
Enabling THP everywhere. Transparent 2 MB pages help workloads with large working sets, but khugepaged compacting memory can introduce pauses of tens of milliseconds. Databases usually recommend madvise or never for exactly this reason.
Reading a SIGSEGV as a system bug. It is the MMU doing its job: the process accessed an address with no valid translation or without the right permissions. Compare the address against /proc/<pid>/maps to find out whether it was a corrupt pointer (it falls in a gap) or a write into a read-only area.
Diagnostic tip: when you suspect a memory problem, this is the order that works: free -h (look at available), vmstat 1 (look at si/so and wa), ps -eo pid,rss,maj_flt --sort=-rss | head (who is consuming and who is faulting), pmap -x <pid> (which region is growing) and dmesg -T | grep -i oom (whether somebody has already died). Five commands and you have the whole picture.
Exercises
Exercise 1: address translation and table sizes
A system has 32-bit logical addresses and 4 KB pages.
- How many bits do the page number and the offset take? How many pages are there at most?
- With this page table, translate the logical addresses
0x00003ABC,0x00001234and0x00006000:
| Page | Frame | Present |
|---|---|---|
| 0 | 0x0A | Yes |
| 1 | 0x1F | Yes |
| 2 | 0x03 | Yes |
| 3 | 0x2C | Yes |
| 4 | — | No |
- Calculate the size of a flat table with 4-byte entries. Compare it with the 64-bit case from section 4 of the lesson.
- With a 96 % TLB hit rate, a memory access of 80 ns, a TLB lookup of 2 ns and 2 table levels, calculate the effective access time.
Exercise 2: comparing replacement algorithms
The aggregator generates this page reference string:
- Calculate the page faults with 3 frames for: optimal, FIFO and LRU. Show the state of the frames at each step.
- Repeat with 4 frames.
- Which algorithm exhibits Belady's anomaly? Demonstrate it with your own numbers.
- If every major fault costs 8 ms, calculate the total time lost in each case with 3 frames.
Exercise 3: diagnosing a server with memory problems
meteo-01 is responding extremely slowly. You collect this data:
$ free -h
total used free shared buff/cache available
Mem: 7.8Gi 7.4Gi 102Mi 12Mi 298Mi 118Mi
Swap: 4.0Gi 3.8Gi 204Mi
$ vmstat 2 3
procs -----------memory---------- ---swap-- -----io---- --system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 31 3985408 104448 512 305152 42104 39882 51204 40118 9821 24102 3 8 1 88 0
1 29 3985408 102112 512 303104 44210 41004 53108 41220 10104 25811 2 9 1 88 0
0 33 3985408 101888 512 301056 43108 40112 52004 40988 9902 24998 3 8 1 88 0
$ ps -eo pid,rss,maj_flt,comm --sort=-rss | head -5
PID RSS MAJFL COMMAND
1877 4720884 892104 aggregator
1901 148320 41022 meteo-api
1842 18204 8104 ingestor- Diagnose what is wrong with the system. Name the phenomenon and justify it with at least four pieces of evidence.
- Why is CPU usage at 3 % if the system is crawling?
- Identify the root cause. How much memory should the
aggregatorbe using, given that it processes one day of readings? - Propose three solutions: an immediate one, a configuration one and a design one. State which is the correct one.
- Write the piece of code that would fix the problem at its root.
Solutions
Solution 1
1. Decomposing the address.
4 KB page = 2^12 bytes → offset = 12 bits Page number = 32 − 12 = 20 bits Maximum pages = 2^20 = 1,048,576
2. Translations.
0x00003ABC:
In binary: 0000 0000 0000 0000 0011 | 1010 1011 1100
p = 0x00003 = 3 | d = 0xABC = 2748
Arithmetically: 0x3ABC = 15036; 15036 / 4096 = 3; 15036 % 4096 = 2748
Table: page 3 → frame 0x2C = 44, present
physical = 44 × 4096 + 2748 = 180,224 + 2,748 = 182,972 = 0x0002CABCThe hexadecimal shortcut: the three right-hand digits (ABC) are preserved and the left-hand ones go from 00003 to 0002C.
0x00001234:
p = 0x00001 = 1, d = 0x234 = 564 Table: page 1 → frame 0x1F = 31 physical = 31 × 4096 + 564 = 126,976 + 564 = 127,540 = 0x0001F234
0x00006000:
p = 0x00006 = 6, d = 0 Table: page 6 does not exist (there are only entries 0-4) → PAGE FAULT on an invalid address → The kernel checks /proc/pid/maps: it belongs to no region → SIGSEGV: segmentation fault
It is important to tell this case apart from page 4, which does exist but has Present = No: there the fault would be recoverable (the kernel would bring it in from swap or from the file and retry the instruction), whereas here it is a genuine program error.
3. Size of the flat table in 32 bits.
Comparison with 64 bits:
| 32 bits | 64 bits (48 in use) | |
|---|---|---|
| Page bits | 20 | 36 |
| Entries | 1,048,576 | 68,719,476,736 |
| Bytes per entry | 4 | 8 |
| Flat table | 4 MB | 512 GB |
| With 180 processes | 720 MB | 92 TB |
And here is the key observation of the exercise: 4 MB per process is already too much. With 180 processes that is 720 MB of tables alone, almost 9 % of meteo-01's 8 GB, and the vast majority of those entries would be empty. That is why even 32-bit systems used multilevel tables (32-bit x86 had two levels). The flat table was never viable; in 64 bits it goes from unviable to plainly absurd.
4. Effective access time.
Hit (96 %): 2 ns (TLB) + 80 ns (data) = 82 ns
Miss (4 %): 2 ns + 2 × 80 ns (two levels) + 80 ns (data) = 242 ns
EAT = 0.96 × 82 + 0.04 × 242
= 78.72 + 9.68
= 88.4 nsDegradation against a pure access (80 ns): 88.4 / 80 = 1.105, that is 10.5 %.
It is worth noticing how much weight that 4 % of misses carries: it contributes 9.68 ns out of the 88.4 total, 11 % of the time, while being only 4 % of the accesses. It is the typical arithmetic of caches, and it explains why improving from 96 % to 99 % hits is worth the effort:
Solution 2
1. With 3 frames.
Optimal (evicts the page that will reappear last):
| Ref | 1 | 2 | 3 | 4 | 1 | 2 | 5 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| F1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 3 | 3 | 3 |
| F2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 4 | 4 | |
| F3 | 3 | 4 | 4 | 4 | 5 | 5 | 5 | 5 | 5 | 5 | ||
| Fault | ● | ● | ● | ● | ● | ● | ● |
The eviction decisions: at position 4, 3 leaves (it reappears at 10, later than 1 and 2); at 7, 4 leaves (it reappears at 11); at 10, 1 leaves (it never comes back); at 11, 2 leaves (it does not come back either).
7 faults.
FIFO:
| Ref | 1 | 2 | 3 | 4 | 1 | 2 | 5 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| F1 | 1 | 1 | 1 | 4 | 4 | 4 | 5 | 5 | 5 | 5 | 5 | 5 |
| F2 | 2 | 2 | 2 | 1 | 1 | 1 | 1 | 1 | 3 | 3 | 3 | |
| F3 | 3 | 3 | 3 | 2 | 2 | 2 | 2 | 2 | 4 | 4 | ||
| Fault | ● | ● | ● | ● | ● | ● | ● | ● | ● |
9 faults.
LRU:
| Ref | 1 | 2 | 3 | 4 | 1 | 2 | 5 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| F1 | 1 | 1 | 1 | 4 | 4 | 4 | 5 | 5 | 5 | 3 | 3 | 3 |
| F2 | 2 | 2 | 2 | 1 | 1 | 1 | 1 | 1 | 1 | 4 | 4 | |
| F3 | 3 | 3 | 3 | 2 | 2 | 2 | 2 | 2 | 2 | 5 | ||
| Fault | ● | ● | ● | ● | ● | ● | ● | ● | ● | ● |
10 faults.
2. With 4 frames.
Optimal:
| Ref | 1 | 2 | 3 | 4 | 1 | 2 | 5 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| F1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 4 | 4 |
| F2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | |
| F3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | ||
| F4 | 4 | 4 | 4 | 5 | 5 | 5 | 5 | 5 | 5 | |||
| Fault | ● | ● | ● | ● | ● | ● |
With 4 frames, 1, 2, 3 and 4 all fit from the start. When 5 arrives, 4 is evicted, because it reappears at position 11, later than 1, 2 and 3. At position 10 the 3 is already resident, so there is no fault. At 11 the 4 has to be brought back and 1 is evicted, since it is never used again.
6 faults.
FIFO:
| Ref | 1 | 2 | 3 | 4 | 1 | 2 | 5 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| F1 | 1 | 1 | 1 | 1 | 1 | 1 | 5 | 5 | 5 | 5 | 4 | 4 |
| F2 | 2 | 2 | 2 | 2 | 2 | 2 | 1 | 1 | 1 | 1 | 5 | |
| F3 | 3 | 3 | 3 | 3 | 3 | 3 | 2 | 2 | 2 | 2 | ||
| F4 | 4 | 4 | 4 | 4 | 4 | 4 | 3 | 3 | 3 | |||
| Fault | ● | ● | ● | ● | ● | ● | ● | ● | ● | ● |
10 faults.
LRU:
| Ref | 1 | 2 | 3 | 4 | 1 | 2 | 5 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| F1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 5 |
| F2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | |
| F3 | 3 | 3 | 3 | 3 | 5 | 5 | 5 | 5 | 4 | 4 | ||
| F4 | 4 | 4 | 4 | 4 | 4 | 4 | 3 | 3 | 3 | |||
| Fault | ● | ● | ● | ● | ● | ● | ● | ● |
A trace of the evictions: when 5 arrives (position 7), the order of use is 3, 4, 1, 2, so 3 leaves. At position 10 the 3 is needed and the least recently used is 4. At 11 the 4 is needed and 5 leaves. At 12 the 5 is needed and 1 leaves. Notice the pattern: the last three references all fault because LRU has just evicted exactly what is asked for next.
8 faults.
3. Belady's anomaly.
| Algorithm | 3 frames | 4 frames | Does more memory help? |
|---|---|---|---|
| Optimal | 7 | 6 | Yes (−1) |
| FIFO | 9 | 10 | NO: it gets worse (+1) |
| LRU | 10 | 8 | Yes (−2) |
FIFO exhibits the anomaly: going from 3 to 4 frames raises the faults from 9 to 10.
The structural explanation: FIFO does not satisfy the stack property. Formally, an algorithm satisfies it if the set of resident pages with n frames is always a subset of the set with n+1 frames. LRU and OPT satisfy it because their decision depends on the reference pattern, which does not change when frames are added. FIFO decides by arrival order, and that order is completely altered by changing the number of frames: with 4 frames, pages 1 and 2 survive longer and end up being evicted just before they are needed again.
It is an important result because it breaks an intuition that seemed safe, and it is the practical reason why no real system uses pure FIFO: you cannot promise that adding RAM will improve performance.
4. Time lost with 3 frames.
| Algorithm | Faults | Time lost | Against the optimum |
|---|---|---|---|
| Optimal | 7 | 7 × 8 ms = 56 ms | — |
| FIFO | 9 | 9 × 8 ms = 72 ms | +28.6 % |
| LRU | 10 | 10 × 8 ms = 80 ms | +42.9 % |
An honest observation about these numbers: here LRU comes out worse than FIFO, which contradicts the general intuition. It is an artefact of this particular string, designed precisely to exhibit Belady's anomaly. With real strings, which show strong temporal locality, LRU clearly beats FIFO. It is a good reminder that a twelve-reference string proves nothing about an algorithm's general behavior; for that you need real traces of millions of references.
What the comparison does prove is the order of magnitude of the problem: 12 memory accesses that should have cost 1.2 microseconds have cost between 56 and 80 milliseconds. A factor of 50,000. When frames are missing, the replacement algorithm is the least of your worries.
Solution 3
1. Diagnosis: thrashing.
The evidence that confirms it, one item at a time:
| Evidence | Value | What it means |
|---|---|---|
available |
118 Mi out of 7.8 Gi | Memory practically exhausted |
| Swap used | 3.8 Gi out of 4.0 Gi | Swap is full as well |
si/so |
~42,000 / ~40,000 KB/s | 40 MB/s in both directions at once |
wa |
88 % | The CPU does nothing but wait for the disk |
b |
29-33 processes | Almost the whole system in state D |
The aggregator's MAJFL |
892,104 | Nearly a million major faults |
The decisive figure is si and so both high at the same time. If there were only so, the system would be freeing memory in an orderly way. Traffic in and out simultaneously at 40 MB/s means that the same pages are being evicted and fetched back without pause: the working set does not fit in RAM and every evicted page is needed immediately afterwards. It is the exact definition of the feedback loop in the lesson's diagram.
2. Why the CPU is at 3 %.
Because there is nothing to run. Almost every process is in state D, blocked waiting for the disk to bring in a page. The r column (runnable) reads 0 or 1, while b (blocked) reads 31.
This is the most deceptive pattern of all: an almost idle CPU with the system completely stalled. Anyone looking only at CPU usage will conclude that the server is fine and go looking for the problem somewhere else. The combination to recognize instantly is low us+sy + sky-high wa + high b.
A calculation that puts a size on it: 892,104 major faults in the aggregator, at about 100 µs each with an SSD, come to 89 seconds of pure waiting. With a mechanical disk at 8 ms it would be ~2 hours of disk time.
3. Root cause.
The aggregator has 4,720,884 KB = 4.5 GB of RSS on a 7.8 GB machine. On its own it is 60 % of the total RAM. Neither meteo-api (148 MB) nor ingestor (18 MB) matters by comparison.
How much it should be using:
One day of readings: 17 MB (from the course glossary) Aggregation structures: Stations × hours × 5 fields ≈ 50 × 24 × 5 × 8 bytes = 48 KB Working buffers, libc, code: ~15 MB Reasonable consumption: 30-50 MB Actual consumption: 4,500 MB Excess factor: ~100×
And at 24 bytes per reading, those 4.5 GB amount to some 196 million readings: more than 270 days of data. The diagnosis is immediate: the aggregator is loading the entire history into memory instead of the one day it needs to process, most likely because of a readdir over /var/lib/meteora/readings/ with no date filter, accumulating everything into an in-memory structure.
4. Three solutions.
Immediate (get the server back now):
It frees 4.5 GB instantly. The system stops paging within seconds. It is a plaster, not a cure: it will happen again on the next run.
Configuration (contain the damage):
# Limit the service's memory with systemd $ sudo systemctl edit meteora-aggregator [Service] MemoryMax=512M MemoryHigh=384M # Protect the critical processes from the OOM killer $ echo -900 | sudo tee /proc/1842/oom_score_adj # ingestor $ echo -500 | sudo tee /proc/1901/oom_score_adj # meteo-api # Reduce the tendency to swap $ sudo sysctl -w vm.swappiness=10
With MemoryMax=512M (a cgroup limit, the subject of 06-02), an aggregator that tries to go past 512 MB dies on its own without dragging the rest of the system down. This turns a system-wide outage into an isolated failure, which is exactly what you want. It does not fix the bug, but it contains it.
Design (the correct one):
Process as a stream, not by loading everything. Two valid variants: read in blocks with read(), or map the day's file with mmap() and let demand paging manage the memory.
5. The code that fixes it.
The mmap() version, which applies directly what the lesson covered:
/* aggregate_day.c — processes ONE day without loading it all into its own memory */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdint.h>
struct Reading {
uint32_t station_id;
uint32_t timestamp;
float temperature;
float humidity;
float pressure;
uint32_t _reserved;
};
#define MAX_STATIONS 64
#define HOURS_DAY 24
struct Accumulator {
double sum_temp;
double sum_hum;
double sum_pres;
uint32_t n;
};
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "usage: %s YYYY-MM-DD\n", argv[0]);
return 1;
}
char path[256];
snprintf(path, sizeof path,
"/var/lib/meteora/readings/%s.dat", argv[1]);
int fd = open(path, O_RDONLY);
if (fd == -1) { perror("open"); return 1; }
struct stat st;
if (fstat(fd, &st) == -1) { perror("fstat"); close(fd); return 1; }
struct Reading *readings = mmap(NULL, st.st_size,
PROT_READ, MAP_PRIVATE, fd, 0);
if (readings == MAP_FAILED) { perror("mmap"); close(fd); return 1; }
close(fd);
/* Hint to the kernel: we will walk it in order and will not re-read it */
madvise(readings, st.st_size, MADV_SEQUENTIAL);
/* Accumulators: 64 × 24 × 32 bytes = 48 KB, FIXED size */
static struct Accumulator acc[MAX_STATIONS][HOURS_DAY];
memset(acc, 0, sizeof acc);
size_t n = st.st_size / sizeof(struct Reading);
for (size_t i = 0; i < n; i++) {
uint32_t station = readings[i].station_id % MAX_STATIONS;
uint32_t hour = (readings[i].timestamp / 3600) % HOURS_DAY;
struct Accumulator *a = &acc[station][hour];
a->sum_temp += readings[i].temperature;
a->sum_hum += readings[i].humidity;
a->sum_pres += readings[i].pressure;
a->n++;
}
munmap(readings, st.st_size);
for (int s = 0; s < MAX_STATIONS; s++)
for (int h = 0; h < HOURS_DAY; h++)
if (acc[s][h].n > 0)
printf("%s %02d:00 station=%d n=%u T=%.2f H=%.1f P=%.1f\n",
argv[1], h, s, acc[s][h].n,
acc[s][h].sum_temp / acc[s][h].n,
acc[s][h].sum_hum / acc[s][h].n,
acc[s][h].sum_pres / acc[s][h].n);
return 0;
}Why this fixes the problem at its root:
- One day per run. The path is built from the date it is given: loading the whole history by accident is impossible.
- The accumulators have a fixed size: 64 × 24 × 32 bytes = 48 KB, independent of the volume of data. This is the heart of the fix: the process's memory no longer grows with the input.
mmapconsumes no RSS of its own. The mapped pages belong to the kernel's page cache, which is disposable under memory pressure (they are clean and file-backed, as we saw in 02-03). If RAM runs short, the kernel discards them without writing anything and re-reads them later. Compared with the 4.5 GB of anonymous memory in the previous version — which could only go to swap — this changes the system's behavior under pressure completely.madvise(MADV_SEQUENTIAL)tells the kernel that access will be sequential. The kernel turns on aggressive read-ahead and discards the pages already walked sooner. It is a small optimization to write and a very effective one on full traversals.
The resulting consumption:
Process RSS: ~15 MB (code + libc + accumulators) Page cache: up to 17 MB, freeable instantly Effective total: ~32 MB against 4,500 MB
A reduction by a factor of 140, and not a single anonymous page that could drag the system into thrashing.
Conclusion
Paging divides the logical space into pages and physical memory into frames of the same size, and translates every address by separating the page number from the offset — which is never translated. Each table entry carries the frame plus a set of control bits that govern everything: P enables virtual memory, R/W and NX implement W^X, and A and D, written by the hardware, are the only clue the kernel has for deciding who to evict and at what cost.
A flat table in 64 bits would take 512 GB per process, so tables are multilevel and sparse: four levels of 9 bits, each table occupying exactly one page, and 60 KB in practice instead of 512 GB. The price is four memory accesses per translation, and that is why the TLB exists: at a 99 % hit rate you lose 4 % of your performance, at 70 % you lose more than half. Huge pages cut 4,150 TLB entries down to 9 for the aggregator's 17 MB, at the cost of internal fragmentation and a coarse eviction granularity.
Demand paging means nothing is loaded until it is touched, which explains libc with only 44 % of its code in RAM. A minor fault costs microseconds and is normal; a major fault costs milliseconds, and with one per thousand accesses the system runs 81 times slower. The replacement algorithms — optimal as a bound, FIFO with its Belady's anomaly, LRU unworkable in its exact form, and the clock as a cheap approximation using the A bit — move within a margin of 33 %, far less than having enough RAM matters. When the working set does not fit, thrashing appears: CPU at 3 %, wa at 88 % and the system at a standstill.
And all of this can be touched with your own hands: mmap() turns 4,219 system calls into one and lets two processes physically share the same 2026-08-31.dat; the copy-on-write behind fork() is simply R/W=0 plus a protection fault; swappiness decides what gets sacrificed first; and the OOM killer leaves a trail in dmesg that you read process by process. The rule that sums up the operational side: look at available and not at free, look at si/so and not at swpd, and add up PSS and not RSS.
With this we close memory. What remains is the other great resource the operating system administers, and the one that has appeared in every major fault in this lesson: storage. How much that disk access we have been counting in milliseconds really costs, why an SSD changes all the rules, how requests are ordered to minimize head movement and what RAID configuration /var/lib/meteora deserves. That is what we will see in Storage Management.
Operating Systems Fundamentals
Module 1: Introduction to Operating Systems
- Basic Concepts of Operating Systems
- History and Evolution of Operating Systems
- Types of Operating Systems
- Main Functions of an Operating System
- Kernel Architecture: Monolithic, Microkernel and Hybrid
- User Mode, Kernel Mode and System Calls
Module 2: Resource Management
- Process Management
- CPU Scheduling
- Memory Management
- Virtual Memory and Paging
- Storage Management
- Device Management
- Drivers, Interrupts and I/O Operations
Module 3: Concurrency
- Concurrency Concepts
- Threads and Processes
- Inter-Process Communication (IPC)
- Synchronization and Mutual Exclusion
- Classic Concurrency Problems
- Deadlocks: Prevention, Detection and Recovery
Module 4: File Structures
- File Systems
- Directory Structures
- Partitions, Mounting and the Virtual File System
- File Management
- Space Allocation, Journaling and Integrity
- File Security and Permissions
Module 5: System Protection and Security
- Protection Principles and Access Control
- Users, Authentication and Privilege Escalation
- Common Threats and System Hardening
- Auditing, Logging and Incident Response
Module 6: Virtualization and Containers
- Virtualization: Hypervisors and Virtual Machines
- Containers: Namespaces and cgroups
- The Operating System in the Cloud
- Mobile and Real-Time Operating Systems
