Active Objects
Part of: Reef Language Reference Last reviewed on version: 0.9.0 Status: Implemented
Table of Contents
- Introduction
- Declaring Active Objects
- Fields and Self
- Constructors (init)
- Destructors (finalize)
- Exclusive Methods
- Shared Methods
- Await (Blocking on a Condition)
- Background Threads (run)
- Thread Safety Guarantees
- Generic Active Objects
- Best Practices
- 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?
- Automatic locking: the monitor is acquired and released around method dispatch; no manual mutex management.
- Compiler-enforced data-race safety:
sharedmethods are prevented from mutating state, andexclusivemethods serialize. See Thread Safety Guarantees for the precise scope — and for what is not guaranteed. - Simple mental model: state lives behind one monitor, per object.
- 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:
- Object allocated on heap
- Protected object header initialized
init()called (if exists)run()thread started (if exists)- 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, notthis.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:
- Object allocated (fields zero-initialized)
- init() called synchronously (in calling thread)
- Finalizer registered (if finalize() exists)
- run() thread started (if run() exists)
- 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 mostcore.strhelpers that return a string; - creating an array or an object (
new [int](n),new Thing()); - calling stdlib functions that return a freshly built
stringor array; - writing a closure that captures anything — the closure's environment is a heap object, and so is the closure itself;
- declaring a
mutbinding that a closure captures — a capturedmutbinding'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 heapdiagnostic 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. - building a string: concatenation (
-
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:
- Caller invokes
obj.method(args) - Method wrapper locks the object (exclusive)
- Method body executes
- Object unlocked
- 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:
- Assigning to the object's own state (
self.x = ...) — shared methods are read-only (see Thread Safety Guarantees below). - Calling an
exclusivemethod onself— 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 exclusive→exclusive self-call is legal (only shared→exclusive
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:
- Object allocated
init()called (if present)run()thread startednewreturns 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
- No data races on Active Object state. Only one thread executes an
exclusivemethod on a given object at a time, andsharedmethods are typecheck-enforced not to mutate state — that restriction is what makes concurrentsharedreaders safe rather than merely conventional. Attempting to assign to a field from asharedmethod is a compile error. - 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.
- Memory safety for GC-managed memory. The collector prevents
use-after-free for objects it manages. Raw pointers obtained inside
unsafeblocks 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
awaitcondition only becomes true because another thread'sexclusivecall 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_byowner 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.
awaitre-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.queueexposes free functions, not methods. It isqueue.enqueue(self.tasks, task), notself.tasks.enqueue(task).dequeuereturnsoption.Option[T], notT— there is no sentinel for "empty". Branch on it withoption.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 arbitraryT. AQueueof 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 withoutrun()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