In this article:
- How Isolates and the Event Loop work
- Microtask Queue vs Event Queue — priority and ordering
- Why async/await creates an illusion of parallelism
- Race conditions in a single-threaded language — real examples
- Completer: the building block of async synchronization
- Why Dart needs a Mutex
#How Dart's Thread Works
Isolate — the Unit of Execution
Each Dart Isolate is one thread with its own heap. Isolates don't share memory. Communication is only possible via messages (SendPort / ReceivePort).
Isolate (main) Isolate (worker)
┌────────────────┐ ┌────────────────┐
│ Event Loop │ │ Event Loop │
│ Heap │ │ Heap │
└───────┬────────┘ └───────┬────────┘
└─── SendPort/ReceivePort ─┘
A Flutter app runs in one main isolate. All your code — widgets, state management, business logic — executes on a single thread.
Event Loop: Two Queues, Strict Priority
The Event Loop is an infinite cycle that pulls tasks from two queues:
Microtask Queue (HIGH priority):
scheduleMicrotask()Future.then()/ Future callbacksCompleter.sync().complete()— fires synchronouslyasync/await— continuation after await
Event Queue (LOW priority):
- Timer,
Future.delayed Future()constructor- I/O callbacks, HTTP responses
- Platform events (gestures, input, rendering)
Algorithm of one iteration:
- Execute all tasks from the Microtask Queue
- Execute one task from the Event Queue
- Repeat
Microtasks have absolute priority. If microtasks endlessly generate new microtasks — the Event Queue will never be processed (UI freezes).
What Goes Where?
Microtask Queue:
scheduleMicrotask(() => print('microtask'));
Future.value(42).then((v) => print(v));
final c = Completer<void>.sync();
c.future.then((_) => print('sync completer'));
c.complete(); // fires HERE, synchronously
Event Queue:
Timer(Duration.zero, () => print('timer'));
Future.delayed(Duration.zero, () => print('delayed'));
Future(() => print('future constructor'));
Execution Order Demo
void main() {
print('1. synchronous');
Future(() => print('5. event queue'));
scheduleMicrotask(() => print('3. microtask'));
Future.value().then((_) => print('4. future.then'));
print('2. synchronous');
}
// Output:
// 1. synchronous
// 2. synchronous
// 3. microtask
// 4. future.then
// 5. event queue
Synchronous first → Microtasks → Events.
#async/await — the Illusion of Parallelism
async/await is syntactic sugar over Future. Each await is a point where the function suspends and the Event Loop can process other tasks.
Future<void> fetchData() async {
print('A: start'); // synchronous
final data = await http.get(url); // suspend here
print('B: got data'); // resumes after response
}
fetchData();
print('C: after call');
Execution order: A → C → (HTTP completes) → B
await does NOT block the thread. It frees the thread for other work. When the Future completes, the continuation goes into the Microtask Queue.
async/await as Futures
// async/await:
Future<int> calculate() async {
final a = await getA();
final b = await getB(a);
return a + b;
}
// Equivalent:
Future<int> calculate() {
return getA().then((a) =>
getB(a).then((b) => a + b));
}
Each await = .then() = the Event Loop takes control.
#Race Conditions in a Single-Threaded Language
If everything is single-threaded, where do race conditions come from?
Classic Example
int counter = 0;
Future<void> increment() async {
final value = counter; // read
await Future.delayed(Duration.zero); // yield
counter = value + 1; // write
}
void main() async {
await Future.wait([
increment(),
increment(),
increment(),
]);
print(counter); // Expected 3, got 1!
}
What happens:
Step 1 — all three read counter (0): #1: value = 0 #2: value = 0 #3: value = 0 → all suspended at await Step 2 — all three write (0 + 1): #1: counter = 1 #2: counter = 1 ← overwrite! #3: counter = 1 ← overwrite!
Between read and write there's an await. During the await, other Futures read a stale value.
Real-World Example: Cart
class CartController {
List<Product> items = [];
Future<void> addItem(Product product) async {
final currentItems = [...items]; // read
await api.addToCart(product); // await!
items = [...currentItems, product]; // write
}
}
User taps "Add" twice quickly:
addItem(A): reads [] → API call → writes [A] addItem(B): reads [] → API call → writes [B] ← lost A!
Product A is lost — addItem(B) read an empty list before addItem(A) wrote its result.
When Race Conditions DON'T Occur
// ✅ No await between read and write
void incrementSync() {
counter++;
}
// ✅ No dependency on current state
Future<void> setValue() async {
counter = await fetchFromApi();
}
// ✅ Read AFTER await
Future<void> incrementCorrectly() async {
await someOperation();
counter++; // read + write in one synchronous block
}
Rule: if there's an await between reading state and writing state — you're potentially vulnerable.
#Completer: the Foundation of Async Synchronization
Completer<T> separates the creation of a Future from its completion.
final completer = Completer<int>();
completer.future.then((value) => print('Got: $value'));
completer.complete(42); // → "Got: 42"
Completer vs Completer.sync
This difference is critical for mutex performance.
Regular Completer — callback via microtask:
final c = Completer<void>();
c.future.then((_) => print('callback'));
c.complete();
print('after');
// Output: after → callback
Completer.sync — callback fires immediately:
final cs = Completer<void>.sync();
cs.future.then((_) => print('callback'));
cs.complete();
print('after');
// Output: callback → after
Completer.sync() invokes callbacks immediately upon complete() — no Microtask Queue overhead. Saves one Event Loop tick per operation, critical for mutex chains.
If a sync callback callscomplete()on anotherCompleter.sync(), you get synchronous recursion. For linear chains (as in a mutex), this is safe.
#Why Dart Needs a Mutex
Mutex (mutual exclusion) guarantees only one async operation runs at a time.
Without Mutex: #1: read(0) ── await ── write(1) #2: read(0) ── await ── write(1) ← BAD #3: read(0) ── await ── write(1) ← BAD With Mutex: #1: read(0) ── await ── write(1) | #2: read(1) ── await ── write(2) | #3: read(2) ...
A Mutex does NOT block the thread. It queues tasks — waiting tasks await through Dart primitives while the Event Loop continues.
Basic Mutex API
abstract class Mutex {
bool get locked;
Future<void Function()> lock();
Future<T> synchronize<T>(Future<T> Function() action);
}
lock()— acquires the lock, returns an unlock functionsynchronize()— lock → execute → unlock (with try/finally)locked— check current state
Key Event Loop Invariants for Mutex
Four properties that make correct mutexes possible:
- Synchronous code is not interrupted. Between two
awaits, no other Dart code runs. awaitis a switch point. The Event Loop may execute another task.- Microtask order is predictable. FIFO.
Completer.sync().complete()fires synchronously. Future.then()runs in a microtask. Placed in the Microtask Queue on Future completion.
#What's Next
In Part 2 we'll examine three concrete Mutex implementations:
- SimpleMutex — Future chain
- QueueMutex — explicit queue via
DoubleLinkedQueue<Completer> - LinkedMutex — implicit linked list via Completers
We'll compare them by memory, performance, API safety, and GC pressure — and show why LinkedMutex is the optimal choice.