Clock Replacement
Give every page a second chance, so the pages in use stay and the ones nobody needs leave
Choose the page that leaves a full pool with the clock: a reference bit on every frame, set by each hit, and a hand that sweeps the frames clearing bits until it finds an unpinned page whose bit is already 0. Show the hand, the bits and the pins with pager clock.
- The replacement policy decides which page leaves, and it decides how many misses a workload costs
- Evicting in the order pages came in throws out the pages used most, sooner or later
- A reference bit records that a page was used since the hand last passed
- A second chance: the hand clears a set bit instead of evicting, and evicts only a page that has not been used since
- Pins still win: the hand passes over a pinned page without touching its bit
Why this stage exists
Until now, which page leaves a full pool has been up to you. Most first versions pick the page that came in first, or the next frame in turn. That is simple, it is correct, and it is bad at the one thing a cache is for.
Think of what a query does to a B+Tree. Every lookup starts at the root. With 16 frames and a table of a few hundred leaves, the root is the single most used page in the database. First in, first out does not know that. The root came in early, so it is among the first to be thrown out, and the next lookup reads it straight back from the file. The pages a workload uses most are exactly the ones that have been in the pool longest, and an order-of-arrival policy evicts them for that reason.
What you want is the page that has gone longest without being used. Tracking that exactly means reordering a list on every single hit, which is a lot of work for the most common event in the pool. The clock gets most of the benefit for almost none of the cost: one bit per frame, set on a hit, and a hand that only moves when something has to leave.
From this stage the choice is no longer yours. The contract fixes it exactly, so the counters are exact with any pool, and the tests can watch the hand move.
What changes: a bit per frame and a hand
Picture the frames in a circle with a hand pointing at one of them. Each frame has a reference bit. A hit sets the bit of its frame to 1, and that is all a hit costs.
When a page must come in and there is an empty frame, it goes to the lowest-numbered one, its bit set to 1, and the hand stays where it is. When there is no empty frame, the hand sweeps:
- a pinned page is passed over, its bit left alone;
- a bit of 1 is set to 0, and the hand moves on: the page gets a second chance;
- a bit of 0 on an unpinned page: that is the victim.
The new page takes the victim's frame with its bit set to 1, and the hand moves one past it. A page that was used since the hand last went by survives one more revolution. A page nobody touched in a whole revolution leaves.
If every frame is pinned the sweep can never stop, so it does not start: the request is 01201 and the hand stays where it was.
A full example
The worked sequence of the contract, on a file of five pages with --cache-pages 3. Opening reads page 0 into frame 0 with its bit at 1, before the first command:
| Command | What happens | Frame 0 | Frame 1 | Frame 2 | Hand |
|---|---|---|---|---|---|
| open | page 0 read, lowest empty frame | page 0, bit 1 | empty | empty | 0 |
pager get 1 | miss, lowest empty frame | page 0, bit 1 | page 1, bit 1 | empty | 0 |
pager get 2 | miss, lowest empty frame | page 0, bit 1 | page 1, bit 1 | page 2, bit 1 | 0 |
pager get 3 | miss, no empty frame: the hand clears frames 0, 1 and 2, comes back to frame 0, finds bit 0 and evicts page 0 | page 3, bit 1 | page 1, bit 0 | page 2, bit 0 | 1 |
pager get 1 | hit: set the bit | page 3, bit 1 | page 1, bit 1 | page 2, bit 0 | 1 |
pager get 4 | miss: frame 1 is cleared, frame 2 has bit 0, page 2 leaves | page 3, bit 1 | page 1, bit 0 | page 4, bit 1 | 0 |
Page 2 leaves, not page 1, because page 1 was used again after the hand last went by. Leave out that pager get 1 and page 1 is the one that goes. Evicting in the order pages came in would have thrown out page 1 either way.
The first sweep goes all the way round. When every bit is 1, the hand clears all of them and comes back to where it started. That is expected: all three pages had been used since the last sweep, which was never.
Least recently used. LRU evicts the page whose last use is oldest, exactly. It needs every hit to move its page to the front of a list, and on a busy pool that list becomes the bottleneck. The clock approximates it with one bit. PostgreSQL uses a clock with a small usage count in place of the bit; MySQL's InnoDB uses an LRU list split in two, so that one big scan cannot flush the hot pages out; LRU-K and 2Q are other answers to the same problem.
The algorithm in words
Keep the bits and the hand
The state of the clock is small: one bit per frame, and one frame number.
- 1.The frames are numbered from 0. Each has a reference bit. The hand points at one frame, frame 0 when the pool is new and empty.
- 2.A hit sets the bit of the page's frame to 1. Nothing else happens on a hit: the hand does not move.
- 3.A page coming in while some frame is empty goes to the lowest-numbered empty frame with its bit set to 1. The hand does not move.
- 4.Opening a file reads page 0 into frame 0, so a session on a file with pages starts with page 0 in frame 0, its bit at 1, and the hand at 0.
Sweep for a victim
Only when no frame is empty does the hand move.
- 1.Look at the frame under the hand. If its page is pinned, pass over it and leave its bit as it is.
- 2.If its bit is 1, set it to 0 and move the hand to the next frame. After the last frame comes frame 0.
- 3.If its bit is 0 and its page is unpinned, that page is the victim. Write it first if it is dirty, as before.
- 4.Once the victim is chosen, the hand moves one frame past it. The new page then takes the victim's frame with its bit set to 1.
- 5.If every page in the pool is pinned there is no victim: 01201, and the hand stays where it was.
A read that fails
A page that fails its checksum makes room like any other.
- 1.The sweep runs and the victim leaves before the read, so the victim stays evicted and the hand stays moved.
- 2.The page that failed does not come in. Its frame is left empty, and the next page to come in takes the lowest empty frame.
Output contract for this stage
The full definition is on the output contract page, section 25.
| Input | Output |
|---|---|
pager clock | [PAGER] Clock: hand=<i> frames=<n>, then for each frame in order [PAGER] Frame <i>: page=<p> ref=<0 or 1> pin=<k> or [PAGER] Frame <i>: empty |
pager clock requests nothing. pager frames does not change: it lists pages, in page order; pager clock lists frames, in frame order. From this stage the counters after the pager commands of section 23 are exact with any pool.
What you should see when done
The worked sequence, on the five-page file of the earlier stages:
$droid --db notes.db --cache-pages 3Welcome to droid!>pager get 1[PAGER] Get page 1: CACHE_MISS (loaded from disk)>pager get 2[PAGER] Get page 2: CACHE_MISS (loaded from disk)>pager get 3[PAGER] Get page 3: CACHE_MISS (loaded from disk)>pager clock[PAGER] Clock: hand=1 frames=3[PAGER] Frame 0: page=3 ref=1 pin=0[PAGER] Frame 1: page=1 ref=0 pin=0[PAGER] Frame 2: page=2 ref=0 pin=0>pager get 1[PAGER] Get page 1: CACHE_HIT>pager get 4[PAGER] Get page 4: CACHE_MISS (loaded from disk)>pager clock[PAGER] Clock: hand=0 frames=3[PAGER] Frame 0: page=3 ref=1 pin=0[PAGER] Frame 1: page=1 ref=0 pin=0[PAGER] Frame 2: page=4 ref=1 pin=0>.exitexiting.. good bye!
Things to notice. After page 3 came in, the hand was at 1, not 0: it moves one past the frame it filled. The hit on page 1 set its bit back to 1, which is what saved it: the sweep for page 4 cleared it instead of evicting it, and moved on to page 2.
Common bugs in this stage
- Moving the hand when a frame is empty. Filling an empty frame does not touch the hand. Only a sweep moves it.
- Leaving the hand on the victim's frame. After the new page goes in, the hand moves one past it. Otherwise the next sweep starts by clearing the page that just arrived.
- Clearing a pinned page's bit. The hand passes over a pinned page and leaves its bit alone.
- Sweeping forever. With every page pinned there is no victim. Check for that before sweeping, or stop after passing every frame once with nothing found, and restore nothing you changed: the hand must not move.
- Setting the bit to 0 for a new page. A page that just came in was just used: its bit is 1.
- Forgetting the empty frame after a bad page. When the page read for a frame fails its checksum, the victim is already gone and the frame stays empty. The next page goes there, without a sweep.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: the bit can be a field of the frame struct next to its pin count and dirty flag. The hand is an unsigned index into the frames; moving past the last frame brings it back to the first.
- C++: the same.
std::vector<bool>saves space, but a plainboolin the frame struct is simpler to read and debug. - Rust: keep the hand as
usizeand the frames in aVec; the sweep is a loop with an index, not an iterator, because it wraps round and stops in the middle. - Zig: the same shape, with the hand as a
usizethat wraps atframes.len. A labelledwhileloop that breaks out with the victim keeps the sweep readable.
Checklist
One line per thing the tests check. The tags name the test cases.
- The worked sequence of the contract, frame by frameclock-worked-sequence
- Without the second request, page 1 is the one that leavesclock-second-chance
- A page asked for between every two misses is never evictedclock-hot-page-stays
- The hand passes over pinned pages, and with every page pinned it does not moveclock-skips-pinned
- A dirty victim is written before its frame is reusedclock-dirty-victim
- A page that fails its checksum leaves its frame empty, and the next page takes itclock-bad-page-leaves-frame-empty
- pager clock on a new file shows every frame empty and the hand at 0clock-empty-pool