← Root to End Companion to CSE321 Theory Assignment 01 · traced through Linux, glibc and libuv source

One core, two ways to wait: threads and the event loop

Our report compares multi-threading with event-driven concurrency at the level of ideas. This page goes one layer down. Both models are built from the same kernel primitive, a task that marks itself asleep and calls schedule(), and they differ only in how many tasks sleep and on what. Everything below is real code from the Linux kernel, glibc and libuv, plus two simulators we can drive.

Read the original: our submitted report (PDF, 10 pages).

Kernel, glibc and libuv excerpts were fetched from their upstream repositories in October 2026 and trimmed with // …. The two small servers in the second section are our own example programs. Highlighted lines are the ones each explanation hinges on.

Frame 0 · the root

Both models end in the same place: a sleeping task and schedule()

From the report, Section 4.1In synchronous I/O the kernel suspends the thread until data is available; in asynchronous I/O the kernel notifies the application later.

Here is what "suspends the thread" means in the kernel. On Linux every thread is a task_struct. To wait, a task puts itself on a wait queue, sets its state to sleeping, and calls the scheduler, which picks some other task to run. When the awaited thing happens, whoever caused it walks the wait queue and wakes the sleepers.

The thread-per-connection server blocks in read(), which for TCP reaches sk_wait_data and waits on that one socket's queue. The event-loop server blocks in epoll_wait(), which reaches ep_poll and waits on one queue for every socket it watches. The mechanism is identical. The difference is the number of sleepers: N threads each waiting on one socket, versus one thread waiting on N.

linux/net/core/sock.c · sk_wait_data()blocking read() on a TCP socket

    
linux/fs/eventpoll.c · ep_poll()epoll_wait(), trimmed

    

tcp_recvmsg_locked in net/ipv4/tcp.c calls sk_wait_data(sk, &timeo, last) when the receive queue is empty. sk_wait_event sleeps until the condition changes. In ep_poll, __set_current_state(TASK_INTERRUPTIBLE) followed by schedule_hrtimeout_range is the textbook "mark asleep, then yield the CPU" pair.

Frame 1 · user space

The same echo server, written both ways

These are minimal C programs, not from any project, written to make the structural difference concrete. The left one creates a thread per connection, and each thread spends its life blocked in read(). The right one has a single thread that blocks in epoll_wait() and handles whichever sockets are ready. Error handling is omitted.

echo_threads.cthread per connection

    
echo_epoll.cone thread, one event loop

    

The left program has N+1 kernel tasks for N clients, each with its own stack, and the kernel's scheduler decides who runs. The right program has one task, and our loop decides who runs, by the order in which it handles ready events. That is the whole architectural difference. The rest of this page follows each side down to the kernel code that pays for it.

Frame 2 · threads

What pthread_create actually asks the kernel for

From the report, Section 2.1Threads in a process share the address space, file descriptors and globals, but each has its own program counter, registers and stack.

On Linux there is no separate "thread" object in the kernel. A thread is a task created by clone with flags saying which resources to share with its creator. glibc's create_thread spells out exactly what our report describes: share memory (CLONE_VM), cwd and umask (CLONE_FS), file descriptors (CLONE_FILES) and signal handlers (CLONE_SIGHAND), join the same thread group so the process has one PID (CLONE_THREAD), and give the new task its own stack and thread-local storage.

glibc/nptl/pthread_create.c · create_thread()verbatim

  

The stack passed in .stack is mapped by glibc with mmap before this call. Its default size comes from RLIMIT_STACK, which is usually 8 MB on Linux. That is the "8 MB per thread" in our Section 4.3. The kernel also allocates a separate kernel stack for every task (16 KB on x86-64). Both appear in the scale model below.

Frame 3 · threads

The context switch, line by line

From the report, Section 2.2The kernel saves the current thread's registers, program counter and stack pointer, loads the next thread's state, and switches the page table base if the threads belong to different processes.

This is that function. When the scheduler picks a different task, it calls context_switch. If the next task is a user task with a different address space, switch_mm_irqs_off loads its page tables (CR3 on x86). Two threads of the same process share mm, so that step costs almost nothing between them. Then switch_to, a short piece of architecture-specific assembly, swaps the stack pointer and callee-saved registers. When switch_to "returns", it returns on the next task's stack, into wherever that task last called schedule().

linux/kernel/sched/core.c · context_switch()trimmed

  

An event loop never reaches this function to move between clients. Switching from one client to another is just our loop calling a different handler: a function call on the same stack. That is where "no context switches within the loop" in our Table 1 comes from.

Frame 4 · threads

Why counter++ loses updates, and what pthread_mutex_lock really does

