Skip to content

Commit 5ba12da

Browse files
committed
Design doc feedback from #26621. NFC
1 parent ecb9e39 commit 5ba12da

File tree

2 files changed

+101
-92
lines changed

2 files changed

+101
-92
lines changed
Lines changed: 86 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Design Doc: Precise Futex Wakeups in Emscripten
1+
# Design Doc: Precise Futex Wakeups
22

33
- **Status**: Draft
44
- **Bug**: https://github.com/emscripten-core/emscripten/issues/26633
@@ -24,7 +24,7 @@ CPU wakeups and increased latency for events.
2424
## Non-Goals
2525
- **Main Browser Thread**: Changes to the busy-wait loop in `futex_wait_main_browser_thread` are out of scope.
2626
- **Direct Atomics Usage**: Threads that call `atomic.wait` directly (bypassing `emscripten_futex_wait`) will remain un-interruptible.
27-
- **Wasm Workers**: Wasm Worker do not have a `pthread` structure, they are not covered by this design.
27+
- **Wasm Workers**: Wasm Workers do not have a `pthread` structure, so they are not covered by this design.
2828

2929
## Proposed Design
3030

@@ -38,103 +38,101 @@ As part of this design we will need to explicitly state that
3838
application.
3939

4040
### 1. `struct pthread` Extensions
41-
We will add the following fields to `struct pthread` (in
42-
`system/lib/libc/musl/src/internal/pthread_impl.h`). All operations on these
43-
fields must use `memory_order_seq_cst` to ensure the handshake is robust.
41+
We will add a single atomic `wait_addr` field to `struct pthread` (in
42+
`system/lib/libc/musl/src/internal/pthread_impl.h`).
4443

