Skip to main content

Overview

Join and Await are synchronization primitives that allow threads to wait for other threads to complete their execution. They enable expressing dependencies between threads, where one thread cannot proceed until other threads finish.

When to Use Join/Await

Task Dependencies

When task B cannot start until task A completes (e.g., construction phases)

Result Aggregation

Waiting for multiple worker threads before combining their results

Fork-Join Parallelism

Spawning parallel tasks and waiting for all to complete

Pipeline Stages

Sequential pipeline where each stage depends on previous completions

Join vs Await

1

join(target)

Wait for a single specific thread to finish
2

awaitAll(targets)

Wait for multiple threads to all finish
Difference from barriers: Barriers synchronize threads at a checkpoint but threads continue. Join/await wait for threads to terminate (reach END).

How It Works

The JoinManager tracks:
  • Finished threads: Set of thread names that have reached END
  • Join waiters: Map of target → list of threads waiting for that target
  • Await waiters: List of threads waiting for multiple targets

Lifecycle

1

Thread calls join/awaitAll

Checks if target(s) already finished
2

If not finished

Thread blocks and is added to appropriate waiting list
3

Target thread finishes

Calls END instruction, manager marks it finished
4

Wake up waiters

All threads waiting for this target are unblocked

Implementation

Here’s the complete join manager implementation from the simulator:
source/js/core/joinManager.js
// Gestor de sincronizacion tipo join/await entre hilos.
export class JoinManager {
  constructor() {
    // Nombres de hilos que ya terminaron (END ejecutado).
    this.finished = new Set();
    // Mapa: objetivo -> lista de hilos esperando join(objetivo).
    this.joinWaiters = new Map();
    // Lista de hilos esperando await sobre varios objetivos.
    this.awaitAllWaiters = [];
  }

  // join(target): true si el objetivo ya termino, false si debe esperar.
  join(thread, targetName) {
    if (this.finished.has(targetName)) return true;

    const queue = this.joinWaiters.get(targetName) ?? [];
    if (!queue.includes(thread)) queue.push(thread);
    this.joinWaiters.set(targetName, queue);
    return false;
  }

  // awaitAll(targets): true si todos terminaron, false si debe esperar.
  awaitAll(thread, targets) {
    const safeTargets = Array.from(new Set(targets.filter(Boolean)));
    const allDone = safeTargets.every((target) => this.finished.has(target));
    if (allDone) return true;

    const alreadyRegistered = this.awaitAllWaiters.some((entry) => entry.thread === thread);
    if (!alreadyRegistered) {
      this.awaitAllWaiters.push({ thread, targets: safeTargets });
    }
    return false;
  }

  // Marca hilo terminado y despierta hilos que dependian de el.
  markFinished(thread) {
    this.finished.add(thread.name);
    const awakened = [];

    // 1) Despierto todos los joiners que esperaban este hilo.
    const joiners = this.joinWaiters.get(thread.name) ?? [];
    this.joinWaiters.delete(thread.name);
    joiners.forEach((waitingThread) => {
      awakened.push(waitingThread);
    });

    // 2) Reviso awaitAll y despierto los que ya cumplieron todos sus objetivos.
    const pending = [];
    this.awaitAllWaiters.forEach((entry) => {
      const ready = entry.targets.every((target) => this.finished.has(target));
      if (ready) awakened.push(entry.thread);
      else pending.push(entry);
    });
    this.awaitAllWaiters = pending;

    return awakened;
  }
}

Join Operation

The join() method waits for a single thread:
source/js/core/joinManager.js
join(thread, targetName) {
  if (this.finished.has(targetName)) return true;

  const queue = this.joinWaiters.get(targetName) ?? [];
  if (!queue.includes(thread)) queue.push(thread);
  this.joinWaiters.set(targetName, queue);
  return false;
}
If target is in the finished set, return immediately:
if (this.finished.has(targetName)) return true;
Add thread to the join waiters for this target:
const queue = this.joinWaiters.get(targetName) ?? [];
if (!queue.includes(thread)) queue.push(thread);
this.joinWaiters.set(targetName, queue);
return false;
Data structure: Map from target name → array of waiting threads
joinWaiters = {
  "Thread-A": [Thread1, Thread2],
  "Thread-B": [Thread3]
}

