Skip to content

Commit 2cdcc61

Browse files
authored
Introduce ThreadHerd instead of StructuredConcurrencyScope, broaden Java compatibility (#319)
Moreover, replaces the `ForkLocal` implementation based on `ScopedValue`s with a custom one, where the locals are bound to the concurrency scope. Ox now no longer uses any preview Java features.
1 parent 253bd9e commit 2cdcc61

25 files changed

Lines changed: 399 additions & 266 deletions

File tree

.github/workflows/ci.yml

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,39 @@ on:
66
branches: [ master ]
77
tags: [ v* ]
88
jobs:
9-
build:
10-
uses: softwaremill/github-actions-workflows/.github/workflows/build-scala.yml@main
11-
with:
12-
java-version: '21'
13-
compile-documentation: true
9+
ci:
10+
runs-on: ubuntu-24.04
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
java: [ "21", "24" ]
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
- name: Set up JDK
19+
uses: actions/setup-java@v4
20+
with:
21+
distribution: 'temurin'
22+
java-version: ${{ matrix.java }}
23+
cache: 'sbt'
24+
- uses: sbt/setup-sbt@v1
25+
- name: Check formatting
26+
run: sbt -v scalafmtCheckAll
27+
- name: Compile
28+
run: sbt -v compile
29+
- name: Compile documentation
30+
run: sbt -v compileDocumentation
31+
- name: Test
32+
run: sbt -v test
33+
- uses: actions/upload-artifact@v4 # upload test results
34+
if: success() || failure() # run this step even if previous step failed
35+
with:
36+
name: 'tests-results-java-${{ matrix.java }}'
37+
path: '**/test-reports/TEST*.xml'
1438

1539
publish:
1640
uses: softwaremill/github-actions-workflows/.github/workflows/publish-release.yml@main
17-
needs: [build]
41+
needs: [ci]
1842
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v'))
1943
secrets: inherit
2044
with:
@@ -28,5 +52,5 @@ jobs:
2852
auto-merge:
2953
# only for PRs by softwaremill-ci
3054
if: github.event.pull_request.user.login == 'softwaremill-ci'
31-
needs: [ build, label ]
55+
needs: [ ci, label ]
3256
uses: softwaremill/github-actions-workflows/.github/workflows/auto-merge.yml@main

.jvmopts

Lines changed: 0 additions & 1 deletion
This file was deleted.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![CI](https://github.com/softwaremill/ox/workflows/CI/badge.svg)](https://github.com/softwaremill/ox/actions?query=workflow%3A%22CI%22)
55
[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.softwaremill.ox/core_3/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.softwaremill.ox/core_3)
66

7-
Safe direct-style concurrency and resiliency for Scala on the JVM. Requires JDK 21 & Scala 3. The areas that we'd like
7+
Safe direct-style concurrency and resiliency for Scala on the JVM. Requires JDK 21+ & Scala 3. The areas that we'd like
88
to cover with Ox are:
99

1010
* concurrency: developer-friendly structured concurrency, high-level concurrency operators, safe low-level primitives,

core/src/main/scala/ox/Ox.scala

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ package ox
22

33
import ox.channels.Actor
44
import ox.channels.ActorRef
5+
import ox.channels.BufferCapacity
6+
import ox.internal.ThreadHerd
57

6-
import java.util.concurrent.StructuredTaskScope
78
import java.util.concurrent.atomic.AtomicReference
89
import scala.annotation.implicitNotFound
9-
import ox.channels.BufferCapacity
1010

1111
/** Capability granted by an [[unsupervised]] concurrency scope (as well as, via subtyping, by [[supervised]] and [[supervisedError]]).
1212
*
@@ -22,10 +22,12 @@ import ox.channels.BufferCapacity
2222
"This operation must be run within a `supervised`, `supervisedError` or `unsupervised` block. Alternatively, you must require that the enclosing method is run within a scope, by adding a `using OxUnsupervised` parameter list."
2323
)
2424
trait OxUnsupervised:
25-
private[ox] def scope: StructuredTaskScope[Any]
25+
private[ox] def herd: ThreadHerd
2626
private[ox] def finalizers: AtomicReference[List[() => Unit]]
2727
private[ox] def supervisor: Supervisor[Nothing]
2828
private[ox] def addFinalizer(f: () => Unit): Unit = finalizers.updateAndGet(f :: _).discard
29+
private[ox] def parent: Option[OxUnsupervised]
30+
private[ox] def locals: ForkLocalMap
2931
end OxUnsupervised
3032

3133
/** Capability granted by an [[supervised]] or [[supervisedError]] concurrency scope.
@@ -68,16 +70,18 @@ private[ox] trait RunInScope:
6870
"This operation must be run within a `supervisedError` block. Alternatively, you must require that the enclosing method is run within a scope, by adding a `using OxError[E, F]` parameter list, using the desired error mode type parameters."
6971
)
7072
case class OxError[E, F[_]](
71-
private[ox] val scope: StructuredTaskScope[Any],
73+
private[ox] val herd: ThreadHerd,
7274
private[ox] val finalizers: AtomicReference[List[() => Unit]],
7375
private[ox] val supervisor: Supervisor[E],
74-
private[ox] val errorMode: ErrorMode[E, F]
76+
private[ox] val errorMode: ErrorMode[E, F],
77+
private[ox] val parent: Option[OxUnsupervised],
78+
private[ox] val locals: ForkLocalMap
7579
) extends Ox:
7680
override private[ox] def asNoErrorMode: OxError[Nothing, [T] =>> T] =
7781
if errorMode == NoErrorMode then this.asInstanceOf[OxError[Nothing, [T] =>> T]]
78-
else OxError(scope, finalizers, supervisor, NoErrorMode)
82+
else OxError(herd, finalizers, supervisor, NoErrorMode, parent, locals)
7983
end OxError
8084

8185
object OxError:
82-
def apply[E, F[_]](s: Supervisor[E], em: ErrorMode[E, F]): OxError[E, F] =
83-
OxError(DoNothingScope[Any](oxThreadFactory), new AtomicReference(Nil), s, em)
86+
def apply[E, F[_]](s: Supervisor[E], em: ErrorMode[E, F], parent: Option[OxUnsupervised], locals: ForkLocalMap): OxError[E, F] =
87+
OxError(ThreadHerd(oxThreadFactory), new AtomicReference(Nil), s, em, parent, locals)

core/src/main/scala/ox/fork.scala

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package ox
22

3+
import ox.internal.currentScope
4+
5+
import java.util.concurrent.CompletableFuture
6+
import java.util.concurrent.Semaphore
37
import java.util.concurrent.atomic.AtomicBoolean
4-
import java.util.concurrent.{CompletableFuture, Semaphore}
58
import scala.concurrent.ExecutionException
69
import scala.util.control.NonFatal
710

@@ -34,15 +37,17 @@ def forkError[E, F[_], T](using OxError[E, F])(f: => F[T]): Fork[T] =
3437
val oxError = summon[OxError[E, F]]
3538
// the separate result future is needed to wait for the result, as there's no .join on individual tasks (only whole scopes can be joined)
3639
val result = new CompletableFuture[T]()
37-
oxError.scope.fork { () =>
40+
41+
oxError.herd.startThread {
42+
currentScope.set(oxError) // propagating the current scope
3843
val supervisor = oxError.supervisor
3944
try
4045
val resultOrError = f
4146
val errorMode = oxError.errorMode
4247
if errorMode.isError(resultOrError) then
4348
// result is never completed, the supervisor should end the scope
4449
supervisor.forkAppError(errorMode.getError(resultOrError))
45-
else result.complete(errorMode.getT(resultOrError))
50+
else result.complete(errorMode.getT(resultOrError)).discard
4651
catch
4752
case e: Throwable =>
4853
// we notify the supervisor first, so that if this is the first failing fork in the scope, the supervisor will
@@ -52,6 +57,7 @@ def forkError[E, F[_], T](using OxError[E, F])(f: => F[T]): Fork[T] =
5257
if !supervisor.forkException(e) then result.completeExceptionally(e).discard
5358
end try
5459
}
60+
5561
new ForkUsingResult(result) {}
5662
end forkError
5763

@@ -81,7 +87,9 @@ def forkUserError[E, F[_], T](using OxError[E, F])(f: => F[T]): Fork[T] =
8187
val oxError = summon[OxError[E, F]]
8288
val result = new CompletableFuture[T]()
8389
oxError.supervisor.forkStarts()
84-
oxError.scope.fork { () =>
90+
91+
oxError.herd.startThread:
92+
currentScope.set(oxError) // propagating the current scope
8593
val supervisor = oxError.supervisor.asInstanceOf[DefaultSupervisor[E]]
8694
try
8795
val resultOrError = f
@@ -96,7 +104,7 @@ def forkUserError[E, F[_], T](using OxError[E, F])(f: => F[T]): Fork[T] =
96104
case e: Throwable =>
97105
if !supervisor.forkException(e) then result.completeExceptionally(e).discard
98106
end try
99-
}
107+
100108
new ForkUsingResult(result) {}
101109
end forkUserError
102110

@@ -111,12 +119,16 @@ end forkUserError
111119
* For alternate behaviors, see [[fork]], [[forkUser]] and [[forkCancellable]].
112120
*/
113121
def forkUnsupervised[T](f: => T)(using OxUnsupervised): UnsupervisedFork[T] =
122+
val oxUnsupervised = summon[OxUnsupervised]
114123
val result = new CompletableFuture[T]()
115-
summon[OxUnsupervised].scope.fork { () =>
116-
try result.complete(f)
117-
catch case e: Throwable => result.completeExceptionally(e)
118-
}
124+
125+
oxUnsupervised.herd.startThread:
126+
currentScope.set(oxUnsupervised) // propagating the current scope
127+
try result.complete(f).discard
128+
catch case e: Throwable => result.completeExceptionally(e).discard
129+
119130
new ForkUsingResult(result) with UnsupervisedFork[T] {}
131+
end forkUnsupervised
120132

121133
/** For each thunk in the given sequence, starts a fork using [[fork]]. All forks are guaranteed to complete before the enclosing
122134
* [[supervised]] or [[unsupervised]] block completes.
@@ -149,21 +161,28 @@ def forkCancellable[T](f: => T)(using OxUnsupervised): CancellableFork[T] =
149161
// interrupt signal
150162
val done = new Semaphore(0)
151163
val ox = summon[OxUnsupervised]
152-
ox.scope.fork { () =>
153-
val nestedOx = OxError(NoOpSupervisor, NoErrorMode)
154-
scopedWithCapability(nestedOx) {
155-
nestedOx.scope.fork { () =>
156-
// "else" means that the fork is already cancelled, so doing nothing in that case
157-
if !started.getAndSet(true) then
158-
try result.complete(f).discard
159-
catch case e: Throwable => result.completeExceptionally(e).discard
160-
161-
done.release() // the nested scope can now finish
164+
165+
ox.herd.startThread:
166+
try
167+
val nestedOx = OxError(NoOpSupervisor, NoErrorMode, Some(ox), ox.locals)
168+
scopedWithCapability(nestedOx) {
169+
nestedOx.herd.startThread {
170+
currentScope.set(nestedOx) // propagating the nested scope, which has the current scope set as a parent
171+
// "else" means that the fork is already cancelled, so doing nothing in that case
172+
if !started.getAndSet(true) then
173+
try result.complete(f).discard
174+
catch case e: Throwable => result.completeExceptionally(e).discard
175+
176+
done.release() // the nested scope can now finish
177+
}.discard
178+
179+
done.acquire()
162180
}
181+
catch
182+
// if this thread was interrupted, any context is already captured as part of the thread started in the nested scope
183+
// hence, ignoring this exception, so that it's not logged by the uncaught exception handler
184+
case e: InterruptedException =>
163185

164-
done.acquire()
165-
}
166-
}
167186
new ForkUsingResult(result) with CancellableFork[T]:
168187
override def cancel(): Either[Throwable, T] =
169188
cancelNow()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package ox.internal
2+
3+
import ox.OxUnsupervised
4+
import ox.ForkLocalMap
5+
6+
/** Should only ever be updated when starting a new scope, for the duration of the scope's lifetime. Used to verify that forks are properly
7+
* started, within a running concurrency scope, on a thread that is part of some scope in the tree.
8+
*/
9+
private[ox] val currentScope: ThreadLocal[OxUnsupervised] = new ThreadLocal[OxUnsupervised]()
10+
11+
private[ox] def currentLocals: ForkLocalMap =
12+
val scope = currentScope.get()
13+
if scope == null then ForkLocalMap(Map.empty) else scope.locals
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package ox.internal
2+
3+
import ox.OxUnsupervised
4+
import ox.discard
5+
6+
import java.util.concurrent.ConcurrentHashMap
7+
import java.util.concurrent.ThreadFactory
8+
import java.util.concurrent.atomic.AtomicBoolean
9+
import scala.annotation.tailrec
10+
11+
/** Until the structured concurrency JEP is stable in an LTS release, a replacement for `StructuredTaskScope`. Downside: does not integrate
12+
* with scoped values (instead, `ForkLocal` is based on a custom implementation as well). Upside: works with any Java 21+.
13+
*
14+
* Naming: `ThreadFlock` is part of the Structured Concurrency JEP; `ThreadGroup` is already part of the JDK. Hence, using "herd."
15+
*/
16+
private[ox] class ThreadHerd(threadFactory: ThreadFactory):
17+
// capturing the owner at construction time to later check if forks are started properly
18+
private val herdOwner = Thread.currentThread()
19+
20+
private val shutdownInProgress = new AtomicBoolean(false)
21+
private val threads = ConcurrentHashMap.newKeySet[Thread]()
22+
23+
def startThread(t: => Unit): Unit =
24+
assertNotShuttingDown()
25+
26+
verifyCurrentThreadInScopeTree()
27+
28+
val thread = threadFactory.newThread(() =>
29+
try t
30+
finally threads.remove(Thread.currentThread()).discard
31+
)
32+
threads.add(thread)
33+
34+
thread.start()
35+
end startThread
36+
37+
/** Interrupts all thread in this heard, and waits for them to complete. If this process is interrupted, the exception is captured and
38+
* rethrown only after all threads from the herd have completed.
39+
*/
40+
def interruptAllAndJoinUntilCompleted(): Unit =
41+
assertOnOwnerThread()
42+
43+
shutdownInProgress.set(true)
44+
45+
/*
46+
If a startThread() is in progress, after the no-shutdown-assertion, the thread that it creates will be discovered
47+
in the loop below, as the thread that is starting the new thread itself has to be joined; and this can only happen
48+
after the new thread is added to the `threads` set.
49+
*/
50+
51+
var interruptedThreads = Set.empty[Thread] // making sure we interrupt each thread only once
52+
def interruptIfNeeded(t: Thread): Unit = if !interruptedThreads.contains(t) then
53+
t.interrupt()
54+
interruptedThreads += t
55+
56+
var interruptedException: InterruptedException = null
57+
58+
while !threads.isEmpty do
59+
threads.forEach(interruptIfNeeded)
60+
threads.forEach: thread =>
61+
interruptIfNeeded(thread) // double-checking, as this thread might have been added after the previous forEach
62+
try thread.join()
63+
catch
64+
case e: InterruptedException => // if the thread is not yet done, we'll join it again in the next iteration
65+
if interruptedException == null then interruptedException = e
66+
else interruptedException.addSuppressed(e)
67+
end while
68+
69+
if interruptedException != null then throw interruptedException
70+
end interruptAllAndJoinUntilCompleted
71+
72+
def isOwnerOrHerdThread(thread: Thread): Boolean = thread == herdOwner || threads.contains(thread)
73+
74+
private def assertNotShuttingDown(): Unit =
75+
if shutdownInProgress.get() then
76+
throw new IllegalStateException("Scope is shutting down, cannot start new threads or join existing ones.")
77+
78+
/** Naming: verify, not assert, as this might be a user error, not a bug in Ox (which is what the other asserts assert). */
79+
private def verifyCurrentThreadInScopeTree(): Unit =
80+
val currentThread = Thread.currentThread()
81+
82+
@tailrec
83+
def doAssert(scope: OxUnsupervised): Unit =
84+
if scope.herd.isOwnerOrHerdThread(currentThread) then return
85+
scope.parent match
86+
case None => throw new IllegalStateException("Fork cannot be started outside the tree of concurrency scopes.")
87+
case Some(parentScope) => doAssert(parentScope)
88+
89+
currentScope.get() match
90+
case null =>
91+
throw new IllegalStateException("Forks can only be started inside a concurrency scope, or from other forks started in the scope.")
92+
case scope => doAssert(scope)
93+
end verifyCurrentThreadInScopeTree
94+
95+
private def assertOnOwnerThread(): Unit =
96+
val current = Thread.currentThread()
97+
if current != herdOwner then
98+
throw new IllegalStateException("This operation can only be performed from the thread that created the scope.")
99+
end ThreadHerd

0 commit comments

Comments
 (0)