Skip to content

Commit 2326dfc

Browse files
committed
Introduce ThreadHerd instead of StructuredConcurrencyScope, broaden Java compatibility
1 parent cfee511 commit 2326dfc

5 files changed

Lines changed: 122 additions & 41 deletions

File tree

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import java.util.concurrent.StructuredTaskScope
77
import java.util.concurrent.atomic.AtomicReference
88
import scala.annotation.implicitNotFound
99
import ox.channels.BufferCapacity
10+
import ox.internal.ThreadHerd
1011

1112
/** Capability granted by an [[unsupervised]] concurrency scope (as well as, via subtyping, by [[supervised]] and [[supervisedError]]).
1213
*
@@ -22,7 +23,7 @@ import ox.channels.BufferCapacity
2223
"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."
2324
)
2425
trait OxUnsupervised:
25-
private[ox] def scope: StructuredTaskScope[Any]
26+
private[ox] def herd: ThreadHerd
2627
private[ox] def finalizers: AtomicReference[List[() => Unit]]
2728
private[ox] def supervisor: Supervisor[Nothing]
2829
private[ox] def addFinalizer(f: () => Unit): Unit = finalizers.updateAndGet(f :: _).discard
@@ -68,16 +69,16 @@ private[ox] trait RunInScope:
6869
"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."
6970
)
7071
case class OxError[E, F[_]](
71-
private[ox] val scope: StructuredTaskScope[Any],
72+
private[ox] val herd: ThreadHerd,
7273
private[ox] val finalizers: AtomicReference[List[() => Unit]],
7374
private[ox] val supervisor: Supervisor[E],
7475
private[ox] val errorMode: ErrorMode[E, F]
7576
) extends Ox:
7677
override private[ox] def asNoErrorMode: OxError[Nothing, [T] =>> T] =
7778
if errorMode == NoErrorMode then this.asInstanceOf[OxError[Nothing, [T] =>> T]]
78-
else OxError(scope, finalizers, supervisor, NoErrorMode)
79+
else OxError(herd, finalizers, supervisor, NoErrorMode)
7980
end OxError
8081

8182
object OxError:
8283
def apply[E, F[_]](s: Supervisor[E], em: ErrorMode[E, F]): OxError[E, F] =
83-
OxError(DoNothingScope[Any](oxThreadFactory), new AtomicReference(Nil), s, em)
84+
OxError(ThreadHerd(oxThreadFactory), new AtomicReference(Nil), s, em)

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

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,16 @@ def forkError[E, F[_], T](using OxError[E, F])(f: => F[T]): Fork[T] =
3434
val oxError = summon[OxError[E, F]]
3535
// 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)
3636
val result = new CompletableFuture[T]()
37-
oxError.scope.fork { () =>
37+
38+
oxError.herd.startThread:
3839
val supervisor = oxError.supervisor
3940
try
4041
val resultOrError = f
4142
val errorMode = oxError.errorMode
4243
if errorMode.isError(resultOrError) then
4344
// result is never completed, the supervisor should end the scope
4445
supervisor.forkAppError(errorMode.getError(resultOrError))
45-
else result.complete(errorMode.getT(resultOrError))
46+
else result.complete(errorMode.getT(resultOrError)).discard
4647
catch
4748
case e: Throwable =>
4849
// we notify the supervisor first, so that if this is the first failing fork in the scope, the supervisor will
@@ -51,7 +52,7 @@ def forkError[E, F[_], T](using OxError[E, F])(f: => F[T]): Fork[T] =
5152
// completing the result; any joins will end up being interrupted
5253
if !supervisor.forkException(e) then result.completeExceptionally(e).discard
5354
end try
54-
}
55+
5556
new ForkUsingResult(result) {}
5657
end forkError
5758

@@ -81,7 +82,8 @@ def forkUserError[E, F[_], T](using OxError[E, F])(f: => F[T]): Fork[T] =
8182
val oxError = summon[OxError[E, F]]
8283
val result = new CompletableFuture[T]()
8384
oxError.supervisor.forkStarts()
84-
oxError.scope.fork { () =>
85+
86+
oxError.herd.startThread:
8587
val supervisor = oxError.supervisor.asInstanceOf[DefaultSupervisor[E]]
8688
try
8789
val resultOrError = f
@@ -96,7 +98,7 @@ def forkUserError[E, F[_], T](using OxError[E, F])(f: => F[T]): Fork[T] =
9698
case e: Throwable =>
9799
if !supervisor.forkException(e) then result.completeExceptionally(e).discard
98100
end try
99-
}
101+
100102
new ForkUsingResult(result) {}
101103
end forkUserError
102104

@@ -112,11 +114,13 @@ end forkUserError
112114
*/
113115
def forkUnsupervised[T](f: => T)(using OxUnsupervised): UnsupervisedFork[T] =
114116
val result = new CompletableFuture[T]()
115-
summon[OxUnsupervised].scope.fork { () =>
116-
try result.complete(f)
117-
catch case e: Throwable => result.completeExceptionally(e)
118-
}
117+
118+
summon[OxUnsupervised].herd.startThread:
119+
try result.complete(f).discard
120+
catch case e: Throwable => result.completeExceptionally(e).discard
121+
119122
new ForkUsingResult(result) with UnsupervisedFork[T] {}
123+
end forkUnsupervised
120124