AwaitAll Operation

The awaitAll() method waits for multiple threads:
source/js/core/joinManager.js
awaitAll(thread, targets) {
  const safeTargets = Array.from(new Set(targets.filter(Boolean)));
  const allDone = safeTargets.every((target) => this.finished.has(target));
  if (allDone) return true;

  const alreadyRegistered = this.awaitAllWaiters.some((entry) => entry.thread === thread);
  if (!alreadyRegistered) {
    this.awaitAllWaiters.push({ thread, targets: safeTargets });
  }
  return false;
}
Remove duplicates and null/undefined values:
const safeTargets = Array.from(new Set(targets.filter(Boolean)));
If all targets are in the finished set, return immediately:
const allDone = safeTargets.every((target) => this.finished.has(target));
if (allDone) return true;
Add thread to await waiters with its target list:
const alreadyRegistered = this.awaitAllWaiters.some(
  (entry) => entry.thread === thread
);
if (!alreadyRegistered) {
  this.awaitAllWaiters.push({ thread, targets: safeTargets });
}
return false;
Data structure: Array of objects
awaitAllWaiters = [
  { thread: Architect, targets: ["Techador", "Instalador"] },
  { thread: Manager, targets: ["Worker1", "Worker2", "Worker3"] }
]

MarkFinished Operation

When a thread finishes, the manager wakes up dependent threads:
source/js/core/joinManager.js
markFinished(thread) {
  this.finished.add(thread.name);
  const awakened = [];

  // 1) Despierto todos los joiners que esperaban este hilo.
  const joiners = this.joinWaiters.get(thread.name) ?? [];
  this.joinWaiters.delete(thread.name);
  joiners.forEach((waitingThread) => {
    awakened.push(waitingThread);
  });

  // 2) Reviso awaitAll y despierto los que ya cumplieron todos sus objetivos.
  const pending = [];
  this.awaitAllWaiters.forEach((entry) => {
    const ready = entry.targets.every((target) => this.finished.has(target));
    if (ready) awakened.push(entry.thread);
    else pending.push(entry);
  });
  this.awaitAllWaiters = pending;

  return awakened;
}
Add thread name to the finished set:
this.finished.add(thread.name);
Find all threads waiting for this specific thread via join():
const joiners = this.joinWaiters.get(thread.name) ?? [];
this.joinWaiters.delete(thread.name);
joiners.forEach((waitingThread) => {
  awakened.push(waitingThread);
});
For each thread waiting via awaitAll(), check if all its targets are now finished:
const pending = [];
this.awaitAllWaiters.forEach((entry) => {
  const ready = entry.targets.every((target) => this.finished.has(target));
  if (ready) awakened.push(entry.thread);
  else pending.push(entry);
});
this.awaitAllWaiters = pending;
Key logic: Thread is awakened only when all of its awaited targets have finished.

Usage Example: House Construction

The house scenario demonstrates complex dependencies:
import { JoinManager } from "../core/joinManager.js";
import { Thread } from "../core/thread.js";
import { Instructions } from "../core/instructions.js";

const joinManager = new JoinManager();

// Foundation (no dependencies)
const foundationWorker = new Thread("Maestro-Cimientos", [
  { type: Instructions.BUILD_STAGE, stage: "Cimientos", duration: 3 },
  { type: Instructions.END },
]);

// Walls (depends on foundation)
const wallWorker = new Thread("Maestro-Paredes", [
  { type: Instructions.JOIN_THREAD, target: "Maestro-Cimientos" },
  { type: Instructions.BUILD_STAGE, stage: "Paredes", duration: 4 },
  { type: Instructions.END },
]);

// Roof (depends on walls)
const roofWorker = new Thread("Techador", [
  { type: Instructions.JOIN_THREAD, target: "Maestro-Paredes" },
  { type: Instructions.BUILD_STAGE, stage: "Techo", duration: 2 },
  { type: Instructions.END },
]);

// Installations (depends on walls)
const installationWorker = new Thread("Instalador", [
  { type: Instructions.JOIN_THREAD, target: "Maestro-Paredes" },
  { type: Instructions.BUILD_STAGE, stage: "Instalaciones", duration: 3 },
  { type: Instructions.END },
]);

