docs: define memory and threading model
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
[Back to Manual](index.md)
|
||||
|
||||
For the proposed long-term ownership and OS-thread semantics, including freely aliasable
|
||||
`mut lent` borrows, see [Memory Model and Threading Design](../memory-model-and-threading-design.md).
|
||||
|
||||
Peon manages heap-allocated values using **generational references** -- a scheme that
|
||||
provides memory safety without a garbage collector and without the full complexity of
|
||||
Rust's borrow checker.
|
||||
|
||||
376
docs/memory-model-and-threading-design.md
Normal file
376
docs/memory-model-and-threading-design.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# Memory Model and Threading Design
|
||||
|
||||
Status: proposed design direction.
|
||||
|
||||
This document defines the intended relationship between ownership, freely aliasable
|
||||
borrows, deterministic destruction, and OS-thread concurrency in Peon. It complements
|
||||
the user-facing [memory model](manual/memory-model.md) and the existing
|
||||
[structured concurrency plan](structured-concurrency-plan.md).
|
||||
|
||||
## Goals
|
||||
|
||||
Peon's memory model should provide:
|
||||
|
||||
- no tracing garbage collector by default
|
||||
- deterministic destruction of owned values
|
||||
- exactly one owning `ref T` handle for each managed allocation
|
||||
- lightweight non-owning borrows without Rust-style alias exclusivity
|
||||
- any number of `mut lent T` aliases in sequential code
|
||||
- scoped `mut lent T` borrows that may cross thread boundaries
|
||||
- memory safety even when programmers choose nondeterministic concurrent mutation
|
||||
- no atomic or locking overhead in ordinary single-threaded code
|
||||
- explicit costs for shared ownership and synchronization
|
||||
|
||||
An optional cycle collector may eventually be provided for explicit shared-ownership
|
||||
graphs. It must not run in the background by default.
|
||||
|
||||
## Core ownership model
|
||||
|
||||
### Plain values
|
||||
|
||||
Plain `T` values use inline storage. Values implementing `ImplicitCopy` are copied by
|
||||
ordinary assignment and argument passing. Other values move by default and are destroyed
|
||||
at their compiler-determined ownership boundary.
|
||||
|
||||
### Unique managed references
|
||||
|
||||
`ref T` is a unique, affine owning handle. At most one owning handle for an allocation
|
||||
may exist at a time.
|
||||
|
||||
- Assignment, argument passing, capture, and return transfer the handle unless an
|
||||
operation explicitly constructs an independent value.
|
||||
- Moving a `ref T` invalidates the source handle.
|
||||
- `clone(refValue)` creates a fresh allocation and clones the referent; it does not create
|
||||
a second owner of the original allocation.
|
||||
- Destruction of the sole owner destroys the referent and releases its allocation.
|
||||
|
||||
Owner uniqueness is a frontend invariant. Random generation IDs and runtime checks can
|
||||
detect stale access, but cannot establish unique ownership.
|
||||
|
||||
If Peon later provides shared ownership, it should use distinct explicit types:
|
||||
|
||||
- `Rc[T]` for non-atomic, thread-local shared ownership
|
||||
- `Arc[T]` for atomic, cross-thread shared ownership
|
||||
- `Weak[T]` for non-owning references into an `Rc` or `Arc` allocation
|
||||
|
||||
The ordinary `ref T` type must not silently become reference-counted or shared.
|
||||
|
||||
## Freely aliasable borrows
|
||||
|
||||
`lent T` and `mut lent T` are non-owning views. They never destroy their referent.
|
||||
|
||||
Unlike Rust, Peon does not impose the rule "many immutable borrows or one mutable
|
||||
borrow." Any number of mutable and immutable borrows may refer to overlapping storage:
|
||||
|
||||
```peon
|
||||
var owner = new(int64);
|
||||
let a = mut borrow(owner);
|
||||
let b = mut borrow(owner);
|
||||
|
||||
a[] = 1;
|
||||
b[] = 2;
|
||||
```
|
||||
|
||||
In sequential code, operations through these aliases occur in ordinary program order.
|
||||
This is memory-safe as long as the owner remains alive and the referenced storage remains
|
||||
stable.
|
||||
|
||||
The performance consequence is that the compiler generally cannot assume `mut lent`
|
||||
parameters are exclusive. It must not attach C `restrict`-like or LLVM `noalias`
|
||||
semantics unless a separate API explicitly promises exclusivity.
|
||||
|
||||
An optional exclusive-borrow type or parameter annotation may be added later for
|
||||
specialized high-performance APIs, but it is not the default meaning of `mut lent`.
|
||||
|
||||
## Borrow lifetime and provenance
|
||||
|
||||
Freely aliasable borrows still require lifetime checking. The compiler must prevent a
|
||||
borrow from outliving or invalidating its storage.
|
||||
|
||||
Provenance analysis must cover all escape sinks, including:
|
||||
|
||||
- function returns
|
||||
- assignment into globals or longer-lived object fields
|
||||
- escaping closures
|
||||
- generator and async frames
|
||||
- task and thread arguments
|
||||
- storage in containers whose lifetime exceeds the owner
|
||||
|
||||
When a borrow is live, operations that can invalidate its storage must be rejected or
|
||||
delayed. Depending on the storage kind, these include:
|
||||
|
||||
- destroying or moving the owner
|
||||
- reallocating a collection's backing storage
|
||||
- replacing an owner cell
|
||||
- unmapping or recycling the allocation
|
||||
|
||||
Runtime generation checks remain useful as hardening, for dynamically checked weak
|
||||
references, and where static provenance is deliberately incomplete. A random 64-bit
|
||||
generation is probabilistic stale-reference detection, not a proof of ownership or a
|
||||
substitute for a valid metadata-lifetime policy.
|
||||
|
||||
## Crossing thread boundaries
|
||||
|
||||
### Structured thread scopes
|
||||
|
||||
Borrows may cross threads only through a structured thread scope whose children are
|
||||
joined before the scope exits:
|
||||
|
||||
```peon
|
||||
thread scope workers {
|
||||
spawn worker(mut borrow(value));
|
||||
spawn worker(mut borrow(value));
|
||||
}
|
||||
```
|
||||
|
||||
For the lifetime of `workers`:
|
||||
|
||||
- `value` and its backing storage outlive every child
|
||||
- the owner cannot be moved or destroyed
|
||||
- relocatable backing storage cannot be reallocated
|
||||
- child borrows cannot escape the scope
|
||||
|
||||
The compiler should prove these constraints from provenance and the lexical scope. When
|
||||
the owner is dynamic and static proof is unavailable, the runtime may pin it once when
|
||||
the borrow enters the thread scope and unpin it after join. It should not pin and unpin
|
||||
the owner on every dereference.
|
||||
|
||||
Structured lifetime safety does not by itself synchronize access to the payload.
|
||||
|
||||
### Transfer versus sharing
|
||||
|
||||
Moving a uniquely owned value into a child transfers it to that thread. Once transferred,
|
||||
ordinary accesses remain non-atomic because no other safe handle can access it.
|
||||
|
||||
Borrowing the same value into multiple children shares it. Shared immutable reads are
|
||||
safe when the referent cannot be mutated for the duration of the scope. Shared mutation
|
||||
uses one of the access models below.
|
||||
|
||||
## Concurrent access models
|
||||
|
||||
Peon must not lower unsynchronized conflicting accesses to ordinary C loads and stores
|
||||
while claiming memory safety. A C data race is undefined behavior and can cause memory
|
||||
corruption, not merely a nondeterministic result.
|
||||
|
||||
Peon therefore distinguishes ordinary shared storage, atomic storage, and representations
|
||||
that are explicitly safe to race on.
|
||||
|
||||
### Ordinary `T`
|
||||
|
||||
Multiple cross-thread `mut lent T` handles may exist, but conflicting accesses to an
|
||||
ordinary `T` require synchronization such as:
|
||||
|
||||
- `Mutex[T]` or `RwLock[T]`
|
||||
- channels or ownership transfer
|
||||
- actor isolation
|
||||
- a user-defined internally synchronized abstraction
|
||||
|
||||
Unsynchronized conflicting access to ordinary storage is not safe Peon. It must either
|
||||
be rejected by the relevant API or require an explicit `unsafe` block. It must not be
|
||||
silently emitted as a racy C access.
|
||||
|
||||
### `Atomic[T]`
|
||||
|
||||
`Atomic[T]` provides explicit operations and memory orderings for supported scalar or
|
||||
pointer-sized values:
|
||||
|
||||
```peon
|
||||
let counter: mut lent Atomic[int64] = ...;
|
||||
counter.fetchAdd(1, relaxed);
|
||||
```
|
||||
|
||||
Atomic operations may still participate in logical races, but they do not create a
|
||||
language-level data race. Relaxed atomics should be available when the programmer only
|
||||
needs indivisible access rather than inter-thread ordering.
|
||||
|
||||
### `shared mut lent T`
|
||||
|
||||
Peon may provide an explicit shared view for ordinary-looking, implicitly atomic access:
|
||||
|
||||
```peon
|
||||
let shared = share(mut borrow(counter));
|
||||
```
|
||||
|
||||
Loads and stores through `shared mut lent T` use atomic operations. The view itself is
|
||||
cheaply copyable and may be sent to multiple threads.
|
||||
|
||||
This facility is only sound for types with a race-safe representation. A compiler-known
|
||||
`RaceSafe` marker should be derived conservatively.
|
||||
|
||||
Likely `RaceSafe` types include:
|
||||
|
||||
- integers and booleans with supported atomic widths
|
||||
- suitable floating-point representations when supported by the target
|
||||
- pointer-sized tokens with explicitly atomic access
|
||||
- structures whose independently accessible fields are all `RaceSafe`
|
||||
|
||||
Types that are not automatically `RaceSafe` include:
|
||||
|
||||
- strings, sequences, and slices represented by multiple related words
|
||||
- generational references represented by `(ptr, owner, generation)`
|
||||
- tagged unions whose tag and payload can be observed inconsistently
|
||||
- types with validity invariants spanning multiple independently mutated fields
|
||||
- values wider than the target can atomically load or store as a unit
|
||||
|
||||
Even a structure of `RaceSafe` fields offers only per-field atomicity. It does not provide
|
||||
a coherent snapshot or preserve application-level invariants across fields. Such
|
||||
invariants still require a lock, a sequence lock, immutable copy-and-swap, or another
|
||||
purpose-built abstraction.
|
||||
|
||||
## Thread eligibility
|
||||
|
||||
Peon needs compiler-known equivalents of the following properties:
|
||||
|
||||
- `Send`: ownership of a value may be transferred to another thread
|
||||
- `Sync`: an immutable borrow may be shared between threads
|
||||
- `RaceSafe`: supported accesses through a shared mutable view have defined atomic
|
||||
semantics
|
||||
|
||||
These should be marker properties with no runtime representation. They are derived from
|
||||
the full transitive contents of a type and may only be implemented manually through an
|
||||
`unsafe` mechanism.
|
||||
|
||||
Suggested defaults:
|
||||
|
||||
- plain scalar values are `Send` and `Sync`
|
||||
- a value object is `Send` or `Sync` when all of its fields are
|
||||
- unique `ref T` is `Send` when `T` is `Send`
|
||||
- a moved unique owner does not require atomic access in its destination thread
|
||||
- `lent T` may cross a scoped thread boundary when `T` is `Sync` and provenance is valid
|
||||
- freely aliasable `mut lent T` may cross a scoped thread boundary, but payload access
|
||||
must follow the concurrent access models above
|
||||
- local `Rc[T]`, raw pointers, unsynchronized FFI handles, and unsafe closure captures do
|
||||
not derive thread eligibility automatically
|
||||
|
||||
## `#pragma[thread]` and call propagation
|
||||
|
||||
`#pragma[thread]` means that a named function is eligible to execute in an OS-threaded
|
||||
context. It is a semantic effect, not merely a request to select atomic allocator helpers.
|
||||
|
||||
Thread context propagates transitively through calls, but users should not have to place
|
||||
the pragma on every helper.
|
||||
|
||||
When a threaded function calls an otherwise ordinary function, the compiler should:
|
||||
|
||||
1. Check the callee and its arguments under the threaded effect.
|
||||
2. Reuse the ordinary implementation if its behavior and lowering are context-independent.
|
||||
3. Emit a threaded specialization if reference, shared-access, or runtime lowering differs.
|
||||
4. Reject the call if the callee uses thread-local-only state, unsynchronized mutable
|
||||
globals, thread-unsafe FFI, or another operation forbidden in a threaded context.
|
||||
|
||||
The threaded effect must be present in function and closure types so indirect calls cannot
|
||||
bypass these checks. Closures used as thread entry points must have thread-eligible
|
||||
captures.
|
||||
|
||||
The compiler should avoid duplicating functions that contain only pure value computation
|
||||
or otherwise generate identical code in both contexts. Specialization is needed only
|
||||
where behavior or runtime operations differ.
|
||||
|
||||
### Globals
|
||||
|
||||
Annotating an ordinary mutable global with `#pragma[thread]` does not make its payload
|
||||
safe to access concurrently. The language should distinguish:
|
||||
|
||||
- thread-local globals, with one instance per OS thread
|
||||
- shared immutable globals
|
||||
- shared `Atomic`, locked, or internally synchronized globals
|
||||
|
||||
An annotation may grant visibility from threaded code, but it cannot substitute for a
|
||||
synchronization or representation rule.
|
||||
|
||||
## Runtime implications
|
||||
|
||||
The common paths should have the following costs:
|
||||
|
||||
| Operation | Intended cost |
|
||||
|-----------|---------------|
|
||||
| Owned local dereference | Raw pointer access; no atomic operation |
|
||||
| Proven local `lent` dereference | Raw pointer access or one hoisted validity check |
|
||||
| Local allocation | Thread-local allocator cache; no global lock in the common path |
|
||||
| Move into another thread | Ownership transfer; normally no runtime synchronization |
|
||||
| Borrow into structured threads | Static proof, or one pin per boundary crossing |
|
||||
| Immutable scoped sharing | No per-dereference synchronization |
|
||||
| `Arc` clone/drop | Atomic strong-count operation |
|
||||
| `Weak` upgrade | Atomic lifetime validation |
|
||||
| `Atomic` or shared-view access | Explicit atomic operation |
|
||||
| Locked mutation | Explicit synchronization operation |
|
||||
|
||||
The existing threaded open protocol, which atomically pins an owner for each dereference
|
||||
and records that pin in thread-local storage, should not be the long-term common path.
|
||||
Lifetime protection belongs at ownership and structured-concurrency boundaries wherever
|
||||
possible.
|
||||
|
||||
Owner metadata also needs a deterministic lifetime rule. Acceptable strategies include:
|
||||
|
||||
- reclaiming it with the owner when static analysis proves no dynamic stale handles exist
|
||||
- weak-counting the metadata when `Weak` or other dynamic checked handles exist
|
||||
- retaining a bounded allocator arena with an explicit reuse and exhaustion policy
|
||||
|
||||
Keeping all metadata resident forever is acceptable as an early prototype strategy, not
|
||||
as the final memory model for long-running programs.
|
||||
|
||||
## Cycles and optional collection
|
||||
|
||||
Unique `ref` ownership should make ordinary ownership graphs acyclic by construction.
|
||||
Explicit `Rc` and `Arc` graphs may form cycles and should use `Weak` edges to break them.
|
||||
|
||||
If Peon later provides cycle collection:
|
||||
|
||||
- it applies only to explicit shared-ownership graphs
|
||||
- it is disabled by default
|
||||
- it runs synchronously when requested or at an explicitly configured safe point
|
||||
- it does not run as an implicit background service
|
||||
- destructor ordering and interaction with concurrent graph mutation must be specified
|
||||
|
||||
A concurrent general-purpose cycle collector is not required for the initial threading
|
||||
model.
|
||||
|
||||
## Safety guarantee
|
||||
|
||||
Safe Peon should guarantee:
|
||||
|
||||
- use-after-free and double-free prevention
|
||||
- deterministic destruction of uniquely owned values
|
||||
- valid lifetimes for borrows crossing structured thread scopes
|
||||
- freely aliasable `mut lent` handles
|
||||
- defined concurrent mutation through `Atomic`, `RaceSafe` shared views, or synchronized
|
||||
abstractions
|
||||
- no hidden tracing collector or background memory-management work
|
||||
|
||||
Peon does not guarantee deterministic application results when multiple threads perform
|
||||
logically racing atomic operations. It does guarantee that such supported races do not
|
||||
become C undefined behavior or corrupt the language runtime.
|
||||
|
||||
Unsynchronized conflicting access to an ordinary non-atomic representation is outside
|
||||
safe Peon and requires an explicit `unsafe` boundary.
|
||||
|
||||
## Prior art
|
||||
|
||||
- Rust separates thread transfer and sharing with
|
||||
[`Send` and `Sync`](https://doc.rust-lang.org/nomicon/send-and-sync.html), and separates
|
||||
local `Rc` from atomic [`Arc`](https://doc.rust-lang.org/std/sync/struct.Arc.html).
|
||||
- Rust's [scoped threads](https://doc.rust-lang.org/std/thread/fn.scope.html) demonstrate
|
||||
how lexical joining permits threads to borrow non-static storage safely.
|
||||
- Swift's [`Sendable`](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0302-concurrent-value-and-concurrent-closures.md)
|
||||
provides a compiler-known concurrency-domain boundary.
|
||||
- Pony's [reference capabilities](https://tutorial.ponylang.io/reference-capabilities/reference-capabilities.html)
|
||||
distinguish isolated transfer, immutable sharing, and mutable access rights.
|
||||
- Nim's [ARC/ORC memory managers](https://nim-lang.org/docs/mm.html) demonstrate
|
||||
deterministic reference counting with cycle collection either omitted or provided as
|
||||
a separate strategy.
|
||||
- Koka's [Perceus](https://koka-lang.github.io/koka/doc/book.html#why-perceus) demonstrates
|
||||
compiler optimization of deterministic reference counting and allocation reuse under
|
||||
strong cycle and sharing constraints.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Final syntax for structured OS-thread scopes and shared mutable views.
|
||||
- Whether `RaceSafe` is user-visible or only appears in diagnostics and generic bounds.
|
||||
- Which scalar widths and floating-point types are atomically supported on each target.
|
||||
- Whether shared-view field accesses are automatically atomic or require fields to be
|
||||
declared `Atomic` explicitly.
|
||||
- The exact unsafe boundary for unsynchronized ordinary cross-thread access.
|
||||
- How threaded effects participate in overload resolution and generic specialization.
|
||||
- Whether dynamic owner pinning blocks destruction, reports an error, or is impossible
|
||||
outside runtime-internal weak-reference operations.
|
||||
- The final metadata reclamation strategy for dynamically checked stale handles.
|
||||
Reference in New Issue
Block a user