Lab 3.2: The Threading Model and the EventManager
In Lab 3.1 you traced one request across the API/VMM thread boundary. This lab makes the threading
model physical: you will start a real microVM, enumerate its operating-system threads with
ps -T and /proc/<pid>/task, read the thread names the OS shows you (fc_api, fc_vmm,
fc_vcpu N), and map each one back to the line of Rust that spawned it. Then you will read the heart
of the VMM thread — the EventManager epoll loop from the rust-vmm event-manager crate — and
enumerate what subscribes to it: the device file descriptors, the API wake-up eventfd, and the vCPU
event channels. Finally you will articulate, precisely, why the control plane is kept off the data
plane.
This is a trace-it lab with a strong "observe the live process" component. There is no production code to write, but you will run commands against a running microVM and correlate the kernel's view of the process with the source.
Background
A Firecracker process implements exactly three thread classes (the Level 3 overview introduces them, and the VMM threading model deep dive covers them in depth):
| Thread | Linux comm name (verify) | Role | The loop it runs |
|---|---|---|---|
| API thread | fc_api | control plane: HTTP on the UDS | micro-http accept loop |
| VMM thread | fc_vmm | data plane control: devices, MMDS, rate limiting | the EventManager epoll_wait loop |
| vCPU thread (×N) | fc_vcpu 0, fc_vcpu 1, … | data plane: run guest code | the KVM_RUN loop |
Three things make this model what it is:
- One process is one microVM. Threads are not shared across guests; there is no in-process multiplexing. A host runs thousands of guests by running thousands of processes.
- The VMM thread is event-driven, not busy. It sleeps in
epoll_waituntil an fd it registered becomes readable. Every source of work — a guest kicking a virtqueue, the API thread sending an action, a periodic metrics flush — is a readable fd handled by aMutEventSubscriber. - The control plane is off the fast path. Configuration requests ride a separate channel + an eventfd; they are one more readable fd in the same epoll loop, never a lock on a vCPU and never a blocking call in device emulation.
Note: The thread
commnames (fc_api,fc_vmm,fc_vcpu N) are set explicitly in the source with aset_name/prctl(PR_SET_NAME)-style call. Linux truncatescommto 15 characters, so long names may appear clipped inps/top. Verify the exact strings on your branch with thergin Step 4 — they have been tidied across releases.
Why This Lab Matters for Contributors
A maintainer reasons about Firecracker bugs in terms of which thread is misbehaving. "The vCPU is
spinning at 100%" points at the KVM_RUN loop; "the VMM stalls under load" points at a blocking
process() in the EventManager; "configuration hangs" points at the API channel and the eventfd. To
debug a hang or a deadlock you must be able to attach to a specific thread, know what loop it is
supposed to be in, and recognize when it is stuck somewhere it shouldn't be. The EventManager loop in
particular is a place where a single mistake — a subscriber that blocks, or one that forgets to re-arm
its fd — degrades every device on the microVM. You cannot review a device PR or diagnose a stall
without this model in your hands.
Prerequisites
- Firecracker builds:
tools/devtool build(Level 1). - You can boot a microVM by hand with a kernel + rootfs (see Level 1, Lab 1.3). You need a real running guest for the thread-enumeration half.
- You completed Lab 3.1 (you know the API→VMM channel).
- You have read the Event Manager deep dive intro.
- Linux tools:
ps,top, and/proc(you are on the Linux host running Firecracker, not inside the guest).
mkdir -p ~/firecracker-notes
: > ~/firecracker-notes/threading-3.2.md
Note: Run these commands on the host, against the
firecrackerprocess — not inside the guest. If youjailer-launch in production the process lives in its own PID namespace; for this lab launchfirecrackerdirectly so the hostps//procsees it plainly.
Part A — Enumerate the Threads of a Live microVM (budget: 40 min)
Step 1 (8 min) — Boot a 2-vCPU microVM
Use two vCPUs so you can see two vCPU threads. Adjust the kernel/rootfs paths to your Lab 1.3 artifacts.
ARCH=$(uname -m)
BIN=build/cargo_target/${ARCH}-unknown-linux-musl/debug/firecracker
API=/tmp/fc-3.2.socket
rm -f "$API"
sudo "$BIN" --api-sock "$API" &
sleep 0.3
curl -sS -X PUT --unix-socket "$API" \
--data '{"kernel_image_path":"./vmlinux-6.1.x","boot_args":"console=ttyS0 reboot=k panic=1 pci=off"}' \
http://localhost/boot-source
curl -sS -X PUT --unix-socket "$API" \
--data '{"drive_id":"rootfs","path_on_host":"./ubuntu-24.04.ext4","is_root_device":true,"is_read_only":false}' \
http://localhost/drives/rootfs
# TWO vCPUs — this is the point:
curl -sS -X PUT --unix-socket "$API" \
--data '{"vcpu_count":2,"mem_size_mib":512}' http://localhost/machine-config
# Before starting: how many threads now? (API + VMM, no vCPUs yet.)
FC_PID=$(pgrep -n firecracker)
echo "PID=$FC_PID"
cat /proc/$FC_PID/status | grep '^Threads:'
Note the thread count before InstanceStart: you should see roughly 2 (API + VMM). The vCPU
threads do not exist yet — they are spawned by the builder at start. Now start the guest:
curl -sS -X PUT --unix-socket "$API" \
--data '{"action_type":"InstanceStart"}' http://localhost/actions
sleep 0.5
cat /proc/$FC_PID/status | grep '^Threads:'
The count should jump by vcpu_count (here +2, to ~4). You have just watched the vCPU threads
come into existence at boot.
Step 2 (8 min) — Name every thread
Three views of the same fact. Run all three and compare.
FC_PID=$(pgrep -n firecracker)
# (a) ps -T: one row per thread, with the comm name:
ps -T -p "$FC_PID" -o spid,tid,comm
# (b) top in thread mode (-H), one-shot batch:
top -H -b -n1 -p "$FC_PID" | sed -n '7,30p'
# (c) the raw kernel view: one directory per thread, comm in each:
for t in /proc/$FC_PID/task/*; do
printf '%s\t%s\n' "$(basename "$t")" "$(cat "$t/comm")"
done
Expected (names verify on your branch; ids differ):
SPID TID COMMAND
40121 40121 firecracker ← could be the main/VMM thread
40122 40122 fc_api ← the API thread
40123 40123 fc_vmm ← the VMM/EventManager thread
40124 40124 fc_vcpu 0 ← vCPU 0 (KVM_RUN loop)
40125 40125 fc_vcpu 1 ← vCPU 1
Tip:
commis capped at 15 bytes by the kernel. If you see a truncated name, that's why. The source string may be longer;ps/topshow the clipped version. Cross-check against thergin Step 4 rather than the truncated string.
Log it:
cat >> ~/firecracker-notes/threading-3.2.md <<'EOF'
## Live threads (vcpu_count=2)
Before InstanceStart: ~2 threads (API + VMM, no vCPUs)
After InstanceStart: ~4 threads (+2 vCPUs)
TID comm role
<..> fc_api API thread, micro-http accept loop
<..> fc_vmm VMM thread, EventManager epoll loop
<..> fc_vcpu 0 vCPU 0, KVM_RUN loop
<..> fc_vcpu 1 vCPU 1, KVM_RUN loop
EOF
Step 3 (8 min) — Observe what each thread is doing
A thread's state tells you which loop it's in. Sample the stack/state of each.
FC_PID=$(pgrep -n firecracker)
# Per-thread scheduler state (S = sleeping/blocked, R = running):
ps -T -p "$FC_PID" -o tid,comm,stat
# What syscall is each thread parked in right now? (root may be required)
for t in /proc/$FC_PID/task/*; do
tid=$(basename "$t")
printf '%s %-12s %s\n' "$tid" "$(cat "$t/comm")" "$(cat "$t/wchan" 2>/dev/null)"
done
Read the result. On an idle guest:
fc_apiis blocked in anaccept/epoll(waiting for the next HTTP request).fc_vmmis blocked inepoll_wait(the EventManager loop, waiting for a device/API event).- each
fc_vcpu Nis blocked inside theKVM_RUNioctl — the guest is idle (ithlt-ed), so the vCPU is parked in the kernel until an interrupt.
This is the threading model at rest: three loops, all asleep, each waiting on its own kind of event.
Generate some I/O in the guest (e.g. dd on its console) and re-sample — you'll see fc_vmm and a
vCPU flicker to R as virtqueue kicks wake the EventManager.
Step 4 (8 min) — Map each thread to its spawn site in source
Now correlate the kernel's view with the code. Find where each thread is named and spawned.
# Where the thread comm names are set:
rg -rn "fc_vcpu|fc_vmm|fc_api|set_name|PR_SET_NAME|prctl" src/vmm/src/ src/firecracker/src/ | head
# The vCPU thread spawn site:
rg -rn "thread::Builder|spawn\(|fn start_threads|fn start_vcpus|Builder::new\(\).name" src/vmm/src/ | rg -i "vcpu|builder" | head
# The API thread spawn site (in the adapter, not main.rs):
rg -rn "thread::Builder|spawn\(|run_with_api|ApiServer" src/firecracker/src/ | head
# The vCPU run loop itself (the KVM_RUN body):
rg -rn "fn run|KVM_RUN|VcpuExit|fn run_emulation" src/vmm/src/vstate/vcpu/ | head
What you should find and confirm:
- The vCPU threads are spawned in the builder (
builder.rs) when the microVM starts — onethread::Builder::new().name("fc_vcpu N")...spawn(...)per vCPU, each entering the vCPU'srunloop. This is why the thread count jumps atInstanceStart. - The API thread is spawned in
run_with_api(the adapter), running themicro-httpserver. - The VMM thread is the main thread that runs the EventManager loop after the builder hands it
the
Vmm(or a dedicated thread — verify which on your branch).
Log it:
cat >> ~/firecracker-notes/threading-3.2.md <<'EOF'
## Spawn sites
fc_vcpu N : src/vmm/src/builder.rs (start_vcpus / start_threads) -> Vcpu::run() -> KVM_RUN loop in src/vmm/src/vstate/vcpu/
fc_api : src/firecracker/src/ run_with_api -> micro-http server
fc_vmm : the EventManager loop (main/VMM thread) after the builder returns the Vmm
EOF
Part B — The EventManager Epoll Loop (budget: 35 min)
Step 5 (8 min) — Find the loop and confirm it is external
The EventManager is the rust-vmm event-manager crate — an external dependency, not vendored code.
# Confirm it is an external dependency:
rg -n "event-manager" Cargo.toml src/vmm/Cargo.toml
# Find where Firecracker drives the loop (the run() call on the VMM thread):
rg -rn "EventManager|event_manager|\.run\(|epoll" src/vmm/src/ src/firecracker/src/ | rg -i "event_manager|EventManager|\.run\(" | head
# (optional) read the crate source the build pulled in:
find ~/.cargo -path '*event-manager*/src/lib.rs' 2>/dev/null | head
The shape is: the VMM thread calls event_manager.run() (or run_with_timeout(...)) in a loop. Each
call does one epoll_wait, then dispatches process(...) to every subscriber whose fd is ready.
Read the crate's run/dispatch if you found it — it is small and worth 5 minutes.
Step 6 (10 min) — Enumerate the subscribers
A MutEventSubscriber is anything that registers fds with the EventManager and gets process-ed when
they fire. Enumerate every implementor in Firecracker.
# Every subscriber type:
rg -rn "impl MutEventSubscriber for" src/vmm/src/ src/firecracker/src/
# What each subscriber registers (look for the init/register/add calls):
rg -rn "fn init\b|register\(|add\(|Events::new|EventSet::" src/vmm/src/devices/ | head -30
You should find subscribers in these roles (the table — confirm the exact types on your branch):
| Subscriber role | What it registers | Waking it means |
|---|---|---|
| The API connection | the API wake-up eventfd | a VmmAction is on the channel (Lab 3.1) |
| virtio-block | the block queue eventfd (+ io_uring/async completion fd) | guest kicked the request queue / I/O completed |
| virtio-net | the TAP fd + RX/TX queue eventfds | a packet arrived from the host, or the guest kicked TX |
| virtio-vsock | the host Unix socket + queue eventfds | host↔guest data is ready |
| serial console | the input fd (stdin/PTY) | a keystroke arrived for the guest console |
| metrics | a timer fd | a periodic flush is due |
the Vmm itself | exit/shutdown signalling | the guest reset/halted; tear down |
The crucial observation: the API eventfd is just one subscriber among the device fds. The same
epoll loop that services a virtio-net packet also services a PUT /drives/{id}. That is the
mechanical realization of "the control plane shares the loop but not the fast path" — they are
different events on one epoll_wait.
Log it:
cat >> ~/firecracker-notes/threading-3.2.md <<'EOF'
## EventManager subscribers (MutEventSubscriber impls)
- API eventfd -> a VmmAction is waiting on the channel
- virtio-block -> queue eventfd / io completion
- virtio-net -> TAP fd + queue eventfds
- virtio-vsock -> host unix socket + queue eventfds
- serial console -> input fd
- metrics -> timer fd
The API eventfd is ONE subscriber among the device fds: same loop, separate events.
EOF
Step 7 (8 min) — The vCPU↔VMM channel is separate
The vCPU threads do not go through the EventManager for control. They talk to the VMM thread over a separate pair of channels.
rg -rn "VcpuEvent|VcpuResponse|VcpuHandle|enum VcpuEmulation" src/vmm/src/vstate/vcpu/ | head
rg -rn "Pause|Resume|SaveState|RestoreState|Finish" src/vmm/src/vstate/vcpu/ | rg -i "VcpuEvent" | head
Confirm: there is a VcpuEvent / VcpuResponse channel pair (Pause, Resume, SaveState, …) distinct
from the ApiRequest/ApiResponse channel. When you PATCH /vm {"state":"Paused"}, the API action
reaches the VMM thread over the API channel, and the VMM thread then sends a VcpuEvent::Pause to
each vCPU over the vCPU channel. Two channels, two purposes: the API channel is human/orchestrator
control; the vCPU channel is the VMM thread commanding its own vCPUs.
API thread ──ApiRequest/Response──► VMM thread ──VcpuEvent/Response──► vCPU threads
(control plane) (EventManager) (KVM_RUN loops)
Step 8 (9 min) — Articulate why the control plane is off the fast path
This is the conceptual deliverable. Write it in your own words; here is the argument to converge on.
The vCPU threads run guest code with minimal host interference — a vCPU spends its life in the
KVM_RUN ioctl and should return to it as fast as possible. The VMM thread services device I/O the
instant a queue is kicked. If configuration were handled on these threads — say, if a vCPU took a
lock on the Vmm to apply a config change — then a slow or malicious control request could stall a
vCPU mid-guest-execution, or block device emulation for every device, harming the data plane of a
tenant who isn't even the one sending the request. By routing all control through a separate API
thread that owns nothing but a channel, and by making the VMM thread process that channel as just
another epoll event (cheap, non-blocking, microsecond-scale — you measured this in Lab 3.1's stretch
goal), Firecracker guarantees that configuration latency and the data-plane fast path do not interact.
The threading model is, at bottom, a latency-isolation argument — and it is the same argument as the
per-thread seccomp categories: different jobs, different trust, hard boundaries.
Log it: write a one-paragraph version naming the API channel, the eventfd, the EventManager loop,
and the vCPU KVM_RUN loop.
Step 9 — Clean up
sudo pkill -f "fc-3.2.socket" 2>/dev/null
rm -f /tmp/fc-3.2.socket
The Model at a Glance
flowchart TD
subgraph proc[ONE firecracker process = ONE microVM]
API["fc_api thread<br/>micro-http accept loop"]
VMM["fc_vmm thread<br/>EventManager.run(): epoll_wait"]
V0["fc_vcpu 0<br/>KVM_RUN loop"]
V1["fc_vcpu 1<br/>KVM_RUN loop"]
end
API -->|ApiRequest + eventfd wake| VMM
VMM -->|ApiResponse| API
VMM -->|VcpuEvent: Pause/Resume| V0
VMM -->|VcpuEvent: Pause/Resume| V1
V0 -->|VcpuResponse / exit signal fd| VMM
V1 -->|VcpuResponse / exit signal fd| VMM
DEV[device fds: block/net/vsock/serial/timer] -->|readable| VMM
KVM[(/dev/kvm)]
V0 -->|ioctl KVM_RUN| KVM
V1 -->|ioctl KVM_RUN| KVM
Implementation Requirements / Deliverables
-
A
~/firecracker-notes/threading-3.2.mdlisting every live thread of a 2-vCPU microVM by TID andcomm, the thread count before vs afterInstanceStart, and each thread's spawn site in source. -
An enumeration of the EventManager subscribers (
MutEventSubscriberimpls) and what fd each registers. -
A note confirming the vCPU↔VMM channel (
VcpuEvent/VcpuResponse) is separate from the API channel. -
A one-paragraph, in-your-own-words explanation of why the control plane is off the fast path,
naming the channel, the eventfd, the EventManager loop, and the
KVM_RUNloop.
Troubleshooting
ps -T shows only one thread
You sampled before InstanceStart, or the process forked/exec'd and you grabbed the wrong PID. Use
pgrep -n firecracker to get the newest PID, and sample after you've sent InstanceStart.
Thread names are all firecracker (not fc_vcpu/fc_api)
Either your branch predates the explicit naming, or comm truncation is hiding them. Confirm with
the rg for set_name/PR_SET_NAME in Step 4 — if the source sets names but ps doesn't show them,
read /proc/<pid>/task/<tid>/comm directly, which is authoritative.
/proc/<pid>/task/*/wchan is empty or 0
Kernel hardening (kernel.yama / restricted /proc) can blank wchan for non-root. Run the loop
under sudo, or fall back to ps -T -o tid,comm,stat (the stat column still shows S/R/D states).
The vCPU threads show as running (R) at 100% on an idle guest
That can be a real bug (a busy KVM_RUN loop), or your guest is genuinely busy. Confirm by checking
inside the guest (top on the serial console). A correctly idle guest hlts and its vCPU parks in
the KVM_RUN ioctl in state S/D. A spinning vCPU on a truly idle guest is exactly the kind of
edge case Level 4, Lab 4.4 hunts.
I can't find impl MutEventSubscriber
The trait may be re-exported under a different path after the crate merge. Broaden the search:
rg -rn "MutEventSubscriber|EventSubscriber|Subscriber" src/vmm/src/ | head. The device subscribers
are under src/vmm/src/devices/.
Expected Output
$ ps -T -p $(pgrep -n firecracker) -o tid,comm,stat
TID COMMAND STAT
40121 firecracker Sl
40122 fc_api Sl
40123 fc_vmm Sl
40124 fc_vcpu 0 Sl
40125 fc_vcpu 1 Sl
(All sleeping on an idle guest: API in accept, VMM in epoll_wait, vCPUs in KVM_RUN.)
Stretch Goals
- Watch a vCPU exit live.
strace -f -e trace=ioctl -p $(pgrep -n firecracker) 2>&1 | grep KVM_RUN | headwhile you generate guest I/O. You'll see theKVM_RUNioctls returning and being re-entered — the run loop you'll read in Level 4. - Count subscribers as devices are added. Boot with one drive, then with a drive + a net iface +
a vsock, and compare how many fds the VMM thread's
epollwatches (ls /proc/<pid>/task/<vmm-tid>/fd | wc -lis a crude proxy). More devices = more subscribers = more readable fds in the same loop. - Pause and re-enumerate.
PATCH /vm {"state":"Paused"}, then re-sample the vCPU threads. They should leave theKVM_RUNloop and park waiting on aVcpuEvent::Resume. Find the parked state in/procand correlate it with the pause handling invstate/vcpu/. - Find the blocking-in-
process()foot-gun. Read one device'sprocessimplementation and reason about what would happen if it did a synchronous, slow host call there. Which other devices would be starved? (This is a real review concern for device PRs — see Level 7.)
Validation / Self-check
Answer without looking back:
- A microVM is configured with
vcpu_count: 4but not yet started. How many threads does the process have, and how many afterInstanceStart? Why the difference? - Name the loop each thread class runs and the syscall each is typically blocked in on an idle guest.
- The API wake-up eventfd is "just one subscriber among the device fds." Explain, in those terms, what it means to say the control plane shares the loop but is off the fast path.
- When you
PATCH /vm {"state":"Paused"}, two different channels are involved before the guest is actually paused. Name both and the order they're used. - What is a
MutEventSubscriber, and what two things must one do (besides existing) to participate in the EventManager loop? - Why would a slow, blocking call inside one device's
process()harm unrelated devices on the same microVM? Which thread is at risk? - Where in source is a vCPU thread spawned, and what loop does it enter? Where is the API thread
spawned, and why is it not in
main.rs?
When you can answer all seven and your threading log maps every live thread to its source, you've completed Lab 3.2. Continue to Lab 3.3: Build It — A Custom API Action.