Active Objects

Part of: Reef Language Reference Last reviewed on version: 0.9.0 Status: Implemented


Table of Contents

  1. Introduction
  2. Declaring Active Objects
  3. Fields and Self
  4. Constructors (init)
  5. Destructors (finalize)
  6. Exclusive Methods
  7. Shared Methods
  8. Await (Blocking on a Condition)
  9. Background Threads (run)
  10. Thread Safety Guarantees
  11. Generic Active Objects
  12. Best Practices
  13. Common Patterns

Introduction

Active Objects are Reef's defining feature for safe, high-level concurrency: an object that guards its own state with a monitor, so callers never manage a lock by hand.

An important clarification up front, because it shapes everything else in this chapter: an Active Object does not automatically get a thread. Calling obj.method() is an ordinary call executed on the caller's thread, wrapped in a lock/unlock pair — there is no thread hop and no message queue. A dedicated background thread exists only for objects that declare run(), and that is the sole construct in the model which creates one.

A passive object (object Button extends Widget) is a different kind: inheritance and a vtable, no monitor. An AO may own a passive graph; it cannot be one. See 040_OBJECTS.md.

What Makes Active Objects Special?

  1. Automatic locking: the monitor is acquired and released around method dispatch; no manual mutex management.
  2. Compiler-enforced data-race safety: shared methods are prevented from mutating state, and exclusive methods serialize. See Thread Safety Guarantees for the precise scope — and for what is not guaranteed.
  3. Simple mental model: state lives behind one monitor, per object.
  4. Based on A2 Oberon: 20+ years of proven design.

When to Use Active Objects

Use Active Objects for:

  • Concurrent services (web servers, databases)
  • Background workers
  • Event processors
  • Stateful concurrent entities

Don't use for:

  • Simple data containers (use structs)
  • Hierarchies and virtual dispatch (use passive objects)
  • Pure computation (use functions)
  • Everything (only where concurrency needed)

Declaring Active Objects

Basic Declaration

active object Counter
    value: int

    exclusive fn get(): int
        return self.value
    end get

    exclusive proc increment()
        self.value = self.value + 1
    end increment
end Counter

Creating Instances

proc main()
    let counter = new Counter()
    counter.increment()
    println("Active Object created")
end main

What happens:

  1. Object allocated on heap
  2. Protected object header initialized
  3. init() called (if exists)
  4. run() thread started (if exists)
  5. Finalizer registered (if exists)

Fields and Self

Declaring Fields

active object Person
    name: string
    age: int
end Person

Accessing Fields with self

Inside methods, use self to access fields:

active object Counter
    value: int

    exclusive fn get(): int
        return self.value         // Read field
    end get

    exclusive proc set(v: int)
        self.value = v            // Write field
    end set
end Counter

Important:

  • Use self.field, not this.field
  • Fields are private to the Active Object
  • Only accessible within methods

Constructors (init)

Basic Constructor

active object Counter
    value: int

    init()
        self.value = 0
    end init

    exclusive fn get(): int
        return self.value
    end get
end Counter

When it runs: Immediately after allocation, before any methods or run().

Constructor with Parameters

active object Counter
    value: int
    name: string

    init(initial: int, counter_name: string)
        self.value = initial
        self.name = counter_name
    end init
end Counter

proc main()
    let c = new Counter(100, "MainCounter")
    println("Counter initialized with parameters")
end main

Output:

Counter initialized with parameters

How Constructors Work

Execution order:

  1. Object allocated (fields zero-initialized)
  2. init() called synchronously (in calling thread)
  3. Finalizer registered (if finalize() exists)
  4. run() thread started (if run() exists)
  5. Object returned to caller

Thread safety: No locking needed in init() - object not yet accessible to other threads.


Destructors (finalize)

Basic Finalizer

active object Resource
    name: string

    init(resource_name: string)
        self.name = resource_name
        print("Resource created: ")
        println(self.name)
    end init

    finalize()
        print("Resource destroyed: ")
        println(self.name)
    end finalize
end Resource

proc main()
    let r1 = new Resource("File1")
    let r2 = new Resource("File2")
    println("Resources created, will be cleaned up by GC")
end main

Output:

Resource created: File1
Resource created: File2
Resources created, will be cleaned up by GC

Note: the two Resource destroyed: lines are absent from that output on purpose. Finalizers run when the object's memory is actually reclaimed, and a short program can exit before that ever happens.