4544
```c
4645
// The address the thread is currently waiting on in emscripten_futex_wait.
47-
// NULL if the thread is not currently in a futex wait.
48-
_Atomic(void*) waiting_on_address;
49-
50-
// A counter that is incremented every time the thread wakes up from a futex wait.
51-
// Used by wakers to ensure the target thread has actually acknowledged the wake.
52-
_Atomic(uint32_t) wait_counter;
53-
54-
// A bitmask of reasons why the thread was woken for a side-channel event.
55-
_Atomic(uint32_t) wait_reasons;
56-
57-
#define WAIT_REASON_CANCEL (1 << 0)
58-
#define WAIT_REASON_MAILBOX (1 << 1)
46+
//
47+
// This field encodes the state using the following bitmask:
48+
// - NULL: Not waiting, no pending notification.
49+
// - NOTIFY_BIT (0x1): Not waiting, but a notification was sent.
50+
// - addr: Waiting on `addr`, no pending notification.
51+
// - addr | NOTIFY_BIT: Waiting on `addr`, notification sent.
52+
//
53+
// Since futex addresses must be 4-byte aligned, the low bit is safe to use.
54+
_Atomic(uintptr_t) wait_addr;
55+
56+
#define NOTIFY_BIT (1 << 0)
5957
```
6058
6159
### 2. Waiter Logic (`emscripten_futex_wait`)
62-
The waiter will follow this logic (using `SEQ_CST` for all atomic accesses):
63-
64-
1. **Pre-check**: Check `wait_reasons`. If non-zero, handle the reasons (e.g., process mailbox or handle cancellation).
65-
2. **Publish**: Set `waiting_on_address = addr`.
66-
3. **Counter Snapshot**: Read `current_counter = wait_counter`.
67-
4. **Double-check**: This is critical to avoid the race where a reason was added just before `waiting_on_address` was set. If `wait_reasons` is now non-zero, clear `waiting_on_address` and go to step 1.
68-
5. **Wait**: Call `ret = __builtin_wasm_memory_atomic_wait32(addr, val, timeout)`.
69-
6. **Unpublish**:
70-
- Set `waiting_on_address = NULL`.
71-
- Atomically increment `wait_counter`.
72-
7. **Post-check**: Check `wait_reasons`. If non-zero, handle the reasons.
73-
8. **Return**: Return the result of the wait to the caller.
74-
- If `ret == ATOMICS_WAIT_OK`, return `0`.
75-
- If `ret == ATOMICS_WAIT_TIMED_OUT`, return `-ETIMEDOUT`.
76-
- If `ret == ATOMICS_WAIT_NOT_EQUAL`, return `-EWOULDBLOCK`.
77-
78-
Note: We do **not** loop internally if `ret == ATOMICS_WAIT_OK`. Even if we suspect the wake was caused by a side-channel event, we must return to the user to avoid "swallowing" a simultaneous real application wake that might not have changed the memory value.
60+
The waiter will follow this logic:
61+
62+
1. **Notification Loop**:
63+
```c
64+
uintptr_t expected_null = 0;
65+
while (!atomic_compare_exchange_strong(&self->wait_addr, &expected_null, (uintptr_t)addr)) {
66+
// If the CAS failed, it means NOTIFY_BIT was set by another thread.
67+
assert(expected_null == NOTIFY_BIT);
68+
// Let the notifier know that we received the wakeup notification by
69+
// resetting wait_addr.
70+
self->wait_addr = 0;
71+
handle_wakeup(); // Process mailbox or handle cancellation
72+
// Reset expected_null because CAS updates it to the observed value on failure.
73+
expected_null = 0;
74+
}
75+
```
76+
2. **Wait**: Call `ret = __builtin_wasm_memory_atomic_wait32(addr, val, timeout)`.
77+
3. **Unpublish & Check**:
78+
```c
79+
// Clear wait_addr and check if a notification arrived while we were sleeping.
80+
if ((atomic_exchange(&self->wait_addr, 0) & NOTIFY_BIT) != 0) {
81+
handle_wakeup();
82+
}
83+
```
84+
4. **Return**: Return the result of the wait.
85+
86+
Note: We do **not** loop internally if `ret == ATOMICS_WAIT_OK`. Even if we
87+
suspect the wake was caused by a side-channel event, we must return to the user
88+
to avoid "swallowing" a simultaneous real application wake.
7989
8090
### 3. Waker Logic
81-
When a thread needs to wake another thread for a side-channel event (e.g., in `pthread_cancel` or `em_task_queue_enqueue`):
82-
83-
1. Atomically OR the appropriate bit into the target thread's `wait_reasons` (`SEQ_CST`).
84-
2. Read `target_addr = target->waiting_on_address` (`SEQ_CST`).
85-
3. If `target_addr` is not NULL:
86-
- Read `start_c = target->wait_counter` (`SEQ_CST`).
87-
- Enter a loop:
88-
- Call `emscripten_futex_wake(target_addr, 1)`.
89-
- Exit loop if `target->wait_counter != start_c` OR `target->waiting_on_address != target_addr`.
90-
- **Yield**: Call `sched_yield()` (or a small sleep) to allow the target thread to proceed if it is currently being scheduled.
91+
When a thread needs to wake another thread for a side-channel event:
92+
93+
1. **Enqueue Work**: Add the task to the target's mailbox or set the cancellation flag.
94+
2. **Signal**:
95+
```c
96+
uintptr_t addr = atomic_fetch_or(&target->wait_addr, NOTIFY_BIT);
97+
if (addr == 0 || (addr & NOTIFY_BIT) != 0) {
98+
// Either the thread wasn't waiting (it will see NOTIFY_BIT later),
99+
// or someone else is already in the process of notifying it.
100+
return;
101+
}
102+
// We set the bit and are responsible for waking the target.
103+
// The target is currently waiting on `addr`.
104+
while (target->wait_addr == (addr | NOTIFY_BIT)) {
105+
emscripten_futex_wake((void*)addr, INT_MAX);
106+
sched_yield();
107+
}
108+
```
91109
92110
### 4. Handling the Race Condition
93-
The "Lost Wakeup" race is handled by the combination of:
94-
- The waiter double-checking `wait_reasons` after publishing its `waiting_on_address`.
95-
- The waker looping `atomic.wake` until the waiter increments its `wait_counter`.
96-
97-
Even if the waker's first `atomic.wake` occurs after the waiter's double-check
98-
but *before* the waiter actually enters the `atomic.wait` instruction, the waker
99-
will continue to loop and call `atomic.wake` again. The subsequent call(s) will
100-
successfully wake the waiter once it is actually sleeping.
101-
102-
Multiple wakers can safely call this logic simultaneously; they will all exit
103-
the loop as soon as the waiter acknowledges the wake by incrementing the
104-
counter.
105-
106-
### 5. Overlapping and Spurious Wakeups
107-
The design must handle cases where "real" wakeups (triggered by the application) and "side-channel" wakeups (cancellation/mailbox) occur simultaneously.
108-
109-
1. **Spurious Wakeups for Other Threads**: If multiple threads are waiting on the same address (e.g., a shared mutex), a side-channel `atomic_wake(addr, 1)` targeted at Thread A might be delivered by the kernel to Thread B.
110-
- **Thread B's response**: It will wake up, increment its `wait_counter`, see that its `wait_reasons` are empty, and return `0` to its caller.
111-
- **Thread C (the waker)**: It will see that Thread A's `wait_counter` has *not* changed and `waiting_on_address` is still `addr`. It will therefore continue its loop and call `atomic_wake` again until Thread A is finally woken.
112-
- **Result**: Thread B experiences a "spurious" wakeup. This is acceptable and expected behavior for futex-based synchronization.
113-
2. **Handling Side-Channel Success**: If Thread A is woken by the side-channel, it handles the event and returns `0`. The user's code will typically see that its own synchronization condition is not yet met and immediately call `emscripten_futex_wait` again. This effectively "resumes" the wait from the user's perspective while having allowed the side-channel event to be processed.
114-
3. **No Lost "Real" Wakeups**: By returning to the caller whenever `atomic.wait` returns `OK`, we ensure that we never miss or swallow a real application-level `atomic.wake`.
115-
116-
### 6. Counter Wrap-around
117-
The `wait_counter` is a `uint32_t` and will wrap around to zero after $2^{32}$ wakeups. This is safe because:
118-
1. **Impossibility of Racing**: For the waker to "miss" a wake-up due to wrap-around, the waiter would have to wake up and re-enter a sleep state exactly $2^{32}$ times in the very brief window between the waker's `atomic_wake` and its subsequent check of `wait_counter`. Even at extreme wakeup frequencies (e.g., 1 million per second), this would take over an hour.
119-
2. **Address Change Check**: The waker loop also checks `target->waiting_on_address != target_addr`. If the waiter wakes up and either stops waiting or starts waiting on a *different* address, the waker will exit the loop regardless of the counter value.
120-
121-
### 6. Benefits
122-
- **Lower Power Consumption**: Threads can sleep indefinitely (or for the full duration of a user-requested timeout) without periodic wakeups.
123-
- **Lower Latency**: Mailbox events and cancellation requests are processed immediately rather than waiting for the next 1ms or 100ms tick.
124-
- **Simpler Loop**: The complex logic for calculating remaining timeout slices in `emscripten_futex_wait` is removed.
111+
The protocol handles the "Lost Wakeup" race by having the waker loop until the
112+
waiter clears its `wait_addr`. If the waker sets the `NOTIFY_BIT` just before
113+
the waiter enters `atomic.wait`, the `atomic_wake` will be delivered once the
114+
waiter is asleep. If the waiter wakes up for any reason (timeout, real wake, or
115+
side-channel wake), its `atomic_exchange` will satisfy the waker's loop
116+
condition.
117+
118+
## Benefits
119+
120+
- **Lower Power Consumption**: Threads can sleep indefinitely (or for the full duration of a user-requested timeout) without periodic wakeups.
121+
- **Lower Latency**: Mailbox events and cancellation requests are processed immediately rather than waiting for the next 1ms or 100ms tick.
122+
- **Simpler Loop**: The complex logic for calculating remaining timeout slices in `emscripten_futex_wait` is removed.
125123
126124
## Alternatives Considered
127-
- **Signal-based wakeups**: Not currently feasible in Wasm as signals are not
128-
implemented in a way that can interrupt `atomic.wait`.
129-
- **A single global "wake-up" address per thread**: This would require the
130-
waiter to wait on *two* addresses simultaneously (the user's futex and its
131-
own wakeup address), which `atomic.wait` does not support. The proposed
132-
design works around this by having the waker use the *user's* futex address.
125+
- **Signal-based wakeups**: Not currently feasible in Wasm as signals are not
126+
implemented in a way that can interrupt `atomic.wait`.
127+
- **A single global "wake-up" address per thread**: This would require the
128+
waiter to wait on *two* addresses simultaneously (the user's futex and its
129+
own wakeup address), which `atomic.wait` does not support. The proposed
130+
design works around this by having the waker use the *user's* futex address.
133131
134132
## Security/Safety Considerations
135-
- The `waiting_on_address` must be managed carefully to ensure wakers don't
136-
call `atomic.wake` on stale addresses. The `wait_counter` and clearing the
137-
address upon wake mitigate this.
138-
- The waker loop should have a reasonable fallback (like a yield) to prevent a
139-
busy-wait deadlock if the waiter is somehow prevented from waking up (though
140-
`atomic.wait` is generally guaranteed to wake if `atomic.wake` is called).
133+
- **The `wait_addr` must be managed carefully** to ensure wakers don't
134+
call `atomic.wake` on stale addresses. Clearing the address upon wake
135+
mitigates this.
136+
- **The waker loop should have a reasonable fallback** (like a yield) to prevent a
137+
busy-wait deadlock if the waiter is somehow prevented from waking up (though
138+
`atomic.wait` is generally guaranteed to wake if `atomic.wake` is called).

docs/design/README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,19 @@
33
This directory contains design documents for emscripten features and major
44
changes/refactors.
55

6-
We are experimenting with keeping these document here under source control with
6+
We are experimenting with keeping these documents here under source control with
77
the hope that this will increase understandability of the codebase. This has
8-
some advantages over doing all planning in Google Docs or GitHub issues. For
9-
example, it allows us to track the history of designs and it allows them to be
10-
searchable using standard tools like `git grep`.
8+
some advantages over doing all our planning in Google Docs or GitHub issues.
9+
For example, it allows us to track the history of designs and it allows them to
10+
be searchable using standard tools like `git grep`.
11+
12+
## Document Format
13+
14+
Each document in this directory should be a markdown file. At the top of each
15+
document should be a `Status` which can be either `Draft`, `Accepted`,
16+
`Completed`.
17+
18+
When a document is marked as `Completed` it should also be updated such that
19+
it is clear the work has been done, and is now in the past. For example,
20+
phrases such as "The current behavior" should be replaced with "The previous
21+
behavior".

0 commit comments

Comments
 (0)