Suspension and cancellation.
A running Effect can suspend at a run and be resumed later, or be cancelled while it is suspended. This page covers suspension points, what a suspended frame keeps alive, local-shared access across suspension, cancellation and the cleanup it performs, the NonParking and Detached properties, and the Execution park and wake protocol from silk/concurrency/execution.
Syntax#
PropertyBound = Bound '+' Property ( '+' Property )*
Property = 'Intrinsic' '.' ( 'NonParking' | 'Detached' )
NonParkingMarker = '@nonParking'A
PropertyBoundappears in a generic parameter list after a callable or Effect bound:F: once fn() -> i32 + Intrinsic.NonParking.NonParkingMarkeris a directive on the line before a service or interface operation:@nonParkingtheneffect fn close() -> () ! E ? &mut S. See Intrinsics for directives.
Suspension points#
A suspension point is a
runwhose Effect can suspend. Nothing else suspends.Effect.suspend(e)defersethrough the stack-safe executor. It keeps the success, failure and requirement channels exactly. The transfer can complete within the same activation.Execution.park(register)relinquishes the currentExecutionuntil aWakemakes it eligible again. Parking is the only external suspension.Fiber.yieldNow()parks and wakes itself at once, so tasks that are already ready run first.An ordinary
fnmayrunan Effect with empty failure and requirement rows. If that Effect parks, the whole call chain parks with it.Program startup supplies no scheduler and checks the entry with
Intrinsic.NonParking. Amainthat can reachExecution.parkis SEM0225 atmain, reported bysilk build. Create an owner withExecution.makeorLocalScheduler.executeinstead.
What a suspended frame keeps#
Every owner live at the suspension point stays in the suspended frame: locals, parameters, partly moved aggregates and held outcomes.
Borrows of the frame's own locals may cross a suspension point. A
&muttaken before arunis usable after it.Execution.maketakes a body of typeonce Effect<'static; A>that is alsoIntrinsic.Detached. A body that borrows a caller's local is rejected at the call with SEM0139 ("A concrete executable that fails one sealed static-property obligation"), not a lifetime error.silk checkreports it.A
Shared<T>handle is an ordinary owner. It may be held across suspension, and clones may live in several suspended frames.
Local-shared access and suspension#
Shared.withandShared.withMutlend the stored value to an ordinaryonce fncallback. The borrow ends when the callback returns.Nothing the callback reaches may
runan Effect. This includes an Effect that never suspends. The violation is OWN0016.Nothing the callback reaches may notify an execution, such as
Execution.wake. That is also OWN0016, reported at the notification site, which may be insidesilk/concurrency/execution.The check follows calls from the callback transitively.
Fix: take what you need out of the value inside the callback, return it, then
runor wake after the access call returns.Nesting access to the same allocation through any alias traps at runtime before the inner callback starts.
Cancellation#
Dropping a parked
Executioncancels it. The body does not resume.Cancellation drops every owner live at the suspension point.
Drophooks run.Cancellation runs the finalizer of each enclosing
Effect.ensuringNonParkingand the release of each enclosingEffect.useReleaseNonParking. Nested scopes finish in reverse order, after the frame's live owners are dropped.The finalizer of
Effect.ensuringdoes not run on cancellation. The parked composition drops its held outcome and captures once.A trap is not cancellation. Traps bypass finalizers, releases and
Drophooks.A
Wakefor a cancelledExecutionpublishes nothing when consumed. A retainedWakekeeps the package memory alive until it is consumed or dropped.
Cancelling fibers#
Under
LocalScheduler, when a task finishes, its unfinished children and all their descendants are cancelled. Their parked executions are dropped, with the cleanup above.LocalScheduler.executecancels every unfinished descendant before it returns or fails.A cancelled task's
Fibercompletes asCancelled.Fiber.awaitreturnsOutcome { value: Cancelled {} }.Fiber.joinraisesCancelled.Dropping a
Fiberstops observation only. It does not cancel the task.A scheduler publishes cancellation through
Fiber.cancel(canceller), which consumes theCompletionCancellerendpoint.
NonParking and Detached#
Intrinsic.NonParkingholds for an executable whose specialized transitive call graph cannot reach external parking.Intrinsic.Detachedholds for an executable that retains no external lexical or provider loan.Both are sealed compiler properties. No
implsupplies them.NonParkingis checked when building, not bysilk check. A violation is SEM0139, or SEM0225 for the program entry.A
NonParkingvalue is checked even when the program never runs it.NonParkingis required by: the finalizer ofEffect.ensuringNonParking, the release ofEffect.useReleaseNonParking, theregistercallback ofExecution.park, theonReady,onCompleteandonSuspendcallbacks ofExecution, and the program entry.A service operation marked
@nonParkingrequires each provider's implementation to beNonParking. A parking provider is SEM0139 at the call of the operation, whensilk buildchecks it: "closemust not park, because this call needs anIntrinsic.NonParkingvalue, but running it can park; move work that can suspend, such asEffect.sleep, out of it".@nonParkingon an ordinary function with a Silk body makes the same demand of that body. A body that can park is SEM0139 at each call that enters the function, whensilk buildchecks it:run settle(), for@nonParking effect fn settle() -> () { run Fiber.yieldNow() }, prints "settlemust not park, because this call needs anIntrinsic.NonParkingvalue, but running it can park; move work that can suspend, such asEffect.sleep, out of it".@nonParkingtakes no arguments, and there is no@detacheddirective.@nonParking(strict: true)or@detachedis SEM0235 at the directive, in bothsilk checkandsilk build.Detachedis only a bound.The other directives are not allowed on a service operation.
@inline,@features,@foreignor@nakedthere is SEM0236 at the directive.A leftover
with Intrinsic.nonParking()clause is PAR0011:Silk has no `with` clauses; write `@nonParking` on the line before the declaration.
Execution, park and wake#
silk/concurrency/execution exposes one owner per independently resumable computation.
Execution.make(body, readyState, onReady)allocates one package and moves the body in. The body does not start. It needs? &mut Allocatorand can fail withOutOfMemoryError. The resultIntrinsic.Execution<A>is affine.onReady: fn<'static>(&O) -> ()is the fixed readiness endpoint. It must beDetachedandNonParking. It runs withreadyState.Execution.drive(execution, state, onComplete, onSuspend)runs one activation. Exactly one callback receivesstate:onComplete(state, value)on completion, oronSuspend(state, execution)when the body parks.driveaccepts an execution that is initial, initial-ready or eligible. Driving a parked, notifying, completed or destroyed execution is a fatal trap.Execution.park(register)callsregisterwith the generation's onlyWake. The guard thatregisterreturns is kept while parked and dropped just before code after theparkcontinues.Execution.wake(wake)consumes theWake. A parked execution becomes eligible andonReadyruns synchronously. Waking never drives.A
Wakeused during its own registration is latched.onReadyruns afterdrivehands the execution back throughonSuspend.A
Wakefor an earlier generation, or for a dropped or completed execution, publishes nothing.Execution.notifyInitial(&mut execution)reports readiness once for an execution that has never run. A second call, or a call after driving, traps.After
onReady, the owner callsdriveagain to resume the body.
Examples#
Park, wake and resume one execution:
import silk.mem.allocator { Allocator, OutOfMemoryError }
import silk.core.effect { Effect }
import silk.concurrency.execution { Execution, Wake }
import silk.mem.shared { Shared }
struct Empty {}
struct Waiting {
wake: Wake,
}
struct Mailbox {
slot: Empty | Waiting,
readyCount: i32,
}
fn install(mailbox: &mut Mailbox, wake: Wake) -> () {
let previous = Intrinsic.replace(mailbox.slot, Waiting { wake: move wake })
drop previous
return ()
}
fn extract(mailbox: &mut Mailbox) -> Empty | Waiting {
return Intrinsic.replace(mailbox.slot, Empty {})
}
fn markReady(mailbox: &mut Mailbox) -> () {
mailbox.readyCount = mailbox.readyCount + 1
return ()
}
fn readyCount(mailbox: &Mailbox) -> i32 {
return mailbox.readyCount
}
struct Registration {}
fn register(wake: Wake, mailbox: Shared<Mailbox>) -> Registration {
Shared.withMut(&mailbox, install(move wake))
return Registration {}
}
effect fn body(mailbox: Shared<Mailbox>) -> i32 {
run Execution.park(register(move mailbox))
return 40
}
fn onReady(mailbox: &Shared<Mailbox>) -> () {
Shared.withMut(mailbox, markReady)
return ()
}
struct Idle {}
struct Parked {
execution: Intrinsic.Execution<i32>,
}
struct Owner {
slot: Idle | Parked,
result: i32,
}
fn complete(owner: &mut Owner, value: i32) -> () {
owner.result = value
return ()
}
fn suspended(owner: &mut Owner, execution: Intrinsic.Execution<i32>) -> () {
let previous = Intrinsic.replace(owner.slot, Parked { execution: move execution })
drop previous
return ()
}
fn signal(selected: Empty | Waiting) -> () {
return match move selected {
Empty {} => ()
Waiting { wake } => Execution.wake(move wake)
}
}
effect fn resume(selected: Idle | Parked, owner: &mut Owner) -> () {
return match move selected {
Idle {} => ()
Parked { execution } => run Execution.drive(move execution, move owner, complete, suspended)
}
}
effect fn program() -> i32
! OutOfMemoryError {
let mut allocator = Allocator.systemAllocatorProvider()
let mailbox = run Shared<Mailbox>.make(Mailbox { slot: Empty {}, readyCount: 0 })
|> Effect.provideMut<Allocator>(&mut allocator)
let execution = run Execution.make(body(Shared.clone(&mailbox)), Shared.clone(&mailbox), onReady)
|> Effect.provideMut<Allocator>(&mut allocator)
let mut owner = Owner { slot: Idle {}, result: 0 }
run Execution.drive(move execution, &mut owner, complete, suspended)
signal(Shared.withMut(&mailbox, extract))
let parked = Intrinsic.replace(owner.slot, Idle {})
run resume(move parked, &mut owner)
return owner.result + Shared.with(&mailbox, readyCount) - 41
}
effect fn recover(error: OutOfMemoryError) -> i32 {
return 1
}
pub fn main() -> i32 {
return run Effect.catchAll(program(), recover)
}Cancel a parked execution. The local Token drops first (digit 1), then the nonparking finalizer runs (digit 2). With Effect.ensuring in place of Effect.ensuringNonParking, the log is 1.
import silk.mem.allocator { Allocator, OutOfMemoryError }
import silk.core.effect { Effect }
import silk.concurrency.execution { Execution, Wake }
import silk.core.ownership { Drop }
import silk.mem.shared { Shared }
struct Log {
value: i32,
}
fn push(log: &mut Log, digit: i32) -> () {
log.value = log.value * 10 + digit
return ()
}
fn read(log: &Log) -> i32 {
return log.value
}
struct Token {
log: Shared<Log>,
digit: i32,
}
impl Drop for Token {
fn drop(self: &mut Token) -> () {
Shared.withMut(&self.log, push(self.digit))
return ()
}
}
struct Guard {
wake: Wake,
}
fn register(wake: Wake) -> Guard {
return Guard { wake: move wake }
}
effect fn body(log: Shared<Log>) -> i32 {
let token = Token { log: move log, digit: 1 }
run Execution.park(register)
drop token
return 0
}
effect fn note(log: Shared<Log>) -> () {
Shared.withMut(&log, push(2))
return ()
}
effect fn guarded(log: Shared<Log>) -> i32 {
let other = Shared.clone(&log)
return run Effect.ensuringNonParking(body(move log), note(move other))
}
struct Endpoint {}
fn ready(endpoint: &Endpoint) -> () {
return ()
}
fn complete(state: &mut (), value: i32) -> () {
return ()
}
fn suspended(state: &mut (), execution: Intrinsic.Execution<i32>) -> () {
drop execution
return ()
}
effect fn program() -> i32
! OutOfMemoryError {
let mut allocator = Allocator.systemAllocatorProvider()
let log = run Shared<Log>.make(Log { value: 0 }) |> Effect.provideMut<Allocator>(&mut allocator)
let execution = run Execution.make(guarded(Shared.clone(&log)), Endpoint {}, ready)
|> Effect.provideMut<Allocator>(&mut allocator)
let mut state = ()
run Execution.drive(move execution, &mut state, complete, suspended)
return Shared.with(&log, read) - 12
}
effect fn recover(error: OutOfMemoryError) -> i32 {
return -1
}
pub fn main() -> i32 {
return run Effect.catchAll(program(), recover)
}A child is cancelled when its parent task finishes. The grandchild leaf sleeps for an hour; middle returns first, so leaf is cancelled, its Token drops, and awaiting its Fiber reports Cancelled:
import silk.mem.allocator { Allocator, OutOfMemoryError }
import silk.core.effect { Effect }
import silk.concurrency.fiber { Fiber, Cancelled, Failure, Outcome, Success }
import silk.concurrency.local_scheduler { LocalScheduler, StalledError }
import silk.system.monotonic_clock { MonotonicClock }
import silk.system.monotonic_clock_native { OsMonotonicClock }
import silk.core.ownership { Drop }
import silk.concurrency.scheduler { Scheduler, TaskIdExhaustedError }
import silk.mem.shared { Shared }
struct Log {
drops: i32,
}
fn countDrop(log: &mut Log) -> () {
log.drops = log.drops + 1
return ()
}
fn drops(log: &Log) -> i32 {
return log.drops
}
struct Token {
log: Shared<Log>,
}
impl Drop for Token {
fn drop(self: &mut Token) -> () {
Shared.withMut(&self.log, countDrop)
return ()
}
}
struct Empty {}
struct Held {
fiber: Fiber<i32, never>,
}
struct Slot {
phase: Empty | Held,
}
fn store(slot: &mut Slot, fiber: Fiber<i32, never>) -> () {
let previous = Intrinsic.replace(slot.phase, Held { fiber: move fiber })
drop previous
return ()
}
fn take(slot: &mut Slot) -> Empty | Held {
return Intrinsic.replace(slot.phase, Empty {})
}
effect fn leaf(log: Shared<Log>) -> i32
? &mut Scheduler | &mut MonotonicClock {
let token = Token { log: move log }
run MonotonicClock.waitFor(1h)
drop token
return 1
}
effect fn middle(slot: Shared<Slot>, log: Shared<Log>) -> i32
! OutOfMemoryError | TaskIdExhaustedError
? &mut Scheduler | &mut MonotonicClock {
let child = run Fiber<i32, never>.forkChild(leaf(move log))
Shared.withMut(&slot, store(move child))
run Fiber.yieldNow()
return 2
}
fn wasCancelled(outcome: Outcome<i32, never>) -> bool {
let Outcome<i32, never> { value } = move outcome
return match move value {
Success<i32> { value: success } => false
Failure<never> { error } => false
Cancelled {} => true
}
}
effect fn root(log: Shared<Log>) -> i32
! OutOfMemoryError | TaskIdExhaustedError | Cancelled
? &mut Scheduler | &mut MonotonicClock {
let mut allocator = Allocator.systemAllocatorProvider()
let slot = run Shared<Slot>.make(Slot { phase: Empty {} })
|> Effect.provideMut<Allocator>(&mut allocator)
let mid = run Fiber<i32, OutOfMemoryError | TaskIdExhaustedError>.forkChild(
middle(Shared.clone(&slot), Shared.clone(&log)),
)
let value = run Fiber.join(move mid)
let selected = Shared.withMut(&slot, take)
return match move selected {
Empty {} => 10
Held { fiber } => checkLeaf(value, run Fiber.await(move fiber), Shared.with(&log, drops))
}
}
fn checkLeaf(value: i32, outcome: Outcome<i32, never>, dropCount: i32) -> i32 {
if value != 2 {
return 11
}
if !wasCancelled(move outcome) {
return 12
}
return dropCount - 1
}
effect fn scenario() -> i32
! OutOfMemoryError | TaskIdExhaustedError | Cancelled | StalledError {
let mut allocator = Allocator.systemAllocatorProvider()
let log = run Shared<Log>.make(Log { drops: 0 }) |> Effect.provideMut<Allocator>(&mut allocator)
let mut scheduler = LocalScheduler.make()
let mut clock = OsMonotonicClock.make()
return run LocalScheduler.execute(&mut scheduler, root(move log))
|> Effect.provideMut<MonotonicClock>(&mut clock)
}
effect fn recover(
error: OutOfMemoryError | TaskIdExhaustedError | Cancelled | StalledError
) -> i32 {
drop error
return 99
}
pub fn main() -> i32 {
return run Effect.catchAll(scenario(), recover)
}Errors and fixes#
OWN0016, running an Effect inside a Shared.withMut callback:
import silk.mem.allocator { Allocator, OutOfMemoryError }
import silk.core.effect { Effect }
import silk.mem.shared { Shared }
struct Counter { value: i32 }
effect fn five() -> i32 { return 5 }
fn bump(counter: &mut Counter) -> i32 {
counter.value = counter.value + run five()
return counter.value
}
effect fn program() -> i32 ! OutOfMemoryError {
let mut allocator = Allocator.systemAllocatorProvider()
let cell = run Shared<Counter>.make(Counter { value: 1 })
|> Effect.provideMut<Allocator>(&mut allocator)
return Shared.withMut(&cell, bump)
}
effect fn recover(error: OutOfMemoryError) -> i32 { return -1 }
pub fn main() -> i32 { return run Effect.catchAll(program(), recover) }Run the Effect first and pass its result into the callback:
import silk.mem.allocator { Allocator, OutOfMemoryError }
import silk.core.effect { Effect }
import silk.mem.shared { Shared }
struct Counter {
value: i32,
}
effect fn five() -> i32 {
return 5
}
fn add(counter: &mut Counter, amount: i32) -> i32 {
counter.value = counter.value + amount
return counter.value
}
effect fn program() -> i32
! OutOfMemoryError {
let mut allocator = Allocator.systemAllocatorProvider()
let cell = run Shared<Counter>.make(Counter { value: 1 })
|> Effect.provideMut<Allocator>(&mut allocator)
let amount = run five()
return Shared.withMut(&cell, add(amount)) - 6
}
effect fn recover(error: OutOfMemoryError) -> i32 {
return -1
}
pub fn main() -> i32 {
return run Effect.catchAll(program(), recover)
}SEM0139, a parking finalizer passed to Effect.ensuringNonParking. In the cancellation example, adding run Execution.park(register) to the body of note makes the build fail with SEM0139 at the Effect.ensuringNonParking call. Use Effect.ensuring for a finalizer that must park, and accept that it does not run on cancellation.
Diagnostics#
| Code | Meaning |
|---|---|
| OWN0016 | Code reached from a Shared.with or Shared.withMut callback runs an Effect or notifies an execution while the access is held |
| SEM0139 | An executable fails a sealed property obligation, such as Intrinsic.NonParking, or an Execution.make body that borrows a caller's local and so is not Intrinsic.Detached |
| SEM0225 | main may park, but the runtime starts it without a scheduler |
| PAR0011 | A leftover with Intrinsic.nonParking() clause; write @nonParking before the operation |
| SEM0235 | Unknown directive or argument, such as @detached or @nonParking(strict: true) |
| SEM0236 | Known directive on an operation that does not take it, such as @noReturn or @inline |
See also#
Effects and run · Fibers and scheduling · Typed failures · Allocation and shared ownership · Ownership and moves · Borrowing · Lifetimes · Closures and callable types · Program entry and termination · silk/concurrency/execution · silk/concurrency/fiber · silk/mem/shared · silk/core/effect · silk/concurrency/local_scheduler
Open questions#
SEM0139, and SEM0225 for a parking
main, are reported only by a build.silk checkaccepts a program that violatesIntrinsic.NonParking.OWN0016 for a wake inside a callback is reported at
silk/concurrency/execution.silk, not at the user's call.SEM0201, an
export "C"function that may park, is also reported only bysilk build.