// Architect (awaits roof AND installations)
const architect = new Thread("Arquitecto", [
  { type: Instructions.AWAIT_ALL, targets: ["Techador", "Instalador"] },
  { type: Instructions.COMPLETE_HOUSE },
  { type: Instructions.END },
]);

Dependency Graph

The house scenario creates this dependency graph: Critical path: Foundation → Walls → Roof/Installations → Architect

Execution Timeline

Let’s trace the house construction:

Join vs AwaitAll Behavior

// Wall worker waits for foundation only
joinManager.join(wallWorker, "Maestro-Cimientos");

// When foundation finishes:
// → Wall worker immediately unblocked

Waiting Lists Management

// Map: targetName → array of waiting threads
this.joinWaiters = new Map();

// Example state:
{
  "Maestro-Cimientos": [Maestro-Paredes],
  "Maestro-Paredes": [Techador, Instalador]
}

// When "Maestro-Paredes" finishes:
// → Both Techador and Instalador are awakened
// Array: each entry tracks a thread and its targets
this.awaitAllWaiters = [];

// Example state:
[
  {
    thread: Arquitecto,
    targets: ["Techador", "Instalador"]
  }
]

// When any target finishes, check if ALL targets done:
entry.targets.every((target) => this.finished.has(target))

Common Pitfalls

Deadlock - Circular Dependencies: If Thread A joins Thread B, and Thread B joins Thread A, both block forever:
// Thread A
joinManager.join(threadA, "Thread-B");

// Thread B  
joinManager.join(threadB, "Thread-A");

// Both threads deadlock!
Waiting for Self: A thread cannot join itself:
joinManager.join(thread, thread.name); // Would block forever!
The implementation doesn’t explicitly check for this - the caller must avoid it.
Typo in Target Name: If you misspell a thread name, the join will never complete:
joinManager.join(thread, "Maestro-Cimeintos"); // Typo!
// Thread blocks forever

Fork-Join Pattern

A common parallel programming pattern:
// Main thread
const mainThread = new Thread("Main", [
  // Fork: Spawn worker threads
  { type: Instructions.SPAWN, threads: ["Worker-1", "Worker-2", "Worker-3"] },
  
  // Join: Wait for all workers
  { type: Instructions.AWAIT_ALL, targets: ["Worker-1", "Worker-2", "Worker-3"] },
  
  // Combine results
  { type: Instructions.COMBINE_RESULTS },
  { type: Instructions.END },
]);

// Worker threads
for (let i = 1; i <= 3; i++) {
  const worker = new Thread(`Worker-${i}`, [
    { type: Instructions.COMPUTE, data: dataset[i] },
    { type: Instructions.END },
  ]);
  engine.addThread(worker);
}

Join vs Barrier

FeatureJoin/AwaitBarrier
Wait forThread terminationThread arrival at point
Threads continueNo (finished)Yes (continue execution)
SymmetricNo (waiter ≠ target)Yes (all equal)
ReusableNoYes (cyclic)
Use caseTask dependenciesPhase synchronization
// Join: Worker finishes, can't continue
worker.join("DataLoader");
// DataLoader ends, worker proceeds, DataLoader DONE

// Barrier: All arrive, all continue
barrier.arrive();
// All threads pass checkpoint and continue

Visualizing Join/Await

In the simulator (house scenario), you can observe:
  • Dependency arrows: Visual connections showing which threads wait for which
  • Blocked state: Threads showing what they’re waiting for
  • Cascade wake-ups: When one thread finishes, multiple dependent threads wake
  • Parallel execution: Roof and installations working simultaneously after walls complete

House Construction

Complex dependency graph with join and awaitAll demonstrating task coordination

Key Takeaways

1

Thread Termination

Join/await wait for threads to finish (END), not just reach a point
2

Dependency Expression

Allows expressing “B cannot start until A finishes”
3

Join: Single Target

Wait for one specific thread to complete
4

AwaitAll: Multiple Targets

Wait for ALL specified threads to complete
5

Cascade Wake-ups

One thread finishing may unblock multiple waiters
6

Prevent Deadlock

Avoid circular dependencies and joining self

See Also