CS110: Principles of Computer Systems

Spring 2021
Instructors Roz Cyrus and Jerry Cain

PDF

Virtual Memory

  • Support for virtual memory is provided through a scheme very similar to that for file systems, though the unit of storage is called a page instead of a block, and it is typically 4096 bytes (or some other power of two, but we'll assume 2^12 = 4096 here).
  • Virtual memory segments are actually contiguous sequences of virtual pages.  The OS maps each virtual page to a physical one.  The physical pages themselves can be drawn from any portion of physical memory.  Since all pages are the same size, any free page can be allocated to back a new virtual page of memory.
  • The OS stores a page table of virtual-to-physical page mappings in the kernel portion of a process's virtual address space.
  • The OS takes measures to ensure the virtual addresses used by the page table itself map to well-known regions of physical memory.

Virtual Memory

  • Without going into the weeds on implementation details, the page table maps virtual page addresses to physical page addresses.  Each mapping is stored in a page table entry.
  • Because all pages—virtual and physical—are aligned on 4KB boundaries, the base addresses of every single page ends in 0x000.  Similarly, the offset of a byte within a virtual page is backed by a byte at the same offset within the physical page.
  • If the OS maps the virtual page with base address of, say, 0x7FFFFFFF80234000 to, say, 0x835432000, the table entry would store a 52-bit 7FFFFFFF80234 as the key and the 24-bit 835432 as the value.  There's no need to store the 3 trailing zeroes, because they're implied by the 4K-page alignment constraints.

Virtual Memory

  • The page table stored on behalf of a single process might look like this:

7FFFFFFF80234 maps to 835432
0000DDEE65111 maps to 45D834
many additional entries
0000A9CF9AAAF maps to 12387B

  • The address translation is managed by the MMU on the CPU, and the translation itself can be computed via bitwise operations like <<, &, |, and >>.


0x7FFFFFFF80234230 & 0xFFF = 0x230

0x230 | (0x835432 << 12) = 0x835432230

  • The MMU is optimized to do these computations blazingly fast.

Virtual Memory

  • Many, including the authors of your primary textbook/reader, view the hard drive as the physical memory and main memory as a cache storing those pages from disk currently being accessed.  This viewpoint has many advantages!

    • Executables (e.g. ls, pipeline-test, etc) are stored on disk and loaded into main memory when a process is created to run that executable.

    • As a general rule, every single virtual page of memory uniquely maps to a physical page of memory.  However, Linux often ignores that rule for code and rodata segments, since code and global constants are read only.

      • In particular, multiple processes running emacs—there were 17 such processes running on myth63 at the time I constructed this slide—all map their code segment to the same pages in physical memory.

    • As main memory becomes saturated, the OS will evict pages that have become inactive and flush them to the hard drive, often in a special file called a swap file.

      • For instance, at the time of this writing, myth53 had one process running vi that had been idle for 5 days.

      • The VM page mappings have almost certainly been discarded and will only be remapped when the user interacts with that vi session again.  The state of memory needed to eventually continue would have, at some point, been written to disk.

Introduction to Threads

  • A thread is an independent execution sequence within a single process.
    • Operating systems and programming languages generally allow processes to run two or more functions simultaneously via threading.
    • The process's stack segment is essentially subdivided into multiple miniature stacks, one for each thread.
    • The thread manager time slices and switches between threads in much the same way that the OS scheduler switches between processes. In fact, threads are often called lightweight processes.
    • Each thread maintains its own stack, but shares the same text, data, and heap segments with other threads within the same process.
      • Pro: it's easier to support communication between threads, because they run in the same virtual address space.
      • Con: there's no memory protection, since the virtual address space is shared. Race conditions and deadlock threats need to be mitigated, and debugging can be difficult. Many bugs are hard to reproduce, since thread scheduling isn't predictable.
      • Pro and con: Multiple threads can access the same global variables.
      • Pro and con: Each thread can share its stack space (via pointers) with its peer threads.

Introduction to Threads

  • Threading and Introverts
    • C++ provides support for threading and many synchronization directives.
    • Presented below is a short C++ introverts example we've already seen once before. The full program is online right here.
    • Once initialized, each of the six recharge threads is eligible for processor time.
    • These six threads compete for CPU time in much the same way that processes do, and we have very little control over what choices the scheduler makes when deciding which thread to run next.
    • Hot take: processes are are actually implemented as threads.
static void recharge() {
  cout << oslock << "I recharge by spending time alone." << endl << osunlock; 
}
    
static const size_t kNumIntroverts = 6;
int main(int argc, char *argv[]) {
  cout << "Let's hear from " << kNumIntroverts << " introverts." << endl      
  thread introverts[kNumIntroverts]; // declare array of empty thread handles
  for (thread& introvert: introverts)
     introvert = thread(recharge);    // move anonymous threads into empty handles
  for (thread& introvert: introverts)
     introvert.join();    
  cout << "Everyone's recharged!" << endl;
  return 0;
}

Introduction to Threads

  • The primary data type is the thread, which is a C++ class used to manage the execution of a function within its own thread of execution.
  • We install the recharge function into temporary threads objects that are then moved (via thread::operator=(thread&& other)) into a previously empty thread object.
    • This is a relatively new form of operator= that fully transplants the contents of the thread on the right into the thread on the left, leaving the thread on the right fully gutted, as if it were zero-arg constructed. Restated, the left and right thread objects are effectively swapped.
  • The join method is to threads what waitpid is to processes.
  • Because main calls join against all threads, it blocks until all child threads all exit.
  • The prototype of the thread routine—in this case, recharge—can be anything (although the return type is always ignored, so it should generally be void).
  • operator<<, unlike printf, isn't thread-safe.
    • I've constructed custom stream manipulators called oslock and osunlock that can be used to acquire and release exclusive access to an ostream.
    • These manipulators—which we get by #include-ing "ostreamlock.h"—whelp ensure only one thread gets permission to insert into a stream at any one time.

Introduction to Threads

  • Thread routines can accept any number of arguments using variable argument lists. (Variable argument lists—the C++ equivalent of the ellipsis in C—are supported via a recently added feature called variadic templates.)
  • Here's a slightly more involved example, where greet threads are configured to say hello a variable number of times.
static void greet(size_t id) {
  for (size_t i = 0; i < id; i++) {
    cout << oslock << "Greeter #" << id << " says 'Hello!'" << endl << osunlock;
    struct timespec ts = {
      0, random() % 1000000000
    };
    nanosleep(&ts, NULL);
  }
  cout << oslock << "Greeter #" << id << " has issued all of his hellos, " 
       << "so he goes home!" << endl << osunlock;
}

static const size_t kNumGreeters = 6;
int main(int argc, char *argv[]) {
  cout << "Welcome to Greetland!" << endl;
  thread greeters[kNumGreeters];
  for (size_t i = 0; i < kNumGreeters; i++) greeters[i] = thread(greet, i + 1);
  for (thread& greeter: greeters) greeter.join();
  cout << "Everyone's all greeted out!" << endl;
  return 0;
}

Lecture 09: Virtual Memory and Processes

By Jerry Cain

Lecture 09: Virtual Memory and Processes

  • 1,484