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, succeed

  • or 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, 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.