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.
Both models end in the same place: a sleeping task and schedule()
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.
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.
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.
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.
What pthread_create actually asks the kernel for
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.
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.
The context switch, line by line
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().
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.
Why counter++ loses updates, and what pthread_mutex_lock really does
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.
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).
Shared memory
How a single epoll_wait learns about N sockets
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.
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.
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.
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.
What 10,000 idle connections cost in each model
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.
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.
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.switch_mm's real work entirely, because both share one mm_struct.await can be changed by another callback before execution resumes, for example a check-then-act on a balance across an await db.query().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.perf bench sched pipe (exercise 3).See it on our own machine
| What to see | How (Linux or WSL2) |
|---|---|
| Threads are tasks with one PID | ps -eLf | grep echo_threads shows one PID and many LWP (thread) IDs. ls /proc/<pid>/task lists them. |
| The clone flags | strace -f -e trace=clone,clone3 ./echo_threads prints the flags from create_thread for every connection. |
| Where each thread is sleeping | cat /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 thread | grep ctxt /proc/<pid>/task/*/status shows voluntary (blocked on I/O) and nonvoluntary (preempted) switches. |
| Reserved vs resident stack | pmap -x <pid>: each thread stack shows about 8192 KB of address space but a small RSS. |
| Futex calls under contention | strace -f -e trace=futex ./counter on Listing 1: futex calls appear only when threads actually collide. |
Predict, then check
Exercises
- 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-O2and explain why the result changes. Hint: look at the generated loop withobjdump -d. - Using the two echo servers, open 5,000 idle connections to each (a small Python script with
socketworks). CompareVmRSSandThreadsin/proc/<pid>/statuswith the scale model above. - 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 viataskset -c 0. - In the epoll server, add a 200 ms busy loop to the handler for one client. Connect three clients with
ncand observe the head-of-line blocking from the simulator's third workload. Then move the busy work to a thread and explain what changed. - Read
ep_poll_callbackin full and find whereEPOLLEXCLUSIVElimits wakeups. Explain the "thundering herd" problem it solves when many processes wait on one listening socket.
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.
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.
- T1 loads 0 (its first increment) and is preempted.
- T2 completes two increments: counter = 2.
- T1 stores 1: counter = 1, wiping out T2's work.
- T2 loads 1 (its third increment) and is preempted.
- T1 completes its second and third increments: counter = 3.
- 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.
| Step | Word | Syscall? |
|---|---|---|
| A locks: CAS 0 → 1 succeeds | 1 | no |
B: CAS fails; __lll_lock_wait exchanges the word to 2, gets 1 back, sleeps | 2 | yes, futex_wait |
| C: CAS fails; word is already 2, so it goes straight to sleep | 2 | yes, futex_wait |
| A unlocks: exchange to 0, old value was 2 | 0 | yes, futex_wake |
| B wakes, exchanges to 2, gets 0 back: B owns the lock | 2 | no (returning from the wait) |
| B unlocks: exchange to 0, old value 2 | 0 | yes, 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.
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".
(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.
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.
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.
Original files
- linux/kernel/sched/core.c
- linux/fs/eventpoll.c
- linux/net/core/sock.c
- linux/net/ipv4/tcp.c
- glibc/nptl/pthread_create.c
- glibc/nptl/lowlevellock.c
- glibc/sysdeps/nptl/lowlevellock.h
- libuv/src/unix/linux.c
- clone(2) man page
- futex(2) man page
- epoll(7) man page
- Kernel docs: EEVDF scheduler
- Dan Kegel, "The C10K problem"
- Ulrich Drepper, "Futexes Are Tricky"
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.