From the report, Section 2.3, Listing 1Without the mutex, the read-modify-write on counter is not atomic and updates are lost. Under contention, the kernel puts the waiting thread to sleep.

counter++ compiles to three instructions: load into a register, add, store. Each thread has its own registers, so if the scheduler interleaves those instructions, both threads can load the same old value. Step through it below: pick an interleaving, then turn the mutex on.

The mutex is glibc's futex lock. Its word holds 0 (unlocked), 1 (locked, no waiters) or 2 (locked, maybe waiters). The fast path is a single atomic compare-and-swap in user space with no system call. Only when it fails does __lll_lock_wait set the word to 2 and call futex_wait, which puts the thread to sleep in the kernel. Unlock swaps the word back to 0 and, only if it was 2, calls futex_wake.

glibc/sysdeps/nptl/lowlevellock.h · __lll_lockfast path

    
glibc/nptl/lowlevellock.c · __lll_lock_wait()slow path, verbatim

    

What it is. Two threads running counter++ as three instructions (load, add, store), with us choosing the interleaving.

Checked against. Not checked automatically. Whether a real program loses updates depends on the compiler, optimisation level and CPU; Exercise 1 shows how to observe it with Listing 1.

Not covered. Caches, memory ordering, and the instructions a real compiler emits (at -O2 the loop may become a single add).

or step manually: we are the scheduler

Shared memory

0counter
0mutex futex word · 0 free, 1 locked, 2 locked with waiters
Each thread will run counter++ once. Correct final value: 2.
Frame 5 · event loop

How a single epoll_wait learns about N sockets

From the report, Section 3.1Node.js's event loop is built on libuv, which hides platform features like Linux's epoll, macOS's kqueue and Windows I/O completion ports.

When we epoll_ctl(ADD) a socket, the kernel hooks a callback, ep_poll_callback, onto that socket's own wait queue: the same queue a blocked read() would sleep on. When a packet arrives and the TCP stack wakes the socket's queue, this callback runs instead of waking a thread. It appends the socket to the epoll instance's ready list and wakes the one task sleeping in ep_poll.

libuv's uv__io_poll is the user-space side: call epoll_pwait, then loop over the returned events and dispatch each to its watcher. In Node, that dispatch eventually runs our JavaScript callback.

linux/fs/eventpoll.c · ep_poll_callback()trimmed

    
libuv/src/unix/linux.c · uv__io_poll()trimmed

    

Note what is not here: no per-connection thread and no per-connection stack. The per-connection kernel state is an epitem on a list. What a connection costs in an event loop is that small kernel record plus whatever our callback closures hold. The full path from epoll_wait up through V8's microtask queue is traced on the event-loop page.

Frame 6 · both, on one core

Run the same requests through both models

This is a small discrete-event simulation with one CPU core. Each request needs some CPU time, may wait on I/O (a database or network call), then needs more CPU. In the threads model, each request gets a thread, the scheduler runs ready threads round-robin with a time slice, and every switch between threads costs CPU time. In the event loop model, one thread runs each callback to completion and starts I/O without blocking.

What it is. A discrete-event simulation of one CPU core running the same requests under round-robin threads and under an event loop.

Checked against. Not a measurement. It follows the scheduling rules described above; the switch cost is drawn about 100 times larger than reality so it is visible.

Not covered. Multiple cores, real scheduler policy (EEVDF), caches, and I/O latency variation.

The switch cost is drawn about 100 times larger than reality (a real switch costs microseconds) so we can see it on a millisecond axis. Set it to 0 to compare pure scheduling order.

thread running callback running context switch ready, waiting for the CPU waiting on I/O

Things to look for. With the I/O-bound workload the two models finish at about the same time, but the threads row pays for every switch, and on a real server that overhead grows with the number of runnable threads. With CPU-bound jobs on one core neither model can go faster; threads just interleave the work and add switches, so the mean latency gets worse. More cores are what help, and only threads can use them without extra processes. With one expensive request, the event loop's head-of-line blocking appears: every cheap request that becomes ready while the expensive callback runs waits behind it, while the time slice lets threads keep cheap requests moving.

Frame 7 · scale

What 10,000 idle connections cost in each model

From the report, Section 4.3A pthread on Linux defaults to an 8 MB stack. An event-driven server holds only a small descriptor and a few callback closures per connection.

What it is. Arithmetic on default sizes: an 8 MiB reserved stack and a 16 KiB kernel stack per thread.

Checked against. The defaults come from glibc and the kernel; the totals are calculated, not measured. Exercise 2 shows how to measure them with /proc.

Not covered. Pages actually touched, socket buffers, allocator overhead, and per-connection application state.