121125
/** For each thunk in the given sequence, starts a fork using [[fork]]. All forks are guaranteed to complete before the enclosing
122126
* [[supervised]] or [[unsupervised]] block completes.
@@ -149,21 +153,27 @@ def forkCancellable[T](f: => T)(using OxUnsupervised): CancellableFork[T] =
149153
// interrupt signal
150154
val done = new Semaphore(0)
151155
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
156+
157+
ox.herd.startThread:
158+
try
159+
val nestedOx = OxError(NoOpSupervisor, NoErrorMode)
160+
scopedWithCapability(nestedOx) {
161+
nestedOx.herd.startThread {
162+
// "else" means that the fork is already cancelled, so doing nothing in that case
163+
if !started.getAndSet(true) then
164+
try result.complete(f).discard
165+
catch case e: Throwable => result.completeExceptionally(e).discard
166+
167+
done.release() // the nested scope can now finish
168+
}.discard
169+
170+
done.acquire()
162171
}
172+
catch
173+
// if this thread was interrupted, any context is already captured as part of the thread started in the nested scope
174+
// hence, ignoring this exception, so that it's not logged by the uncaught exception handler
175+
case e: InterruptedException =>
163176

164-
done.acquire()
165-
}
166-
}
167177
new ForkUsingResult(result) with CancellableFork[T]:
168178
override def cancel(): Either[Throwable, T] =
169179
cancelNow()
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package ox.internal
2+
3+
import ox.discard
4+
5+
import java.util.concurrent.ConcurrentHashMap
6+
import java.util.concurrent.ThreadFactory
7+
import java.util.concurrent.atomic.AtomicBoolean
8+
9+
/** Until the structured concurrency JEP is stable in an LTS release, a replacement for `StructuredTaskScope`. Downside: does not integrate
10+
* with scoped values. Upside: works with any Java 21+.
11+
*
12+
* Naming: `ThreadFlock` is part of the Structured Concurrency JEP; `ThreadGroup` is already part of the JDK. Hence, using "herd."
13+
*/
14+
private[ox] class ThreadHerd(threadFactory: ThreadFactory):
15+
private val herdOwner = Thread.currentThread()
16+
17+
private val shutdownInProgress = new AtomicBoolean(false)
18+
private val threads = ConcurrentHashMap.newKeySet[Thread]()
19+
20+
def startThread(t: => Unit): Unit =
21+
assertNotShuttingDown()
22+
assertOnOwnerOrHerdThread()
23+
24+
val thread = threadFactory.newThread(() =>
25+
try t
26+
finally threads.remove(Thread.currentThread()).discard
27+
)
28+
threads.add(thread)
29+
30+
thread.start()
31+
end startThread
32+
33+
/** Interrupts all thread in this heard, and waits for them to complete. If this process is interrupted, the exception is captured and
34+
* rethrown only after all threads from the herd have completed.
35+
*/
36+
def interruptAllAndJoinUntilCompleted(): Unit =
37+
assertOnOwnerThread()
38+
39+
shutdownInProgress.set(true)
40+
41+
/*
42+
If a startThread() is in progress, after the no-shutdown-assertion, the thread that it creates will be discovered
43+
in the loop below, as the thread that is starting the new thread itself has to be joined; and this can only happen
44+
after the new thread is added to the `threads` set.
45+
*/
46+
47+
var interruptedThreads = Set.empty[Thread] // making sure we interrupt each thread only once
48+
def interruptIfNeeded(t: Thread): Unit = if !interruptedThreads.contains(t) then
49+
t.interrupt()
50+
interruptedThreads += t
51+
52+
var interruptedException: InterruptedException = null
53+
54+
while !threads.isEmpty do
55+
threads.forEach(interruptIfNeeded)
56+
threads.forEach: thread =>
57+
interruptIfNeeded(thread) // double-checking, as this thread might have been added after the previous forEach
58+
try thread.join()
59+
catch
60+
case e: InterruptedException => // if the thread is not yet done, we'll join it again in the next iteration
61+
if interruptedException == null then interruptedException = e
62+
else interruptedException.addSuppressed(e)
63+
end while
64+
65+
if interruptedException != null then throw interruptedException
66+
end interruptAllAndJoinUntilCompleted
67+
68+
private def assertNotShuttingDown(): Unit =
69+
if shutdownInProgress.get() then
70+
throw new IllegalStateException("Scope is shutting down, cannot start new threads or join existing ones.")
71+
72+
private def assertOnOwnerOrHerdThread(): Unit =
73+
val current = Thread.currentThread()
74+
if current != herdOwner && !threads.contains(current) then
75+
throw new IllegalStateException("Forks can only be started from threads that are part of the scope.")
76+
77+
private def assertOnOwnerThread(): Unit =
78+
val current = Thread.currentThread()
79+
if current != herdOwner then
80+
throw new IllegalStateException("This operation can only be performed from the thread that created the scope.")
81+
end ThreadHerd

