In Part 1, we established why single-threadedness doesn't protect against race conditions. All three implementations below solve the same task: serializing async operations — the next one only starts after the previous one completes.
#Implementation 1: SimpleMutex — Future Chain
The Idea
Store a reference to the last launched Future. Each new operation waits for the previous one via await.
class SimpleMutex {
static final Future<void> _initial = Future<void>.value();
Future<void> _task = _initial;
bool get locked => !identical(_task, _initial);
Future<T> synchronize<T>(Future<T> Function() action) async {
final prior = _task;
final task = _task = Future<T>(() async {
if (!identical(_task, _initial)) await prior;
return action();
});
final result = await task;
if (identical(_task, task)) _task = _initial;
return result;
}
}
How It Works
_task— reference to the last Future in the chain_initial— sentinel value (completed Future) meaning "mutex is free"- Each
synchronize()remembers_taskasprior, creates a new Future, awaitsprior, then runsaction()
_task = _initial (unlocked) synchronize(A): prior = _initial, _task = Future(A) synchronize(B): prior = Future(A), _task = Future(B) synchronize(C): prior = Future(B), _task = Future(C)
Problems
1. Double scheduling via Future() constructor. Places callback in the Event Queue, not microtask. Adds a whole Event Loop tick before work starts.
2. The identical(_task, _initial) check is fragile. Between Future creation and execution, _task can change. The "am I last?" logic doesn't scale.
3. No lock()/unlock() API. Only synchronize() — can't acquire in one place and release in another.
4. No GC guarantee. The chain _initial → Future(A) → Future(B) → Future(C) keeps all intermediate Futures in memory.
#Implementation 2: QueueMutex — DoubleLinkedQueue
The Idea
An explicit queue of Completers. Each task adds its Completer to the end and waits for the previous Completer to complete.
import 'dart:collection';
class QueueMutex {
static final Future<void> _empty = Future<void>.value();
final DoubleLinkedQueue<Completer<void>> _queue =
DoubleLinkedQueue<Completer<void>>();
bool get locked => _queue.isNotEmpty;
Future<void> lock() {
final previous = _queue.lastOrNull?.future ?? _empty;
_queue.addLast(Completer<void>.sync());
return previous;
}
void unlock() {
if (_queue.isEmpty) {
assert(false, 'unlock called with no waiting tasks.');
return;
}
final completer = _queue.removeFirst();
if (completer.isCompleted) {
assert(false, 'unlock called on completed completer.');
return;
}
completer.complete();
}
Future<T> synchronize<T>(Future<T> Function() action) async {
await lock();
try {
return await action();
} finally {
unlock();
}
}
}
How It Works
lock() — find last Completer → remember its Future → add new Completer.sync() → return previous Future.
unlock() — remove first Completer → complete() → wakes the next task.
lock() #1: queue = [C1] → return _empty (instant) lock() #2: queue = [C1, C2] → return C1.future lock() #3: queue = [C1, C2, C3] → return C2.future unlock() #1: queue = [C2, C3] → C1.complete() → #2 wakes unlock() #2: queue = [C3] → C2.complete() → #3 wakes unlock() #3: queue = [] → C3.complete()
Completer Ownership
A non-obvious point: a Completer doesn't belong to the task that created it. C1 is "task #1's completion signal for task #2." Works, but less intuitive than LinkedMutex.
Problems
1. DoubleLinkedQueue overhead. Each element → _DoubleLinkedQueueEntry<T> — 4 extra fields (prev, next, element, queue) plus the Entry object.
2. Unsafe separated lock()/unlock() API. Anyone with a reference to the mutex can call unlock():
await mutex.lock();
mutex.unlock(); // OK — removes C1
mutex.unlock(); // DANGER — removes C2 (other task's!)
Asserts catch this only in debug mode. In release — silent data race.
3. dart:collection dependency. LinkedMutex needs only dart:async.
#Implementation 3: LinkedMutex — Implicit Linked List
The Idea
Each task is a _MutexTask with a Completer. Instead of an explicit queue, tasks link via await previousTask.future. Only one pointer (_head) is stored.
import 'dart:async';
class LinkedMutex {
_MutexTask? _head;
bool get locked => _head != null;
Future<void Function()> lock() async {
final prior = _head;
final node = _head = _MutexTask.sync();
if (prior != null) {
prior.next = node;
await prior.future;
}
return () {
if (node.isCompleted) return;
node.complete();
if (identical(_head, node)) _head = null;
};
}
Future<T> synchronize<T>(Future<T> Function() action) async {
final prior = _head;
final node = _head = _MutexTask.sync();
if (prior != null) {
prior.next = node;
await prior.future;
}
try {
return await action();
} finally {
node.complete();
if (identical(_head, node)) _head = null;
}
}
}
final class _MutexTask {
_MutexTask.sync() : _completer = Completer<void>.sync();
final Completer<void> _completer;
bool get isCompleted => _completer.isCompleted;
Future<void> get future => _completer.future;
void complete() => _completer.complete();
_MutexTask? next;
}
Step-by-Step Walkthrough
lock() #1: prior = null, node = Task_A → instant, return unlock_A lock() #2: prior = Task_A, node = Task_B → await Task_A.future (waiting) lock() #3: prior = Task_B, node = Task_C → await Task_B.future (waiting)
Memory:
_head → Task_C Task_A.next → Task_B.next → Task_C (running) (waiting) (waiting)
unlock_A: Task_A.complete() → wakes Task_B → nothing references Task_A → GC collects it.
unlock_C (last): Task_C.complete() → _head == Task_C → _head = null (unlocked).
Why "Linked"
Tasks connect via await prior.future — an implicit linked list:
QueueMutex (explicit): C1.prev ←→ C1 ←→ C1.next LinkedMutex (implicit): Task_A ─(await)→ Task_B ─(await)→ Task_C
No prev/next pointers — links are Future dependencies. When a task completes, GC collects it automatically.
Key Advantages
1. Capability-based unlock
lock() returns an unlock function bound to a specific task:
final unlock = await mutex.lock();
// Only the holder can unlock THEIR task
unlock(); // repeated calls are a no-op
Can't unlock someone else's task. No asserts needed.
2. Minimal allocations
Per task:
- LinkedMutex: 1 Completer + 1 Task (2 fields) = ~4 fields total
- QueueMutex: 1 Completer + 1 Entry (4 fields) = ~6 fields total
3. GC-friendly
Completed tasks automatically lose references. QueueMutex needs removeFirst().
4. Zero-overhead wake-up
Completer.sync() fires callbacks synchronously (0 ticks). For N tasks, saves N microtasks.
#Comparison Table
| Criterion | SimpleMutex | QueueMutex | LinkedMutex |
|---|---|---|---|
| Correctness | ✅ | ✅ | ✅ |
| Scheduling | Event Queue (slow) | Microtask (fast) | Microtask (fast) |
| Memory/task | ~2 objects | ~2 obj + Entry | ~2 objects |
| GC | On chain end | Explicit removeFirst | Automatic |
| lock() API | ❌ None | ⚠️ Separated | ✅ Capability-based |
| Double-unlock | N/A | ⚠️ Silent error | ✅ No-op |
| Dependencies | dart:async | dart:collection | dart:async |
#Which Mutex to Choose?
LinkedMutex is the optimal choice:
- Minimal memory — one Completer + one MutexTask per task
- Safe API — capability-based unlock eliminates a class of bugs
- Predictable GC — completed tasks collected immediately
- Cross-platform — works on web (no dart:io)
- Zero-dependency — only dart:async
This is why LinkedMutex was chosen as the Mutex in the control package — a clean reference implementation for building synchronization primitives in Dart. In Part 3, we'll see how control builds concurrency strategies on top of this Mutex, and compare them with flutter_bloc and riverpod.