[MIT 6.s081] Xv6 Lab 9: Locks Record
The content below was generated entirely by machine translation. Please verify its accuracy. If anything is unclear, consult the Chinese source version.
Update on 2022/8/18: the second exercise in this article is not completely correct, and many other approaches exist. See my discussion with the author of this blog and the author’s new code based on that discussion.
If I have time later, I will revise the second part and add comments.
Update on 2022/9/14: I recently put the lab code on GitHub. If you need a reference, you can find it here:
https://github.com/ttzytt/xv6-riscv
The different branches contain the different labs.
Lab 9: locks
Memory allocator
Lab description
The lab description is again very long, so I will not reproduce a screenshot. Here is the general problem.
The original kalloc() uses one large lock and maintains a single freelist. Every program that allocates or frees memory must compete for that lock before modifying the list. The implementations of freelist, kfree(), and kalloc() are:
1 | struct run { |
Multiple cores cannot call kalloc() or kfree() concurrently, greatly reducing memory-allocation performance.
Testing confirms that this global lock is a major bottleneck. Among all locks, kmem has the most waits and the most severe contention:
1 | $ kalloctest |
The lab asks us to solve this problem. Its hint suggests assigning one freelist to each processor core. A core can then allocate a page without waiting on the globally contended lock. It still uses a lock, but contention is dramatically reduced.
Approach
This creates another problem. Some cores may have many free page frames while another has none. Even if the machine has enough free frames overall, the empty core cannot allocate one locally.
When a core has no available frames, it therefore needs to “steal” some from other cores.
The approximate pseudocode is:
1 | struct { |
This looks reasonable and can even pass the tests, but it can deadlock, although the deadlock is extremely unlikely.
Notice the for (i : kmems) loop. While inside it, the core holds or attempts to acquire two locks: its own kmems[cpu].lock, and the i.lock of the core from which it wants to steal.
Suppose the processor has only two cores, a and b, and both have exhausted their free frames. Each first acquires its own lock and then attempts to steal from the other.
During stealing, each tries to acquire the other core’s lock. But a and b already hold their respective local locks, so both wait forever: a deadlock.
The same pattern can occur with more than two cores; two cores merely make the explanation easier.
To prevent it, a core must not simultaneously hold its own lock and another core’s lock.
That introduces a further issue. While one core is stealing pages and adding them to its local freelist, another may try to steal from it. Concurrent modification of the same list would corrupt it.
My solution works as follows.
When a core discovers that its list is empty, immediately release the local lock and begin stealing. Two locks would ordinarily be needed because several cores might modify a freelist at once. Instead, do not modify the local freelist while stealing. Remove available pages from other cores and record them in a candidate array. After reacquiring the local lock, scan that array and insert the stolen pages into the local list.
Because stolen pages are only recorded in the candidate array and are not yet in the local freelist, another core attempting to steal from this core still sees an empty list and does not modify it. No core changes the local list during stealing, so its lock is unnecessary during that interval.
One more issue is interrupts. During stealing, the core may hold no locks, allowing xv6 to enable interrupts. It could leave to run another process, which might call kalloc() again and begin a duplicate steal operation.
This leads to the following implementation.
Code
kinit():
1 | struct { |
kfree():
1 | void |
Whichever core is running the current process receives the released page in its own freelist. This is a simple allocation policy; better ones may exist, but I was lazy.
steal():
This newly added function scans every core’s freelist and places available pages into the current core’s candidate array, st_ret[STEAL_CNT]:
1 | int steal(uint cpu){ // Return the number of pages stolen |
kalloc():
When no local frame remains, call steal() and then truly add the stolen frames to freelist. Interrupts remain disabled for the entire kalloc() because enabling them could let two processes on one core execute steal() and steal the same pages twice.
1 | void * |
Buffer cache
First, the approach in this section substantially refers to—almost copies—this expert’s blog post.
Lab description
xv6 cannot directly access the disk device. To read disk data, it first copies the data into a cache and then reads the cache.
The smallest unit of disk data in xv6 is one block, whose size is 1024 bytes. In other words, every disk read obtains at least 1024 bytes.
Disk reads and writes call bread() to obtain the appropriate cache buffer, which already contains the data from the corresponding block:
1 | // This file is bio.c |
Notice that it first calls bget(). bget() checks whether the disk block is already cached. If it is, it returns the existing buffer. Otherwise, it locates the least recently used buffer and assigns that buffer to the current block:
1 | // Look through buffer cache for block on device dev. |
All buffers are linked into one doubly linked list. The first element is the most recently used and the final element is the least recently used.
Every call to bget() first traverses the list to check whether the current block is cached. If not, it traverses backward from the end, beginning with the least recently used entries, and selects the first buffer whose reference count is zero, meaning no program is using it.
Every cache allocation therefore competes for the lock protecting this list.
The per-core technique from the preceding exercise might seem applicable, but assigning buffers to individual cores does not work well. Allocating or releasing a page frame involves only one core, and an allocated frame is then accessed by a single process.
A buffer cache entry, however, may be accessed by different processes. Several processes can read and write the same cached disk block. If buffers were preassigned by core, a process would frequently need a buffer owned by another core and would have to scan other cores’ caches one by one, reducing performance. Giving every individual buffer its own lock might reduce granularity, but that is a different design.
The lab hint proposes a hash table. It maps block numbers to buckets containing cache buffers. Contention occurs only when two processes operate on buffers in the same bucket. A lookup also traverses only the relevant bucket instead of all cached blocks.
When the corresponding bucket lacks a free buffer, it can steal one from another bucket as kalloc() did.
Approach
The hash table itself is straightforward. However, stealing introduces the same dilemma as page allocation.
During a steal, the code needs the current bucket lock and must also inspect other buckets, requiring their locks. Holding two locks at once can deadlock as follows:[1]
1 | Assume block b1 hashes to 2 and block b2 hashes to 5, |
One solution is to release the current bucket lock before searching for an unused buffer elsewhere.
This creates a new race. Suppose a process releases its bucket lock and begins searching other buckets for a free buffer. Another process then calls bget() for the same blockno and also begins searching.
After both find free buffers, they may each insert one into the bucket for that block number, leaving two cache entries for the same disk block.
The insertion must therefore be locked, and after acquiring the lock the code must search again for an existing buffer. Another process may have called bget() for the same block concurrently.
Besides locking, we need to identify the least recently used buffer. An LRU buffer is unlikely to be used again soon and is normally recycled when cache space is scarce.
The original design maintained one doubly linked list. A newly released buffer moved to the front, making the tail least recently used.
The new design has several lists, one per bucket, and cannot compare their positions directly. Add lst_use to struct buf to record the last-use time. This time comes from the global ticks variable maintained by timer interrupts:
1 | //trap.c |
Code
binit():
1 |
|
bget():
This is the primary function being changed.
1 | // Look through buffer cache for block on device dev. |
bfind_prelru():
This important helper accepts a pointer to lru_bkt and returns the address of the buffer immediately preceding the least recently used buffer whose reference count is zero. It must continue holding the lock for the bucket containing lru. Otherwise, after releasing that lock and before inserting the buffer into the current bucket, another process could modify the selected lru buffer.
lru_bkt is passed by pointer so the function can assign the bucket number, allowing its caller to know which lock to release.
1 | struct buf* bfind_prelru(int* lru_bkt){ // Return the element before lru while retaining its lock |
brelse():
1 | // Release a locked buffer. |
bpin and bunpin:
1 | void |
- 1.https://blog.miigon.net/posts/s081-lab8-locks/ ↩




![[Stanford CS144] Lab 4 Record](/img/CS144/tcp%E7%8A%B6%E6%80%81%E6%B5%81%E8%BD%AC%E5%9B%BE.jpg)