Finalizer Semantics

Important properties:

  • Not guaranteed to run. Only reclaimed objects are finalized. A program that exits promptly may never finalize anything.

  • No parameters: finalizers take no arguments.

  • Timing is tied to reclamation, not to a collection cycle. Reef sweeps lazily: a GC cycle marks live objects and stops, and memory is reclaimed incrementally afterwards, inside the allocator, when some later allocation looks for a free block. A finalizer therefore runs at the moment its block is reclaimed by an allocation — which may be many collections after the object became unreachable, or never. Do not read "runs during GC" as "runs at the end of the next GC."

  • Position is free. finalize() may be declared anywhere among the object's methods; it does not have to precede them.

  • Use for cleanup only: close files, release resources.

  • A finalizer must not allocate GC memory — an allocating finalizer DEADLOCKS the program, silently. Finalizers run inside the allocator, on the reclaim path, while the runtime holds the heap allocation lock. That lock is not recursive, so any allocation from a finalizer waits forever on a lock the same thread already holds: the program hangs with no error message, no diagnostic and no stack trace. Allocation is easy to do by accident — it is not only new. All of these allocate:

    • building a string: concatenation (a + b), string interpolation ("id=${self.id}"), and most core.str helpers that return a string;
    • creating an array or an object (new [int](n), new Thing());
    • calling stdlib functions that return a freshly built string or array;
    • writing a closure that captures anything — the closure's environment is a heap object, and so is the closure itself;
    • declaring a mut binding that a closure captures — a captured mut binding's storage is a garbage-collected cell (see Closures — mutable capture), so the declaration itself allocates, even if the closure that captures it is only built on a branch that is never taken.

    Safe things a finalizer may do: read its own fields, call functions that return scalars or booleans, compare strings, print a string it already has, and call C functions that do not allocate on the Reef heap (fclose, close, ...). If a finalizer needs a formatted message, build the string in an ordinary method and store it on the object before the object becomes garbage.

    Since 0.9 this restriction is enforced at runtime: an allocation attempted from inside a finalizer aborts the process immediately with a [Heaps] FATAL: a finalizer allocated from the GC heap diagnostic that restates the rule, instead of hanging. The compiler still does not reject an allocating finalizer — it cannot, because the allocation may be any number of calls away — so the check lives where every allocation passes: the allocator itself. The rule and the abort apply to every finalizer, on active objects and on passive objects alike.

  • A finalizer must not reach into owned passive object graphs. At finalization time the object's owner is gone and 0.9's ownership rules (see the object-model documentation once it lands) treat any such access as a violation; the owner-check harness reports it. Keep finalizers to the object's own resources.

Don't rely on finalizers for:

  • Critical cleanup — use an explicit close() method and call it
  • Guaranteed execution
  • Ordering of cleanup between objects

Exclusive Methods

What are Exclusive Methods?

Methods marked exclusive provide automatic mutual exclusion - only one thread can execute exclusive methods on the same object at a time.

Declaring Exclusive Methods

active object Database
    connections: int

    exclusive proc add_connection()
        self.connections = self.connections + 1
    end add_connection

    exclusive fn get_count(): int
        return self.connections
    end get_count
end Database

How Exclusive Methods Work

Execution:

  1. Caller invokes obj.method(args)
  2. Method wrapper locks the object (exclusive)
  3. Method body executes
  4. Object unlocked
  5. Return value (if any) returned to caller

Thread safety: The compiler guarantees no data races.

Example

active object Counter
    value: int

    init()
        self.value = 0
    end init

    exclusive proc increment()
        self.value = self.value + 1
    end increment

    exclusive fn get(): int
        return self.value
    end get
end Counter

proc main()
    let counter = new Counter()

    // These calls are thread-safe
    counter.increment()
    counter.increment()

    println("Counter incremented twice")
end main

Output:

Counter incremented twice

Shared Methods

What are Shared Methods?

Methods marked shared allow concurrent readers - multiple threads can execute shared methods simultaneously, as long as no exclusive method is running.

Declaring Shared Methods

active object Cache
    data: string

    shared fn read(): string
        return self.data       // Concurrent reads OK
    end read

    exclusive proc write(value: string)
        self.data = value      // Exclusive write
    end write
end Cache

Shared vs Exclusive

