Transactions (Software Transactional Memory)
Air includes a software transactional memory (STM) system, in the spirit of Clojure's refs and dosync. It lets you take a set of reads and writes that must happen together and have them either all take effect or none of them do, without taking locks by hand.
Four object types take part in transactions, and a fifth, Var, is how the current transaction is tracked:
Volatile— a reference-like object whose value may only be changed inside a transaction. This is the analogue of a Clojureref.Actor— a worker with its own queue and value, to which functions are sent rather than called. This is the analogue of a Clojure agent.Promise— a one-shot value container whose delivery may be staged in a transaction; see Promises and transactions.Stream— a reusable, optionally bounded FIFO whose reads and writes may be staged in a transaction; see Streams.Var— a task-local binding; see the Task-Local Variables page.Vars are not transactional, but they are how the current transaction is tracked internally.
Volatiles and tx
A Volatile behaves like a Ref except that it may only be written inside a transaction. The transaction body is run with tx (or the @tx macro):
julia> using Air
julia> v = Volatile{Int}(0)
Volatile{Int64}(@Hx4dz8Y0A3w)
julia> @tx v[] = 5
5
julia> v[]
5
julia> v[] = 6 # writing outside a transaction is an error
ERROR: cannot set volatile outside of transactionBecause the body may be re-run, it should be free of side effects that are not themselves transactional:
julia> @tx (v[] = v[] + 1)
6Atomicity and retries
A transaction records the values it reads and the values it writes. When it commits, it checks that none of the values it read have changed in the meantime. If any have, the transaction is discarded and run again, so the body of a transaction may execute more than once. Once a transaction commits, all of its writes become visible at once.
If the body throws, nothing is written:
julia> v = Volatile{Int}(0);
julia> try
@tx begin
v[] = 99
error("abort")
end
catch
end
julia> v[]
0A transaction may deliberately restart itself by throwing TxRetryException. Nested transactions are folded into the enclosing one, so calling tx inside a transaction simply runs the body in the current transaction.
Filters and finalizers
A Volatile may carry two functions that constrain its value. A filter runs whenever the volatile is set directly (v[] = x), and a finalizer runs immediately before the transaction attempts to commit. Both replace the value that would otherwise be stored, and both may throw to abort the transaction.
julia> v = Volatile{Int}(0);
julia> tx() do
setfinalize!(v, x -> x * 2) # committed values are doubled
end
julia> @tx v[] = 3;
julia> v[]
6Use getfilter, getfinalize, setfilter! and setfinalize! to inspect and change them. Note that changing a filter does not re-apply it to the current value.
Actors
An Actor owns a value and a queue of pending functions. Sending a function to an actor schedules it to run on the actor's own task; its return value becomes the actor's new value. Reads and sends made inside a transaction are only committed if the transaction commits.
julia> a = Actor{Symbol}(:start)
Actor{Symbol}(@97gWy32lwvV)
julia> send(x -> Symbol(string(x), "_done"), a);
julia> a[]
:start_doneSending is asynchronous, so reading the actor immediately after send may still yield the old value. A function that throws puts the actor into an error state: subsequent reads and sends raise the captured exception until the actor is reset.
A sent function is called in the latest world, so it may be one that was defined after the actor was created — a closure typed at the REPL, for example, which an actor's long-lived task could otherwise not call.
An actor may be given two hooks, at construction or later:
- a post-processor, called after each scheduled function with the old value and the function's return value. The value it returns is stored instead, which is useful when the scheduled function's job is a side effect — an
IOstream, for example, whoseprintlnreturnsnothing; - an error handler, called with the actor, the value the failing function was given, and the exception. It observes the failure; the actor still enters its error state.
julia> a = Actor{Int}(0; postprocess = (old, r) -> old + r);
julia> send(_ -> 3, a);
julia> a[] # after the actor has processed the message
3
julia> send(_ -> 4, a);
julia> a[]
7Use getpostprocess, setpostprocess!, geterrorhandler and seterrorhandler! to inspect and change them. A setter affects the next message the actor dequeues; setting a hook is immediate configuration and is not rolled back by a transaction.
A send made from a volatile finalizer or a promise delivery callback runs in the transaction's finalize phase, where no transaction is active, but the message still belongs to the transaction that is finalizing: it is staged and queued only if that transaction commits, once rather than once per attempt.
julia> b = Actor{Symbol}(:start);
julia> send(x -> error("boom"), b);
julia> geterror(b) isa Air.ActorException
true
julia> reset(b, :ok) # restart with a new value
Air.ActorException{Symbol}(ErrorException)
julia> b[]
:okPromises and transactions
A Promise is a one-shot container: put! delivers a value, fail! settles it as failed, and take (or p[]) waits for either settlement. A promise may also be given an ondelivery and/or an onfailure callback when it is constructed.
Inside a transaction, put! and fail! do not settle: they stage the settlement, and it happens only if the transaction commits. If the body throws, or the transaction exhausts its retries, nothing is settled. Inside the transaction that staged it, take (which throws PromiseFailed for a staged failure), isready, isfailed and geterror see the staged settlement, so a take after a put! does not deadlock waiting for a settlement that cannot happen until the body returns.
The promise's callback runs in the transaction's finalize phase — the ondelivery callback for a staged delivery, the onfailure callback for a staged failure — after the body and after the volatile finalizers, but before the commit locks or validates anything. It runs on every attempt, so under contention it may run more than once, and it may run for an attempt that never settles. It is a notification, not a commit hook: its return value is ignored, and an exception it throws aborts the transaction. Outside a transaction, the settlement happens immediately and the callback runs in the calling task once the promise's lock is released; the promise then stays settled even if the callback throws.
A promise settles only once. A second settlement — staged twice in one transaction, of either kind, or attempted after another task has settled the promise — raises ArgumentError. That is a permanent failure rather than a retryable conflict, so the transaction fails at once and commits nothing.
Callbacks run with no transaction active, currtx() === nothing, so a volatile read inside one sees the last committed value rather than the transaction's staged write. A send made there is nonetheless staged in the transaction that is finalizing, so a callback whose transaction loses its race queues nothing. Every other side effect a callback performs is immediate and is not rolled back if the transaction fails — in particular, a put! or fail! there settles its promise at once, and a Stream put or take there happens at once rather than being staged. Since callbacks run on every attempt, a callback that settles another promise must be careful: doing so on the first attempt makes the second attempt settle it twice, which raises ArgumentError and aborts the transaction. A callback may start a transaction of its own, but it should not block, and it must not wait on the promise it is being called for.
Streams and transactions
A Stream is the reusable form of a promise: a FIFO of values that any number of tasks may write and read, optionally bounded so that a full stream makes put! wait. Its transactional rules follow the promise's — put!, take and close are staged and take effect only if the transaction commits, and the body reads its own staged operations — with one addition: because other tasks can change a stream while a body runs, a stream that a transaction has touched is validated like a Volatile at commit, and a concurrent change makes the attempt retry rather than commit against a stale view.
Unlike a promise, waiting on a stream inside a transaction is not by itself a deadlock: another task can write to it while the body waits. The exception is a stream created in the current transaction, which no other task can reach yet; an operation that would block on one raises StreamDeadlock. See Streams for the details.
IO
TxIO wraps an IO object so that output is performed on a separate task and writes made inside a transaction only happen if the transaction commits. airout is a TxIO-like wrapper around stdout.
API
The docstrings for every type and function discussed above are in the full API reference.