Threads, Contexts, and Stacks
A game's threads share process resources while retaining separate execution contexts and call stacks; scheduling and races shape what an observer sees.
Cheatsheet for Lesson 6.6
A one-page summary of “Threads, Contexts, and Stacks” you can keep beside your notes. Plain Markdown.
Lesson 6.6 — Threads, Contexts, and Stacks: cheatsheet
- Remember: Thread correctness must survive both interleaved turns on one core and simultaneous work on several cores.
- Also useful: A call stack is a chain of unfinished jobs: each frame keeps enough information to resume the job that called it.
Key terms
- thread: A thread is a path of execution inside that process.
- interleaving: This is an interleaving problem.
- race condition: A bug whose result depends on which thread happens to run first is a race condition.
- thread context: A thread context is a structure containing register state for a particular architecture.
- task: A job is a small function called a task.
ERROR_NO_MORE_FILES: The finalERROR_NO_MORE_FILESis not a failure; it is the documented end of enumeration.
Review
- Q: What does a captured thread context describe? A: Register state, including the current instruction and stack locations
- Q: Which split of responsibilities between a process and its threads is right? A: The process supplies address space, handles, and modules; a thread is a path of execution
A process owns resources; threads execute code
Section titled “A process owns resources; threads execute code”The memory map says which pages existed when the tool asked. It cannot tell whether the game is changing an object at the same moment. To understand that time gap, we need to know who executes the game’s instructions and which state each execution path shares.
A process supplies the address space, handles, modules, and security boundary. A thread is a path of execution inside that process.
Every process begins with at least one thread. Games commonly add threads for rendering, audio, networking, asset loading, input, and background work. Those names describe roles chosen by the program; Windows fundamentally schedules thread IDs, priorities, and CPU state.
Threads in the same process share code and most data, but each thread has its own:
- thread ID, or TID;
- instruction pointer and general CPU registers;
- stack and stack pointer;
- thread-local storage;
- scheduling state and priority information;
- TEB, introduced in the PEB/TEB lesson.
Two threads can access the same object at nearly the same time. That is why games use mutexes, events, atomics, queues, and other synchronization tools.
The process supplies shared resources, while each execution path keeps its own paused CPU state and call history:
This shared-versus-private split explains both communication between threads and the races that communication can create.
Step through one game from program file to process to two threads taking turns on a core, with the numbers worked out:
Complete picture: two threads in one process, one CPU core switching between them.
The complete pictureEverything at once: the program file, the process Windows built from it, two threads that share the process's memory but keep their own stacks and registers, and one core that took turns between them for nine ticks. Thread A ran 6 instructions, B ran 3, and each kept its own count in EAX.
1 Program: a file on disk
14,336 bytes (0x3800)- Headers0x0400
- .text0x1C00
- .rdata0x0C00
- .data0x0400
- .rsrc0x0800
Instructions and data, written down. It sits still: no CPU time, no memory.
2 Process: its own space in memory
No process yet: the file is still only a file.
- Address spaceThe image sits at
0x00400000to0x00406FFF. - Handle tableReferences to the files, events and sockets this process opens.
- Shared by every threadGlobal
scoreat0x00404000holds10.
3 Threads: paths of execution inside the process
Thread ARunning on the CPU
- Own stack (1 MiB)
0x00200000to0x002FFFFF- Local variable
0x002FFFFC- In the CPU now
- EIP
0x004012D2, ESP0x00300000, EAX6
Thread BReady: waiting for a turn
- Own stack (1 MiB)
0x00300000to0x003FFFFF- Local variable
0x003FFFFC- Saved in its thread context
- EIP
0x00401509, ESP0x00400000, EAX3
4 One CPU core: who runs now
- A1
- A2
- A3
- B4
- B5
- B6
- A7
- A8
- A9
Each turn lasts 3 ticks, and a tick here is one instruction.
The core is running thread A
EIP 0x004012D2, ESP 0x00300000, EAX 6
Context switch: the registers of one thread were just saved to its context and the other's loaded.
The steps
Step 1 A program is a file
game.exe sits on disk: 14,336 bytes (0x00003800) of headers, instructions and data. Nothing in it is running. It uses no CPU time and no memory, only disk space.
Step 2 Starting it creates a process
Windows creates a process: a private address space, a handle table for the files and events it will open, and an owner. The loader maps the file's sections into that space at the image base. The image is 0x00007000 bytes, so it covers 0x00400000 to 0x00406FFF. No instruction has run yet: a process only holds resources.
Step 3 The first thread starts
Windows creates thread A inside the process. It gets its own stack, 1 MiB of address space (0x00200000 to 0x002FFFFF), and its own registers. EIP starts at the entry point: base 0x00400000 + RVA 0x000012C0 = 0x004012C0. ESP is the first address past the empty stack, 0x00300000.
Step 4 A second thread joins
The game starts thread B. It lives in the same process, so it gets no new address space, only its own stack (0x00300000 to 0x003FFFFF) and its own registers: EIP 0x00401500, ESP 0x00400000. One process can hold many threads.
Step 5 Memory is shared, stacks are not
Both threads see the same address 0x00404000, the start of .data (base 0x00400000 + RVA 0x00004000). If A stores 10 in the global score, B reads 10. A local variable is different: it lives on the thread's own stack, 0x00300000 − 4 = 0x002FFFFC for A and 0x00400000 − 4 = 0x003FFFFC for B, so the two never collide.
Step 6 One core runs one thread at a time
On a single core the scheduler gives each thread a turn of 3 ticks. Thread A goes first. Each tick runs add eax, 1, which is 3 bytes, so EAX goes 1, 2, 3 and EIP goes 0x004012C0 + 3 × 3 = 0x004012C9. (A tick is one instruction here. A real time slice lasts a few milliseconds, millions of instructions.)
Step 7 The turn ends: save one thread, load the other
A context switch. The scheduler saves A's registers into A's thread context in memory: EAX 3, EIP 0x004012C9. Then it loads B's saved registers into the CPU: EAX 0, EIP 0x00401500. EIP now points into B's code, so B is what runs next.
Step 8 Thread B runs its turn
Ticks 4 to 6 belong to B: EAX goes 1, 2, 3 and EIP goes 0x00401500 + 3 × 3 = 0x00401509. Thread A waits, ready to run, with its registers parked in its context.
Step 9 Switch back: restore A
The scheduler saves B (EAX 3, EIP 0x00401509) and restores A's saved registers: EAX 3, EIP 0x004012C9. A cannot tell it was ever paused: the next instruction it runs is exactly the one it was about to run.
Step 10 Thread A carries on
Ticks 7 to 9 belong to A again. EAX goes from 3 to 6 and EIP goes 0x004012C9 + 3 × 3 = 0x004012D2. In nine ticks A ran six instructions and B three, and neither lost its place.
The addresses are illustrative; a real run is placed by Windows and changes with ASLR. A real time slice lasts milliseconds, not three instructions, and the scheduler picks threads by priority and readiness. The shape is what matters: every thread keeps its own registers and stack, and the process memory is shared.
Scheduling is not the same as parallel execution
Section titled “Scheduling is not the same as parallel execution”Windows schedules runnable threads by giving them turns on CPU cores. On one core, threads can interleave: one runs, pauses, and another runs. On several cores, some threads can also run truly in parallel. The program must be correct under both possibilities.
Suppose the update thread changes a player record in two steps while an overlay thread reads between them:
The overlay may observe a combination that was never a complete game state. This is an interleaving problem. “It worked ten times” does not prove it is safe, because the scheduler may choose a different order on the next run.
A race that loses an update
Section titled “A race that loses an update”A bug whose result depends on which thread happens to run first is a race condition. The torn read above is one. The most common kind loses a write.
“Add 500 gold” is one line of source code, but the CPU does it in three steps:
read the value into a register, add, and write the result back. Put two threads
on the same gold field, one adding a 500-gold quest reward and one taking 300
for a purchase, and let the scheduler interleave them:
| Step | Reward thread | Purchase thread | gold in memory |
|---|---|---|---|
| 1 | reads 1,000 | 1,000 | |
| 2 | reads 1,000 | 1,000 | |
| 3 | 1,000 + 500 = 1,500 | 1,000 − 300 = 700 | 1,000 |
| 4 | writes 1,500 | 1,500 | |
| 5 | writes 700 | 700 |
Two threads each copy the shared gold value 1,000. One register becomes 1,500 after a 500 reward; the other becomes 700 after a 300 purchase. Writing 1,500 then the stale 700 loses the reward. The intended combined total is 1,200.
Read the steps
- The shared field holds 1,000 gold. The reward thread reads it into its private register, while memory remains 1,000.
- Before the reward is written, the purchase thread also reads 1,000. Its private copy does not change when the reward thread later writes memory.
- The private calculations produce 1,000 + 500 = 1,500 and 1,000 − 300 = 700. Both are correct. The shared field still holds 1,000.
- The reward thread copies 1,500 back into gold. The purchase register remains 700: the completed reward does not repair that earlier calculation.
- The purchase writes its stale 700 last and overwrites the reward. The intended total is 1,000 + 500 − 300 = 1,200. The missing 500 is exactly the lost reward.
A mutex makes the purchase wait and read the completed 1,500 before subtracting 300. It protects the whole read–modify–write sequence.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
The player should have 1,000 + 500 − 300 = 1,200 and has 700. The reward vanished, because the purchase thread wrote a result computed from a value that was already out of date. Most runs interleave harmlessly, with one thread finishing before the other starts, which is why a race can pass every test and still fail for a player.
Request Read gold
Step 1: Reward thread to Gold in memory
The reward thread loads the value it will add 500 to.
Reply 1,000
Step 2: Gold in memory to Reward thread
The reward thread now holds a copy of 1,000.
Request Read gold
Step 3: Purchase thread to Gold in memory
The purchase thread reads before the reward has written anything.
Reply 1,000
Step 4: Gold in memory to Purchase thread
Both threads hold the same copy of 1,000. Neither can see the other’s plan.
Data Write 1,500
Step 5: Reward thread to Gold in memory
1,000 + 500 is stored.
Data Write 700
Step 6: Purchase thread to Gold in memory
The purchase computed 1,000 − 300 from its old copy and overwrites the 1,500.
Alone Final gold: 700 (should be 1,200)
Step 7: Gold in memory (alone)
Each thread was correct on its own. The result is wrong because the read and the write were not one step.
Both threads start from 1,000. Writing 1,500 and then 700 loses the reward; the intended combined result is 1,200.
A mutex fixes it by making read, add, and write one unit: the purchase thread
waits at step 2 until the reward thread has written 1,500, then reads 1,500 and
writes 1,200. For a single number, an atomic add does the same job more cheaply;
on x86, lock xadd performs the whole read, add, and write as one indivisible
instruction.
The lock has its own failure, deadlock. Moving gold from player A to player B means locking both. If one thread locks A and then waits for B, while another thread locks B and then waits for A, each holds what the other needs and neither ever continues. Taking locks in one agreed order, say always the lower player ID first, makes that cycle impossible, as Lesson 12.2 shows for the Windows loader lock.
The same lost update appears whenever two writers share one value: a tool that writes a value the game is also updating every frame is racing the game, and one of the two writes will be lost.
Synchronization creates an agreement about ordering or exclusive access. A mutex can protect a group of fields as one critical section. An atomic operation can make one small value indivisible. A queue can transfer ownership of work from one thread to another. These tools solve different problems; adding an atomic flag does not automatically make the object around it coherent.
Two common non-deadlock failures deserve names:
- An atomicity violation happens when several operations must behave as one unit, but another thread can observe or change state in the middle. “Check pointer, then use pointer” is unsafe if another thread can free it between those steps.
- An order violation happens when one action assumes another action already occurred, but no synchronization enforces that order. A render worker reading a snapshot before initialization finishes is one example.
Here is the pointer version of the first problem. A true check does not reserve the object for the next line:
The fix must enforce the required relationship, not merely make the timing less likely. A lock can protect one compound state change. An event, condition variable, or channel can establish that initialization completed before consumption begins.
Ask both safety and progress questions
Section titled “Ask both safety and progress questions”Concurrent code needs two kinds of reasoning:
- Safety: can something bad happen, such as a torn state, double update, or use-after-free?
- Progress: can the intended work eventually happen, or can threads wait forever?
A mutex may improve safety but create a deadlock if two threads acquire locks in opposite orders. A thread may be safe yet suffer starvation if other work continually wins access. A spin loop may make progress in a test and waste an entire core in the real game.
For an observer tool, prefer immutable snapshots and message passing when possible. One component reads a bounded snapshot, validates it, and hands owned data to rendering or logging. That reduces the amount of shared mutable state whose interleavings you must reason about.
Ready, running, and waiting explain thread activity
Section titled “Ready, running, and waiting explain thread activity”A thread that is not executing is not necessarily broken. In a simplified state model, a thread can be:
- running: currently executing on a core;
- ready: able to run, waiting for the scheduler to choose it;
- waiting: blocked until I/O, a timer, lock, event, or another condition changes.
Windows transitions threads between these states. A network thread may spend most of its life waiting and still be perfectly healthy. A CPU profiler mainly samples running work; wait analysis explains why a thread is not running. Use the tool that matches the question.
A thread context stores paused register values
Section titled “A thread context stores paused register values”A thread context is a structure containing register state for a particular architecture. On 32-bit x86, useful fields include:
Eip: the next instruction address;Esp: the top of the current stack;Ebp: a frame pointer when the compiler uses one;Eax,Ebx,Ecx,Edx,Esi, andEdi: general-purpose registers;EFlags: condition and control bits, including the trap flag used for single-stepping.
On x86-64, those names become Rip, Rsp, Rbp, Rax, and so on, with additional registers.
The context is meaningful only at a controlled instant. Reading or writing a running thread’s registers while it continues executing would race with the CPU. A debugger receives an event while Windows has stopped the relevant threads, or a tool explicitly suspends a thread and must later resume it.
This course’s debugger uses the debugger event model. It does not randomly freeze a game thread and hope cleanup succeeds.
A stack records nested work
Section titled “A stack records nested work”When function A calls function B, the program must remember where B should return. Arguments, saved registers, local variables, and return information may be stored in a stack frame.
spend_gold was called from inside end_turn, called from inside process_player_action, called from game_loop.A stack trace walks from the current frame toward older callers. Optimized code can inline functions, omit frame pointers, move values into registers, or reuse stack slots, so the neat classroom picture is a guide rather than a promise.
That complexity is why Microsoft recommends StackWalk64 instead of inventing a stack walker from raw pointers. Matching symbols and unwind information make the result much more reliable.
Worker threads and tasks: keeping slow work off the frame
Section titled “Worker threads and tasks: keeping slow work off the frame”Lesson 4.1 showed that a frame has about 16.67 ms, and that loading a file or planning a path can take longer. Games solve this with worker threads. The main thread runs the frame loop, and the other threads do jobs for it.
Starting a new thread for every job would be costly. Each thread reserves a stack, which is 1 MiB of address space by default for a Windows program, so 1,000 threads reserve about 1,000 MiB, and the scheduler has to juggle all of them. Engines instead start a fixed thread pool: a handful of workers that wait for jobs in a queue. A common size is one fewer than the number of cores, leaving a core for the main thread, so 7 workers on an 8-core processor. A job is a small function called a task. Whenever a worker is idle it takes the next task from the queue. A hundred tasks of 4 ms each take 100 ÷ 7 = 14.3, so 15, rounds of 4 ms = 60 ms on the 7 workers, against 100 × 4 = 400 ms on one thread.
A task finishes on a worker thread, but the main thread must not find its objects changing in the middle of a frame. So the worker does not touch game objects. It sends its result through a channel, a queue that one thread writes and another reads, and the main thread picks results up at one known point, such as the start of a frame, when nothing else is using the data. Lesson 6.11 builds such a channel for a recorder.
A result can arrive late. Suppose a task planned a path for entity 42 and, by the time
the result is ready, that entity was despawned and its slot reused for a new entity. The
task remembered the entity’s generation number (Lesson 3.10), so the main thread compares it
with the slot’s current one and drops the result if they differ. Some engines write tasks
as async functions that pause at a wait point and resume on the pool later, which is the
same idea in different syntax.
For an observer, a pool shows up as several threads that start at the same address and spend most of their time waiting. A tool that pauses threads, as the next lab does, may catch workers that are doing nothing.
Start with a read-only thread inventory
Section titled “Start with a read-only thread inventory”Before opening a thread or asking for its context, identify which threads belong to the target process. TH32CS_SNAPTHREAD creates a system-wide snapshot, even when a PID is supplied. The program must filter th32OwnerProcessID itself:
Complete lab source: thread_inventory.rs
thread_inventory.rs
Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
use std::mem::size_of;
use gha_windows_labs::{OwnedHandle, Process};
use windows::Win32::{
Foundation::ERROR_NO_MORE_FILES,
System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
},
};
fn no_more_files(error: &windows::core::Error) -> bool {
error.code() == ERROR_NO_MORE_FILES.to_hresult()
}
let process_name = std::env::args()
.nth(1)
.unwrap_or_else(|| "wesnoth.exe".to_owned());
let process = Process::find(&process_name)?;
// TH32CS_SNAPTHREAD is system-wide; the PID is used only for filtering below.
// SAFETY: no pointers are supplied and OwnedHandle owns the returned snapshot.
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }?;
let snapshot = OwnedHandle::from_raw(snapshot)?;
let mut entry = THREADENTRY32 {
dwSize: size_of::<THREADENTRY32>() as u32,
..Default::default()
};
// SAFETY: entry has the required size and remains writable during the call.
unsafe { Thread32First(snapshot.raw(), &mut entry) }?;
let mut threads = Vec::new();
loop {
if entry.th32OwnerProcessID == process.id {
threads.push((entry.th32ThreadID, entry.tpBasePri));
}
// SAFETY: entry remains a correctly sized writable output structure.
match unsafe { Thread32Next(snapshot.raw(), &mut entry) } {
Ok(()) => {}
Err(error) if no_more_files(&error) => break,
Err(error) => return Err(error.into()),
}
}
threads.sort_unstable_by_key(|(thread_id, _)| *thread_id);
println!(
"{} (PID {}) has {} thread(s)",
process.name,
process.id,
threads.len()
);
for (thread_id, base_priority) in threads {
println!(" TID {thread_id:<8} base priority {base_priority}");
}
println!("No thread was opened, suspended, queued, or given a new context.");
Ok(())
}
#[cfg(not(windows))]
fn main() {
eprintln!("This read-only thread inventory must run on Windows.");
}#[cfg(windows)]fn main() -> anyhow::Result<()> { use std::mem::size_of; use gha_windows_labs::{OwnedHandle, Process}; use windows::Win32::{ Foundation::ERROR_NO_MORE_FILES, System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, }, };
fn no_more_files(error: &windows::core::Error) -> bool { error.code() == ERROR_NO_MORE_FILES.to_hresult() }
let process_name = std::env::args().nth(1) .unwrap_or_else(|| "wesnoth.exe".to_owned()); let process = Process::find(&process_name)?;
// 🔍 The thread snapshot is system-wide; filter by owner PID below. // 🧹 SAFETY: OwnedHandle owns the returned snapshot. let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }?; let snapshot = OwnedHandle::from_raw(snapshot)?; let mut entry = THREADENTRY32 { dwSize: size_of::<THREADENTRY32>() as u32, ..Default::default() };
// 📏 SAFETY: entry has the required size and is writable. unsafe { Thread32First(snapshot.raw(), &mut entry) }?; let mut threads = Vec::new(); loop { if entry.th32OwnerProcessID == process.id { threads.push((entry.th32ThreadID, entry.tpBasePri)); } // 🔁 SAFETY: entry remains a valid output structure. match unsafe { Thread32Next(snapshot.raw(), &mut entry) } { Ok(()) => {} Err(error) if no_more_files(&error) => break, Err(error) => return Err(error.into()), } }
threads.sort_unstable_by_key(|(thread_id, _)| *thread_id); println!( "{} (PID {}) has {} thread(s)", process.name, process.id, threads.len(), ); for (thread_id, base_priority) in threads { println!(" TID {thread_id:<8} base priority {base_priority}"); } println!("No thread was opened, suspended, queued, or changed."); Ok(())}
#[cfg(not(windows))]fn main() { eprintln!("This read-only thread inventory must run on Windows.");}dwSize tells Windows how large a THREADENTRY32 the caller prepared. The final ERROR_NO_MORE_FILES is not a failure; it is the documented end of enumeration.
Run and compare stable facts
Section titled “Run and compare stable facts”Run the inventory once for each game process. Each row gives a thread ID and base priority from that moment’s snapshot. Look for threads that appear and disappear between runs before trying to assign any one ID a permanent role.
cd windows-labscargo build --release --target i686-pc-windows-msvc ` --bin thread_inventory
.\target\i686-pc-windows-msvc\release\thread_inventory.exe wesnoth.exe.\target\i686-pc-windows-msvc\release\thread_inventory.exe ac_client.exe.\target\i686-pc-windows-msvc\release\thread_inventory.exe Quake3-UrT.exeThe count may change between runs because games create and retire worker threads. A TID is temporary and can eventually be reused. Do not store it as a version-specific constant.
Base priority is scheduling input, not “importance.” The currently busy rendering thread might have the same base priority as an idle worker. ETW CPU samples and debugger stacks show which threads are actually doing work.
Likewise, a TID does not identify a permanent role. A thread’s role shows up in several places: its start address or module, its stack sampled over time, its CPU and wait activity, its thread description when the game sets one, and which game events line up with that activity. One paused stack shows only what the thread was doing at that instant. In an engine with a job system, the same worker thread can be mixing audio in one sample and decompressing a texture in the next.
How the Wesnoth debugger uses a context safely
Section titled “How the Wesnoth debugger uses a context safely”When Windows reports the course breakpoint, the event thread is stopped. The debugger:
- opens that exact event thread with context rights;
- reads its x86
CONTEXT; - moves
Eipback over the one-byteint3; - sets the trap flag in
EFlags; - continues the event so one original instruction runs;
- receives the single-step event;
- clears the trap flag and rearms the breakpoint.
context.Eip = u32::try_from(INCOME_INSTRUCTION)?;context.EFlags |= 1 << 8; // x86 trap flagset_context(&thread, &context)?;The state machine prevents one thread’s single-step event from being mistaken for another thread’s event.
Why this lesson does not hijack threads
Section titled “Why this lesson does not hijack threads”Changing another thread’s instruction pointer, queuing executable work to it, or suspending service threads is not required to understand game execution. Those operations can deadlock a process, strand locks, corrupt state, or stop the diagnostics that would explain the failure.
The course inventories threads read-only and changes a context only inside the explicit breakpoint lifecycle of the version-matched Wesnoth debugger, where every event is continued exactly once.
The inventory is thread_inventory.rs. The controlled context example is wesnoth_debugger.rs.
References: ToolHelp thread functions, GetThreadContext, and StackWalk64.
Read execution state
What does a captured thread context describe?
A context is one instant of CPU-visible thread state. Combining it with stack memory and module symbols helps reconstruct active calls.
Finished Lesson 6.6?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.