Aspect Exclusive Shared
Lock type Exclusive Shared (reader)
Concurrency One at a time Multiple concurrent
Can modify Yes No (read-only)
Use for State changes State queries

Example

active object Database
    record_count: int

    exclusive proc add_record()
        self.record_count = self.record_count + 1
    end add_record

    shared fn count(): int
        return self.record_count
    end count
end Database

proc main()
    let db = new Database()
    db.add_record()

    // count() can be called concurrently by multiple threads
    println("Database has records")
end main

Thread safety: Multiple threads can call count() simultaneously, but if any thread calls add_record(), all readers wait.

What the compiler rejects inside a shared method

Two shapes are compile errors in a shared method body, because each would break the reader-lock contract:

  1. Assigning to the object's own state (self.x = ...) — shared methods are read-only (see Thread Safety Guarantees below).
  2. Calling an exclusive method on self — the shared reader lock blocks the exclusive acquisition, so the object would deadlock waiting for itself (an unconditional hang, which is why it is rejected statically rather than left to runtime).

The exclusive-self-call rejection applies in every expression position, not just plain statements: the checker walks the entire body, so hiding the call inside a string interpolation ("${self.bump()}"), a struct literal field (P { x: self.bump() }), a map or set literal, an is/as operand, type_name/type_id/ to_map arguments, or a lambda body is rejected all the same. (Before 0.9 some of these wrapper positions escaped the check and produced the runtime deadlock — see BUG-220.) Wrapper forms that merely read state stay legal: "${self.n}" or P { x: self.n } in a shared method is fine.

As with the rest of Reef's shared/exclusive enforcement, the check is syntactic over self: a call laundered through an aliasing local is not caught (the documented BUG-113-family limitation).


Await (Blocking on a Condition)

What is await?

await <boolean-expression> blocks the calling thread inside an exclusive or shared method until the expression becomes true — Reef's monitor-style condition wait. If the condition already holds, await returns immediately; otherwise the caller waits until another thread's call to an exclusive method changes state the condition depends on and it becomes true.

active object Counter
    count: int

    init()
        self.count = 0
    end init

    exclusive proc increment()
        self.count = self.count + 1
    end increment

    exclusive proc consume()
        await self.count > 0
        self.count = self.count - 1
    end consume
end Counter

await blocks the calling thread whenever its condition is not yet true — including when the call originates on the main thread rather than from another Active Object's run()-spawned process.

Current Limitations

An await condition is a live expression. Each wake re-evaluates it against current storage: self is a live object pointer, and every named local or parameter is re-read from the parked frame (D44 / BUG-251). A mut local written by another thread (via a captured closure) is visible to the wait. Immutable parameters such as expected in await self.count == expected do not change, so re-reading them is the same value — that idiom is unchanged.

An await condition may not name a binding that is shadowed at the await site. A shadowed name is never what the author meant (D5) — it is rejected at typecheck:

await condition names 'v', which is SHADOWED at this point. An await
condition is re-evaluated on every wake; a shadowed name is never what
you meant — the wait would be either instantly true or never true.
Rename the inner binding, or read the value through 'self'.

Rename the inner binding, or make the condition depend on self — which is what almost every real await does. Unshadowed locals and parameters may be named freely.