core/src/main/scala/ox/unsupervised.scala

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

3-
import java.util.concurrent.StructuredTaskScope
4-
import java.util.concurrent.ThreadFactory
5-
6-
private class DoNothingScope[T](threadFactory: ThreadFactory) extends StructuredTaskScope[T](null, threadFactory) {}
7-
83
/** Starts a new concurrency scope, which allows starting forks in the given code block `f`. Forks can be started using
94
* [[forkUnsupervised]], and [[forkCancellable]]. All forks are guaranteed to complete before this scope completes.
105
*
@@ -30,7 +25,7 @@ private[ox] def scopedWithCapability[T](capability: Ox)(f: Ox ?=> T): T =
3025
es.tail.foreach(e.addSuppressed)
3126
throw e
3227

33-
val scope = capability.scope
28+
val herd = capability.herd
3429
val finalizers = capability.finalizers
3530
def runFinalizers(result: Either[Throwable, T]): T =
3631
val fs = finalizers.get
@@ -51,17 +46,10 @@ private[ox] def scopedWithCapability[T](capability: Ox)(f: Ox ?=> T): T =
5146
end if
5247
end runFinalizers
5348

54-
// if this is inlined manually (in the program's text), it gets incorrectly indented
55-
inline def runFAndJoinScope =
56-
try f(using capability)
57-
finally
58-
scope.shutdown()
59-
scope.join().discard
60-
6149
try
6250
val t =
63-
try runFAndJoinScope
64-
finally scope.close() // join might have been interrupted, hence the finally
51+
try f(using capability)
52+
finally herd.interruptAllAndJoinUntilCompleted()
6553

6654
// running the finalizers only once we are sure that all child threads have been terminated, so that no new
6755
// finalizers are added, and none are lost

core/src/test/scala/ox/resilience/RateLimiterTest.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ class RateLimiterTest extends AnyFlatSpec with Matchers with EitherValues with T
205205
forkUserDiscard:
206206
result2 = rateLimiter.runOrDrop(operation)
207207
forkUserDiscard:
208+
sleep(500.millis)
208209
result3 = rateLimiter.runBlocking(operation)
209210
forkUserDiscard:
210211
// Wait for next window for 3rd operation to start, take number of operations running

0 commit comments

Comments
 (0)