Part 2: Mutex in Dart — 3 Implementations
← All Articles

Mutex in Dart — 3 Implementations

Three approaches to async serialization. We'll build each one, find the flaws, and land on the optimal choice.

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 _task as prior, creates a new Future, awaits prior, then runs action()
_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:

  1. Minimal memory — one Completer + one MutexTask per task
  2. Safe API — capability-based unlock eliminates a class of bugs
  3. Predictable GC — completed tasks collected immediately
  4. Cross-platform — works on web (no dart:io)
  5. 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.