Two corrections make this more precise than the usual "8 MB per thread" line. First, 8 MB is reserved virtual address space, not RAM. Linux backs stack pages lazily, so an idle thread typically uses only the pages it has touched, often tens of KB of resident memory. Second, the cost that is always resident is the kernel side: a task_struct plus a 16 KB kernel stack per thread, and a scheduler that has more runnable tasks to consider. The model shows reserved stack and kernel stack separately for that reason. Socket buffers are the same in both models, so they are left out.

Frame 8 · review

Sharpening a few lines of the report

Tracing the source turned up a few places where the report is right in spirit but can be stated more precisely.

Linux's Completely Fair Scheduler (CFS) is the example of a modern scheduler.
Since Linux 6.6 (2023), the default scheduler for normal tasks is EEVDF (Earliest Eligible Virtual Deadline First), which replaced CFS. It keeps CFS's idea of virtual runtime but also schedules by deadlines derived from a task's time slice.
The kernel saves registers into the process control block (PCB).
Linux has no structure called a PCB. On a switch, user registers are already saved on the task's kernel stack (from the system call or interrupt entry), and switch_to saves the stack pointer and callee-saved state into task_struct (its thread field). "PCB" is the textbook name for the role task_struct plays.
If the threads belong to different processes, the TLB may need to be flushed.
On modern x86 with PCID, switching address spaces usually does not flush the TLB; entries are tagged with an address-space ID. Switching between two threads of the same process skips switch_mm's real work entirely, because both share one mm_struct.
Event-driven code has no race conditions on in-process data.
There are no data races, since only one callback runs at a time, but there are still logical races. Any state read before an await can be changed by another callback before execution resumes, for example a check-then-act on a balance across an await db.query().
fs.readFile hands the request to a thread pool or the kernel's asynchronous I/O facility.
On Linux, libuv performs regular file I/O on its worker thread pool (4 threads by default, UV_THREADPOOL_SIZE), because epoll cannot report readiness for regular files. Newer libuv versions can use io_uring for some file operations. Sockets, by contrast, go through epoll. So a Node process is never truly single-threaded; only our JavaScript is.
A context switch typically costs between one and ten microseconds.
The direct cost of a thread switch within one process is often around a microsecond or less. The larger cost is the indirect one the report mentions: cold caches and branch predictors afterwards. Measure it ourselves with perf bench sched pipe (exercise 3).
Frame 9 · verify it ourselves

See it on our own machine

What to seeHow (Linux or WSL2)
Threads are tasks with one PIDps -eLf | grep echo_threads shows one PID and many LWP (thread) IDs. ls /proc/<pid>/task lists them.
The clone flagsstrace -f -e trace=clone,clone3 ./echo_threads prints the flags from create_thread for every connection.
Where each thread is sleepingcat /proc/<pid>/task/*/wchan for a thread blocked in read shows a wait function such as sk_wait_data; the epoll server shows ep_poll.
Context switches per threadgrep ctxt /proc/<pid>/task/*/status shows voluntary (blocked on I/O) and nonvoluntary (preempted) switches.
Reserved vs resident stackpmap -x <pid>: each thread stack shows about 8192 KB of address space but a small RSS.
Futex calls under contentionstrace -f -e trace=futex ./counter on Listing 1: futex calls appear only when threads actually collide.

Predict, then check

Exercises

  1. Compile Listing 1 without the mutex (gcc -O0 -pthread) with two threads of 1,000,000 increments each. Run it five times and explain each wrong total using the race simulator's interleaving. Then compile with -O2 and explain why the result changes. Hint: look at the generated loop with objdump -d.
  2. Using the two echo servers, open 5,000 idle connections to each (a small Python script with socket works). Compare VmRSS and Threads in /proc/<pid>/status with the scale model above.
  3. Run perf bench sched pipe, which bounces a message between two tasks. Divide the time by the number of round trips to estimate one switch, then rerun with both tasks pinned to one CPU via taskset -c 0.
  4. In the epoll server, add a 200 ms busy loop to the handler for one client. Connect three clients with nc and observe the head-of-line blocking from the simulator's third workload. Then move the busy work to a thread and explain what changed.
  5. Read ep_poll_callback in full and find where EPOLLEXCLUSIVE limits wakeups. Explain the "thundering herd" problem it solves when many processes wait on one listening socket.
Exam practice

Exam-style questions, with model answers

Five questions in the style of a CSE321 theory exam on concurrency. Write our answer on paper first, then open the model answer and mark ourselves honestly. The marks follow the usual pattern of university theory exams; they are not copied from any real paper.

Self-marks stay on this page only; refreshing clears them.
Two threads each run counter++ three times with no lock; counter starts at 0 and each increment is load, add, store. Give the largest and the smallest possible final value, and an interleaving that produces the smallest.