await directly inside run() is rejected at typecheck. run()'s body runs on its own background thread but — unlike an exclusive/shared method call — never acquires the Active Object's monitor lock around itself. await requires the caller to already hold that lock (it's a monitor-style condition wait), so a bare await written lexically inside run() cannot work correctly: it would not actually block. This is caught at typecheck time with:

await cannot appear directly in run() (it executes outside the object's
monitor, so the wait would not block). Move the await into an exclusive
fn/proc and call it from run().

Workaround (the normal, sanctioned pattern): move the await into its own exclusive fn/proc and have run() reach it through a self-call — an exclusiveexclusive self-call is legal (only sharedexclusive self-calls are rejected), and that callee genuinely holds the monitor while it awaits. Exercised by the shipped example examples/parallel_hash.reef:

// From examples/parallel_hash.reef's Worker AO:
// the await lives in take() (an exclusive fn), not in run() directly;
// run() reaches it through a legal exclusive self-call.
exclusive fn take(): string
    await self.queue.length() > 0 or self.done
    if self.queue.length() == 0
        return ""
    end if
    let path = self.queue[0]
    self.queue = self.queue.remove(0)
    return path
end take

run()
    loop
        let path: string = self.take()
        if str.equals(path, "")
            break
        end if
        // ... process path
    end loop
end run

Await conditions may reference method parameters and locals directly. await self.count == expected, where expected is a method parameter (or a let-bound local), compiles and runs correctly — the generated condition function re-reads those locations alongside live self. Copying the parameter into a field first also works, and appears in some existing code, but is not required:

// Both forms are valid. The direct form is simpler:
exclusive proc wait_for(expected: int)
    await self.count == expected
end wait_for

Background Threads (run)

What is run()?

The run() method defines an active body - code that runs in a background thread for the lifetime of the object.

Declaring run()

active object Worker
    running: bool

    init()
        self.running = true
    end init

    run()
        loop
            println("Worker running...")
            if !self.running
                break
            end if
        end loop
    end run

    exclusive proc stop()
        self.running = false
    end stop
end Worker

Auto-Start

The run() method automatically starts when the object is created:

proc main()
    let worker = new Worker()
    println("Main thread continues...")
    worker.stop()
end main

This program's output is nondeterministic. run() executes on its own thread concurrently with main(), so the number of "Worker running..." lines and their position relative to "Main thread continues..." vary between runs and between machines. Do not rely on any particular interleaving — and do not use print ordering to reason about whether the background thread has started.

What is ordered is the construction sequence, which completes before new returns:

  1. Object allocated
  2. init() called (if present)
  3. run() thread started
  4. new returns to the caller

After step 4 the two threads run concurrently, with no ordering between them except what you impose through exclusive methods or await. In the example above, worker.stop() is such a synchronization point: the field write happens under the monitor, so the run() loop observes it safely — but when it observes it is still unspecified.


Thread Safety Guarantees

What Reef Guarantees

  1. No data races on Active Object state. Only one thread executes an exclusive method on a given object at a time, and shared methods are typecheck-enforced not to mutate state — that restriction is what makes concurrent shared readers safe rather than merely conventional. Attempting to assign to a field from a shared method is a compile error.
  2. Automatic locking. The monitor is acquired and released around method dispatch; there is no mutex to manage by hand and no way to forget an unlock.
  3. Memory safety for GC-managed memory. The collector prevents use-after-free for objects it manages. Raw pointers obtained inside unsafe blocks are explicitly outside that protection — see 100_UNSAFE.md.

What Reef Does Not Guarantee

Deadlock freedom is not a property of this model. Nothing prevents a cycle of Active Objects from waiting on each other — if object A's exclusive method calls into B while B's calls into A, or two objects await conditions only the other can satisfy, the result is a deadlock the compiler will not catch. In particular:

  • An await condition only becomes true because another thread's exclusive call changes the state it depends on. If no such call can ever happen, the waiter blocks forever.
  • The monitor is per-object. Holding one object's monitor while calling into a second object is where cross-object cycles come from.

Keep exclusive sections short and avoid calling into other Active Objects while holding a monitor.

How It Works

Each Active Object has one monitor, not a reader lock plus a writer lock:

  • A single locked_by owner field, published under the object's mutex.
  • Exclusive release is a directed handoff: the releasing thread publishes the next owner before signalling, so a newly-arriving caller cannot barge past a waiter that was already granted the monitor.
  • await re-checks ownership in a loop rather than assuming a single wakeup is authoritative, which is what makes it correct against spurious wakeups.
  • Recursive entry is counted, and that count is preserved across an await.

Example showing safety:

active object BankAccount
    balance: int

    init(starting_balance: int)
        self.balance = starting_balance
    end init

    exclusive proc deposit(amount: int)
        self.balance = self.balance + amount
    end deposit

    exclusive proc withdraw(amount: int)
        self.balance = self.balance - amount
    end withdraw

    shared fn get_balance(): int
        return self.balance
    end get_balance
end BankAccount

// Multiple threads can safely call deposit/withdraw
// The compiler guarantees no race conditions

Generic Active Objects

Declaring Generic AOs

active object Queue[T]
    items: [T]

    init()
        self.items = new [T](0)   // an empty [] literal can't infer T
    end init

    exclusive proc enqueue(item: T)
        self.items = self.items.append(item)
    end enqueue

    exclusive fn dequeue(): T
        let head = self.items[0]
        self.items = self.items.remove(0)
        return head
    end dequeue
end Queue

Using Generic AOs

proc main()
    let int_queue = new Queue[int]()
    let string_queue = new Queue[string]()

    println("Generic Active Objects work!")
end main

Implementation: Monomorphization generates specialized versions (Queue_int, Queue_string).


Best Practices

1. Use Shared Methods for Queries

// GOOD
shared fn get_status(): Status
    return self.status
end get_status

// AVOID (unnecessary exclusive lock)
exclusive fn get_status(): Status
    return self.status
end get_status

2. Keep Critical Sections Short

// GOOD - brief exclusive section
exclusive proc update_counter()
    self.count = self.count + 1
end update_counter

// AVOID - long-running exclusive work
exclusive proc process_data()
    // Heavy computation while holding lock - blocks other threads
end process_data

3. Use init() for Setup

// GOOD
init(config: Config)
    self.buffer_size = config.size
    self.timeout = config.timeout
end init

// AVOID - uninitialized fields
// (fields will be zero, might not be desired)

4. Don't Rely on finalize() Timing

// GOOD - explicit cleanup
proc close()
    // Clean up resources explicitly
end close

// AVOID - relying on finalize() to run promptly
finalize()
    // This may run much later or never
end finalize

Common Patterns

Worker Pattern

import collections.queue
import core.option as option

active object Worker
    tasks: queue.Queue[string]

    init()
        self.tasks = queue.create_string_queue()
    end init

    // Callers hand work in; the exclusive lock makes the enqueue atomic.
    exclusive proc submit(task: string)
        let accepted = queue.enqueue(self.tasks, task)
    end submit

    // dequeue returns Option[string] — None when the queue is empty.
    exclusive fn next_task(): option.Option[string]
        return queue.dequeue(self.tasks)
    end next_task

    run()
        loop
            let next = self.next_task()
            if option.is_none(next)
                break
            end if
            println(option.unwrap(next))
        end loop
    end run
end Worker

Three things this pattern depends on, all easy to get wrong:

  • collections.queue exposes free functions, not methods. It is queue.enqueue(self.tasks, task), not self.tasks.enqueue(task).
  • dequeue returns option.Option[T], not T — there is no sentinel for "empty". Branch on it with option.is_none / option.unwrap.
  • Queue[T] is constructed through a per-type factory (create_string_queue, create_int_queue, create_float_queue, create_bool_queue, create_int_queue_cap), because the module cannot allocate storage for an arbitrary T. A Queue of some custom struct is therefore not constructible today — queue the key (a path, an id) and keep the payload elsewhere.

Monitor Pattern

active object Monitor
    state: State

    shared fn observe(): State
        return self.state
    end observe

    exclusive proc update(new_state: State)
        self.state = new_state
    end update
end Monitor

Producer-Consumer Pattern

active object Buffer[T]
    items: [T]

    init()
        self.items = new [T](0)
    end init

    exclusive proc put(item: T)
        self.items = self.items.append(item)
    end put

    exclusive fn get(): T
        let head = self.items[0]
        self.items = self.items.remove(0)
        return head
    end get

    run()
        // Background processing
    end run
end Buffer

Implementation Notes

Based on A2 Oberon

Reef's Active Objects are derived from Active Oberon (A2), a language with 20+ years of production use:

  • Process-based concurrency
  • Protected objects with locks
  • Proven scalability and reliability

Performance Characteristics

  • Method calls: a direct function call plus a lock acquire/release. There is no process creation, thread hop, or message marshaling per call — an uncontended call costs about what a mutex-guarded call costs in C.
  • Lock operations: mutex-based, with a directed handoff on release.
  • Memory: one thread per Active Object that declares run(). Objects without run() carry no thread of their own.
  • Suitable for: guarding shared mutable state; medium to coarse-grained critical sections. Contention is the cost that matters, so keep exclusive sections short.

Comparison to Other Models

Feature Active Objects Actors Threads + Mutexes
Data-race safety Compiler-enforced By isolation Programmer's responsibility
Deadlock safety Not prevented Not prevented Not prevented
Locking Implicit, per object None (messages) Explicit and manual
Best for Shared mutable state under a monitor Async message systems Low-level control

Data-race safety is compiler-enforced in the specific sense described under Thread Safety Guarantees: shared methods cannot mutate, and exclusive methods serialize. No model in this table prevents deadlock, Active Objects included.


Previous: 040_OBJECTS.md Next: 050_TRAITS.md Index: 000_INDEX.md