Concurrency¶
S++ uses coroutines as the primary concurrency mechanism. They can be suspended and resumed, and support bidirectional
data flow between the caller and the coroutine. They are defined in the same way as a function or method, but they
require the cor
keyword rather than fun
.
Coroutine return types are constrained to generators, as explored below. Typically, there will be any number of gen
expressions inside the coroutine, to generate or yield values to the caller. Assigning a value from the gen
expression
allows for sending data back into the coroutine on resuming.
An important distinction is that coroutines are baked into control flow, rather than the type system purely. This means
whilst the multiplicity, optionality and fallibility of a coroutine is depicted in the type system, the actual
destructuring of a yielded value requires a specific block, designated for coroutines. This is because of second class
borrows; for example, Opt[&T]
cannot exist as a type, because this requires Some[&T]
, which requires a borrow type
attribute, invalidating the second class borrow rules. The iter
block is explored in this section.
Coroutine Return Types¶
There are four possible coroutine return types in S++:
Gen[Yield, Send=Void]
GenOnce[Yield, Send=Void]
GenOpt[Yield, Send=Void]
GenRes[Yield, Err, Send=Void]
The standard generator type, Gen
, will only ever yield into 2 different states: yielding a value of type Yield
, or
being exhausted with no more values. Every value yielded from a Gen
coroutine is safely assumed to be valid, existing,
and inferred as the Yield
type. The iter
block can match on value and exhaustion.
The GenOnce
type is used when the coroutine is designed to yield a single value, and then be exhausted. This is useful
for things like indexing a value in a vector. Calling .res()
on a GenOnce
coroutine will yield a value and
auto-unwrap it into the inner value. GenOpt
and GenRes
can’t be one hit due to their no-value or error states
requiring consideration. If the one-hit generator is exhausted immediately, it will panic.
The GenOpt
type is used when the coroutine might yield a “no-value”. This is seen when “getting” an element from a
vector, as the bounds check might fail, and a “no-value” should be signified. A value yielded from a GenOpt
coroutine
can be assumed to be either a value of type Yield
, or a “no-value” (which is represented as Void
in the typesystem).
The iter
block can match on value, “no-value”, and exhaustion.
Finally, the GenRes
type is used when the coroutine might yield a value of type Yield
, or an error of type Err
.
This is used for fallible generators, where the result can be either a value or an error. A value yielded from a
GenRes
coroutine can be assumed to be either a value of type Yield
, or an error of type Err
. The iter
block can
match on value, error and exhaustion.
The Yield
generic parameter’s corresponding argument determines the type of value being yielded from the coroutine.
Whilst this is inferrable, a design decision was taken to mark it explicitly in the return type, in the same way that a
subroutine’s return type must always be explicitly given, despite is being inferrable from ret
statements.
A convention can be applied to the generic argument for the Yield
parameter. Because borrows can be yielded from
coroutines, the yielding convention must match the convention of the Yield
argument. For example, if the gen
expressions look like gen &mut 123
, then the generator type would be Gen[&mut BigInt]
. This allows for the full
convention and type knowledge of the generated values to be accessible from the function signature alone.
The Send
generic parameter represents the type being sent back to the coroutine. This defaults to Void
, disallowing
data to be sent back (cannot have a Void
variable), but can be set to any type. Only owned objects can be sent back
into a coroutine.
Advancing a Generator¶
A generator is advanced by using the .res()
method. As this requires a special compiler intrinsic, res
is a callable
postfix keyword, rather than a method.
cor coroutine(a: BigInt, b: BigInt, c: BigInt) -> Gen[Yield=&BigInt] {
gen &a
gen &b
gen &c
}
fun main() -> Void {
let generator = coroutine(1, 2, 3)
let a = generator.res()
let b = generator.res()
let c = generator.res()
}
See the invalidating-borrows
section for how and why earlier generated borrows are invalidated.
Passing Data Out of a Coroutine¶
As seen above, data is passed out of a coroutine using the gen
expression, including an optional convention. Allowing
borrows to be yielded from coroutines is the basis for iteration, as it allows elements of a vector, for example, to be
borrowed and used in the caller.
cor coroutine(a: BigInt, b: BigInt, c: BigInt) -> Gen[Yield=&BigInt] {
gen &a
gen &b
gen &c
}
Invalidating Borrows [SECTION SUBJECT TO CHANGE]¶
Yielded borrowed belong to the generator they are yielded from. That being said, the law of exclusivity is applied to them, to prevent accessing multiple mutable parts of a generator, which could be overlapping. So a mutably borrowed yield will invalidate the previous mutably borrowed yield.
Further to this, a borrow could be yielded, then its corresponding owned object consumed in the next resuming of the caller. Therefore, each yield must be isolated, and invalidate the previous yield.
cor coroutine(a: BigInt, b: BigInt, c: BigInt) -> Gen[Yield=&BigInt] {
gen &a
let s = a + b
gen &s
}
fun main() -> Void {
let generator = coroutine(1, 2, 3)
let a = generator.res()
let b = generator.res()
let c = generator.res()
}
In this example it can be seen that a
is consumed in the coroutine. As there is no guarantee whether a variable is
consumed or not in the coroutine after being yielded as a borrow, it must be assumed that a worst-cast scenario occurs,
and that every variable yielded as a borrow might subsequently be consumed. As such, every time another borrowed value
is yielded, the previous borrow will be invalidated in the caller.
Passing Data Into a Coroutine¶
The .res()
method takes a single argument, whose type matches the Send
generic parameter of the generator (
coroutine return type). Because substituting Void
as a generic parameter causes function parameters of that type to be
removed from the signature, .res()
can be used for th default Send=Void
generic parameter.
Values are received by placing the gen
expression on the right-hand-side of a variable definition statement. Variables
are always moved into a coroutine, not borrowed. This means that receiving a second value into the coroutine doesn’t
invalidate the first one.
cor coroutine() -> Gen[Yield=BigInt, Send=BigInt] {
let a = gen 1
let b = gen 2
let c = gen 3
gen a + b + c
}
fun main() -> Void {
let generator = coroutine()
let a = generator.res(1)
let b = generator.res(2)
let c = generator.res(3)
let t = generator.res(0)
}
Borrowing Data Into a Coroutine¶
A problem is presented when trying to use borrows with coroutines; a borrow could be passed into a coroutine, the coroutine suspends, the owned object in the caller context is consumed, and the borrow is subsequently used in the coroutine. To prevent this, memory pinning is used.
All borrows into a coroutine must be pinned. This prevents them from being consumed in the caller context, until they are manually released. Releasing a pin will invalidate any object relying on the pin. Therefore, if a coroutine received a borrow, and the memory of that borrow is released, the coroutine is marked as consumed and is non-usable.
Chaining Coroutines¶
Coroutine chaining allows a coroutine to yield the entirety of another coroutine in a one-line expression; without a
loop. This is identical to Pythons yield from
statement. In S++, gen with
is used, to generate values with another
coroutine.
The following example shows equivalent code using a loop and coroutine chaining:
cor coroutine() -> Gen[Yield=BigInt] {
gen 1
gen 2
gen 3
}
cor coroutine_chain() -> Gen[Yield=BigInt] {
gen 0
for i in coroutine() {
gen i
}
}
cor coroutine() -> Gen[Yield=BigInt] {
gen 1
gen 2
gen 3
}
cor coroutine_chain() -> Gen[Yield=BigInt] {
gen 0
gen with coroutine()
}