Fork & join threads
It’s safest to use higher-level methods, such as par or raceSuccess, however this isn’t always sufficient. For
these cases, threads can be started using the structured concurrency APIs described below.
Forks (new threads) can only be started with a scope. Such a scope is defined using the supervised or scoped
methods.
The lifetime of the forks is defined by the structure of the code, and corresponds to the enclosing supervised or
scoped block. Once the code block passed to the scope completes, any forks that are still running are interrupted.
The whole block will complete only once all forks have completed (successfully, or with an exception).
Hence, it is guaranteed that all forks started within supervised or scoped will finish successfully, with an
exception, or due to an interrupt.
import ox.{fork, supervised}
// same as `par`
supervised {
val f1 = fork {
Thread.sleep(2000)
1
}
val f2 = fork {
Thread.sleep(1000)
2
}
(f1.join(), f2.join())
}
It is a compile-time error to use fork/forkUser outside of a supervised or scoped block. Helper methods might
require to be run within a scope by requiring the Ox capability:
import ox.{fork, Fork, Ox, supervised}
def forkComputation(p: Int)(using Ox): Fork[Int] = fork {
Thread.sleep(p * 1000)
p + 1
}
supervised {
val f1 = forkComputation(2)
val f2 = forkComputation(4)
(f1.join(), f2.join())
}
Scopes can be arbitrarily nested.
Supervision
The default scope, created with supervised, watches over the forks that are started within. Any forks started with
fork and forkUser are by default supervised.
This means that the scope will end only when either:
all (user, supervised) forks, including the main body passed to
supervised, succeedor any (supervised) fork, including the main body passed to
supervised, fails
Hence an exception in any of the forks will cause the whole scope to end. Ending the scope means that all running forks
are cancelled (using interruption). Once all forks complete, the exception is propagated further, that is re-thrown by
the supervised method invocation:
import ox.{fork, forkUser, Fork, Ox, supervised}
supervised {
forkUser {
Thread.sleep(1000)
println("Hello!")
}
fork {
Thread.sleep(500)
throw new RuntimeException("boom!")
}
}
// doesn't print "Hello", instead throws "boom!"
User, daemon and unsupervised forks
In supervised scoped, forks created using fork behave as daemon threads. That is, their failure ends the scope, but
the scope will also end once the main body and all user forks succeed, regardless if the (daemon) fork is still running.
Alternatively, a user fork can be created using forkUser. Such a fork is required to complete successfully, in order
for the scope to end successfully. Hence when the main body of the scope completes, the scope will wait until all user
forks have completed as well.
Finally, entirely unsupervised forks can be ran using forkUnsupervised.
Unsupervised scopes
An unsupervised scope can be created using scoped. Any forks started within are unsupervised. This is considered an
advanced feature, and should be used with caution.
Such a scope ends, once the code block passed to scoped completes. Then, all running forks are cancelled. Still, the
scope completes (that is, the scoped block returns) only once all forks have completed.
Fork failures aren’t handled in any special way, and can be inspected using the Fork.join() method.
Cancelling forks
By default, forks are not cancellable by the user. Instead, all outstanding forks are cancelled (interrupted) when the enclosing scope ends.
If needed, a cancellable fork can be created using forkCancellable. However, such an operation is more expensive, as
it involves creating a nested scope and two virtual threads, instead of one.
The CancellableFork trait exposes the .cancel method, which interrupts the fork and awaits its completion.
Alternatively, .cancelNow returns immediately. In any case, the enclosing scope will only complete once all forks have
completed.
Error handling
In supervised mode, if a fork fails with an exception, the enclosing scope will end.
Moreover, if a fork fails with an exception, the Fork.join method will throw that exception.
In unsupervised mode, if there’s no join and the fork fails, the exception might go unnoticed.