Largest: 6 (no overlap). Smallest: 2.

  1. T1 loads 0 (its first increment) and is preempted.
  2. T2 completes two increments: counter = 2.
  3. T1 stores 1: counter = 1, wiping out T2's work.
  4. T2 loads 1 (its third increment) and is preempted.
  5. T1 completes its second and third increments: counter = 3.
  6. T2 adds and stores 2: counter = 2.

Why it cannot be 1: the thread that makes the final store did its earlier increments first, so the value it loads for its last increment was written by some store, and every store writes at least 1. Adding 1 gives at least 2.

Marking: 1 for 6, 1 for 2, 2 for a valid interleaving.

A glibc mutex's futex word is 0 (free), 1 (locked, no waiters) or 2 (locked, maybe waiters). Threads A, B and C use one mutex: A locks; B tries to lock; C tries to lock; A unlocks; B (now woken) gets the lock and later unlocks. Give the word after each step and say which steps make a system call.
StepWordSyscall?
A locks: CAS 0 → 1 succeeds1no
B: CAS fails; __lll_lock_wait exchanges the word to 2, gets 1 back, sleeps2yes, futex_wait
C: CAS fails; word is already 2, so it goes straight to sleep2yes, futex_wait
A unlocks: exchange to 0, old value was 20yes, futex_wake
B wakes, exchanges to 2, gets 0 back: B owns the lock2no (returning from the wait)
B unlocks: exchange to 0, old value 20yes, futex_wake (wakes C)

B sets 2 rather than 1 when it takes the lock because it cannot know whether C is still waiting. That costs at most one unnecessary wake, which is cheaper than losing one.

Marking: 3 for the word values, 2 for identifying the four system calls.

A thread-per-connection server holds 10,000 idle connections. Each pthread has the default 8 MiB stack and each kernel task a 16 KiB kernel stack. Compute the reserved user stack space and the kernel stack memory. Why is the server's resident memory far below the first figure?

User stacks: 10,000 × 8 MiB = 80,000 MiB ≈ 78 GiB of reserved virtual address space.

Kernel stacks: 10,000 × 16 KiB = 160,000 KiB ≈ 156 MiB, always resident, plus a task_struct per thread.

The 8 MiB is a reservation made with mmap. Linux backs pages lazily on first touch, so an idle thread that has used only a few KiB of stack has only those pages resident. A 64-bit address space easily holds 78 GiB of reservations, so the real limits are the kernel memory, the scheduler and ulimit or threads-max settings.

Marking: 1 per figure, 2 for "reserved, not resident; pages faulted in lazily".

One core. At t = 0, requests arrive in the order A (30 ms of CPU), B (2 ms), C (2 ms); none does I/O. (a) Give each finish time and the mean for an event loop that runs each callback to completion. (b) The same for threads under round robin with a 10 ms time slice and zero switch cost. (c) Name the effect that (a) shows.

(a) Event loop: A 0–30, B 30–32, C 32–34. Finish times 30, 32, 34; mean 32 ms.

(b) Round robin: A 0–10, B 10–12 (done), C 12–14 (done), A 14–34 (done). Finish times A 34, B 12, C 14; mean 20 ms.

(c) Head-of-line blocking: the cheap requests wait behind the expensive callback because nothing can preempt it. The total work and the last finish time are the same, but the mean latency is much worse. Load the third workload in the simulator to see it.

Marking: 2 + 2 for the schedules and means, 2 for the effect and its cause.

On Linux, pthread_create calls clone. Which flags make the new task a thread of the same process, and what does each new thread still get of its own?

glibc passes CLONE_VM (share the address space), CLONE_FS (cwd, root, umask), CLONE_FILES (file descriptor table), CLONE_SIGHAND (signal handlers), CLONE_THREAD (same thread group, so the same PID from getpid()), CLONE_SYSVSEM, CLONE_SETTLS, CLONE_PARENT_SETTID and CLONE_CHILD_CLEARTID.

Each thread still has its own kernel task (task_struct) and thread ID, its own registers and program counter, its own user stack (mapped by glibc) and kernel stack, its own thread-local storage, and its own signal mask.

Marking: 2 for the key flags with meanings (VM, FILES, SIGHAND, THREAD at least), 2 for the per-thread state.

Reading list

Everything this page cites, by depth

The small numbered markers in the text open the relevant reading right where we need it. Here is all of it in one place: Start here for a first pass, Go deeper for the full treatment, and Primary sources for the specifications, papers and man pages the code implements.

Sources

Original files

Linux excerpts are GPL-2.0, glibc LGPL-2.1+, libuv MIT; all © their authors and quoted for study. The simulator is a teaching model with stated assumptions, not a benchmark.