Skip to content

Commit 5121005

Browse files
authored
Document and tweak the contract of Executor.asCoroutineDispatcher and ExecutorService.asCoroutineDispatcher (#2727)
* Get rid of ThreadPoolDispatcher and PoolThread classes * Reuse the same class for both asCoroutineDispatcher and newFixedThreadPoolContext * Replace 3-classes hierarchy by a single impl class * Copy the auto-closing logic to test source * Document and tweak the contract of Executor.asCoroutineDispatcher and ExecutorService.asCoroutineDispatcher * Document it properly * Make it more robust to signature changes and/or delegation (e.g. see the implementation of java.util.concurrent.Executors.newScheduledThreadPool) * Give a public way to reduce the memory pressure via ScheduledFuture.cancel Fixes #2601
1 parent 623db41 commit 5121005

File tree

5 files changed

+154
-68
lines changed

5 files changed

+154
-68
lines changed

kotlinx-coroutines-core/jvm/src/Executors.kt

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package kotlinx.coroutines
66

7+
import kotlinx.coroutines.flow.*
78
import kotlinx.coroutines.internal.*
89
import java.io.*
910
import java.util.concurrent.*
@@ -39,6 +40,22 @@ public abstract class ExecutorCoroutineDispatcher: CoroutineDispatcher(), Closea
3940
/**
4041
* Converts an instance of [ExecutorService] to an implementation of [ExecutorCoroutineDispatcher].
4142
*
43+
* ## Interaction with [delay] and time-based coroutines.
44+
*
45+
* If the given [ExecutorService] is an instance of [ScheduledExecutorService], then all time-related
46+
* coroutine operations such as [delay], [withTimeout] and time-based [Flow] operators will be scheduled
47+
* on this executor using [schedule][ScheduledExecutorService.schedule] method. If the corresponding
48+
* coroutine is cancelled, [ScheduledFuture.cancel] will be invoked on the corresponding future.
49+
*
50+
* If the given [ExecutorService] is an instance of [ScheduledThreadPoolExecutor], then prior to any scheduling,
51+
* remove on cancel policy will be set via [ScheduledThreadPoolExecutor.setRemoveOnCancelPolicy] in order
52+
* to reduce the memory pressure of cancelled coroutines.
53+
*
54+
* If the executor service is neither of this types, the separate internal thread will be used to
55+
* _track_ the delay and time-related executions, but the coroutine itself will still be executed
56+
* on top of the given executor.
57+
*
58+
* ## Rejected execution
4259
* If the underlying executor throws [RejectedExecutionException] on
4360
* attempt to submit a continuation task (it happens when [closing][ExecutorCoroutineDispatcher.close] the
4461
* resulting dispatcher, on underlying executor [shutdown][ExecutorService.shutdown], or when it uses limited queues),
@@ -52,6 +69,23 @@ public fun ExecutorService.asCoroutineDispatcher(): ExecutorCoroutineDispatcher
5269
/**
5370
* Converts an instance of [Executor] to an implementation of [CoroutineDispatcher].
5471
*
72+
* ## Interaction with [delay] and time-based coroutines.
73+
*
74+
* If the given [Executor] is an instance of [ScheduledExecutorService], then all time-related
75+
* coroutine operations such as [delay], [withTimeout] and time-based [Flow] operators will be scheduled
76+
* on this executor using [schedule][ScheduledExecutorService.schedule] method. If the corresponding
77+
* coroutine is cancelled, [ScheduledFuture.cancel] will be invoked on the corresponding future.
78+
*
79+
* If the given [Executor] is an instance of [ScheduledThreadPoolExecutor], then prior to any scheduling,
80+
* remove on cancel policy will be set via [ScheduledThreadPoolExecutor.setRemoveOnCancelPolicy] in order
81+
* to reduce the memory pressure of cancelled coroutines.
82+
*
83+
* If the executor is neither of this types, the separate internal thread will be used to
84+
* _track_ the delay and time-related executions, but the coroutine itself will still be executed
85+
* on top of the given executor.
86+
*
87+
* ## Rejected execution
88+
*
5589
* If the underlying executor throws [RejectedExecutionException] on
5690
* attempt to submit a continuation task (it happens when [closing][ExecutorCoroutineDispatcher.close] the
5791
* resulting dispatcher, on underlying executor [shutdown][ExecutorService.shutdown], or when it uses limited queues),
@@ -75,18 +109,15 @@ private class DispatcherExecutor(@JvmField val dispatcher: CoroutineDispatcher)
75109
override fun toString(): String = dispatcher.toString()
76110
}
77111

78-
private class ExecutorCoroutineDispatcherImpl(override val executor: Executor) : ExecutorCoroutineDispatcherBase() {
79-
init {
80-
initFutureCancellation()
81-
}
82-
}
112+
internal class ExecutorCoroutineDispatcherImpl(override val executor: Executor) : ExecutorCoroutineDispatcher(), Delay {
83113

84-
internal abstract class ExecutorCoroutineDispatcherBase : ExecutorCoroutineDispatcher(), Delay {
85-
86-
private var removesFutureOnCancellation: Boolean = false
87-
88-
internal fun initFutureCancellation() {
89-
removesFutureOnCancellation = removeFutureOnCancel(executor)
114+
/*
115+
* Attempts to reflectively (to be Java 6 compatible) invoke
116+
* ScheduledThreadPoolExecutor.setRemoveOnCancelPolicy in order to cleanup
117+
* internal scheduler queue on cancellation.
118+
*/
119+
init {
120+
removeFutureOnCancel(executor)
90121
}
91122

92123
override fun dispatch(context: CoroutineContext, block: Runnable) {
@@ -99,17 +130,12 @@ internal abstract class ExecutorCoroutineDispatcherBase : ExecutorCoroutineDispa
99130
}
100131
}
101132

102-
/*
103-
* removesFutureOnCancellation is required to avoid memory leak.
104-
* On Java 7+ we reflectively invoke ScheduledThreadPoolExecutor.setRemoveOnCancelPolicy(true) and we're fine.
105-
* On Java 6 we're scheduling time-based coroutines to our own thread safe heap which supports cancellation.
106-
*/
107133
override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
108-
val future = if (removesFutureOnCancellation) {
109-
scheduleBlock(ResumeUndispatchedRunnable(this, continuation), continuation.context, timeMillis)
110-
} else {
111-
null
112-
}
134+
val future = (executor as? ScheduledExecutorService)?.scheduleBlock(
135+
ResumeUndispatchedRunnable(this, continuation),
136+
continuation.context,
137+
timeMillis
138+
)
113139
// If everything went fine and the scheduling attempt was not rejected -- use it
114140
if (future != null) {
115141
continuation.cancelFutureOnCancellation(future)
@@ -120,20 +146,16 @@ internal abstract class ExecutorCoroutineDispatcherBase : ExecutorCoroutineDispa
120146
}
121147

122148
override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle {
123-
val future = if (removesFutureOnCancellation) {
124-
scheduleBlock(block, context, timeMillis)
125-
} else {
126-
null
127-
}
149+
val future = (executor as? ScheduledExecutorService)?.scheduleBlock(block, context, timeMillis)
128150
return when {
129151
future != null -> DisposableFutureHandle(future)
130152
else -> DefaultExecutor.invokeOnTimeout(timeMillis, block, context)
131153
}
132154
}
133155

134-
private fun scheduleBlock(block: Runnable, context: CoroutineContext, timeMillis: Long): ScheduledFuture<*>? {
156+
private fun ScheduledExecutorService.scheduleBlock(block: Runnable, context: CoroutineContext, timeMillis: Long): ScheduledFuture<*>? {
135157
return try {
136-
(executor as? ScheduledExecutorService)?.schedule(block, timeMillis, TimeUnit.MILLISECONDS)
158+
schedule(block, timeMillis, TimeUnit.MILLISECONDS)
137159
} catch (e: RejectedExecutionException) {
138160
cancelJobOnRejection(context, e)
139161
null
@@ -149,7 +171,7 @@ internal abstract class ExecutorCoroutineDispatcherBase : ExecutorCoroutineDispa
149171
}
150172

151173
override fun toString(): String = executor.toString()
152-
override fun equals(other: Any?): Boolean = other is ExecutorCoroutineDispatcherBase && other.executor === executor
174+
override fun equals(other: Any?): Boolean = other is ExecutorCoroutineDispatcherImpl && other.executor === executor
153175
override fun hashCode(): Int = System.identityHashCode(executor)
154176
}
155177

kotlinx-coroutines-core/jvm/src/ThreadPoolDispatcher.kt

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -59,40 +59,11 @@ public fun newSingleThreadContext(name: String): ExecutorCoroutineDispatcher =
5959
@ObsoleteCoroutinesApi
6060
public fun newFixedThreadPoolContext(nThreads: Int, name: String): ExecutorCoroutineDispatcher {
6161
require(nThreads >= 1) { "Expected at least one thread, but $nThreads specified" }
62-
return ThreadPoolDispatcher(nThreads, name)
63-
}
64-
65-
internal class PoolThread(
66-
@JvmField val dispatcher: ThreadPoolDispatcher, // for debugging & tests
67-
target: Runnable, name: String
68-
) : Thread(target, name) {
69-
init { isDaemon = true }
70-
}
71-
72-
/**
73-
* Dispatches coroutine execution to a thread pool of a fixed size. Instances of this dispatcher are
74-
* created with [newSingleThreadContext] and [newFixedThreadPoolContext].
75-
*/
76-
internal class ThreadPoolDispatcher internal constructor(
77-
private val nThreads: Int,
78-
private val name: String
79-
) : ExecutorCoroutineDispatcherBase() {
80-
private val threadNo = AtomicInteger()
81-
82-
override val executor: Executor = Executors.newScheduledThreadPool(nThreads) { target ->
83-
PoolThread(this, target, if (nThreads == 1) name else name + "-" + threadNo.incrementAndGet())
84-
}
85-
86-
init {
87-
initFutureCancellation()
62+
val threadNo = AtomicInteger()
63+
val executor = Executors.newScheduledThreadPool(nThreads) { runnable ->
64+
val t = Thread(runnable, if (nThreads == 1) name else name + "-" + threadNo.incrementAndGet())
65+
t.isDaemon = true
66+
t
8867
}
89-
90-
/**
91-
* Closes this dispatcher -- shuts down all threads in this pool and releases resources.
92-
*/
93-
public override fun close() {
94-
(executor as ExecutorService).shutdown()
95-
}
96-
97-
override fun toString(): String = "ThreadPoolDispatcher[$nThreads, $name]"
68+
return executor.asCoroutineDispatcher()
9869
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines
6+
7+
import org.junit.Test
8+
import java.lang.Runnable
9+
import java.util.concurrent.*
10+
import kotlin.test.*
11+
12+
class ExecutorAsCoroutineDispatcherDelayTest : TestBase() {
13+
14+
private var callsToSchedule = 0
15+
16+
private inner class STPE : ScheduledThreadPoolExecutor(1) {
17+
override fun schedule(command: Runnable, delay: Long, unit: TimeUnit): ScheduledFuture<*> {
18+
if (delay != 0L) ++callsToSchedule
19+
return super.schedule(command, delay, unit)
20+
}
21+
}
22+
23+
private inner class SES : ScheduledExecutorService by STPE()
24+
25+
@Test
26+
fun testScheduledThreadPool() = runTest {
27+
val executor = STPE()
28+
withContext(executor.asCoroutineDispatcher()) {
29+
delay(100)
30+
}
31+
executor.shutdown()
32+
assertEquals(1, callsToSchedule)
33+
}
34+
35+
@Test
36+
fun testScheduledExecutorService() = runTest {
37+
val executor = SES()
38+
withContext(executor.asCoroutineDispatcher()) {
39+
delay(100)
40+
}
41+
executor.shutdown()
42+
assertEquals(1, callsToSchedule)
43+
}
44+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines // Trick to make guide tests use these declarations with executors that can be closed on our side implicitly
6+
7+
import java.util.concurrent.*
8+
import java.util.concurrent.atomic.*
9+
import kotlin.coroutines.*
10+
11+
internal fun newSingleThreadContext(name: String): ExecutorCoroutineDispatcher = ClosedAfterGuideTestDispatcher(1, name)
12+
13+
internal fun newFixedThreadPoolContext(nThreads: Int, name: String): ExecutorCoroutineDispatcher =
14+
ClosedAfterGuideTestDispatcher(nThreads, name)
15+
16+
internal class PoolThread(
17+
@JvmField val dispatcher: ExecutorCoroutineDispatcher, // for debugging & tests
18+
target: Runnable, name: String
19+
) : Thread(target, name) {
20+
init {
21+
isDaemon = true
22+
}
23+
}
24+
25+
private class ClosedAfterGuideTestDispatcher(
26+
private val nThreads: Int,
27+
private val name: String
28+
) : ExecutorCoroutineDispatcher() {
29+
private val threadNo = AtomicInteger()
30+
31+
override val executor: Executor =
32+
Executors.newScheduledThreadPool(nThreads, object : ThreadFactory {
33+
override fun newThread(target: java.lang.Runnable): Thread {
34+
return PoolThread(
35+
this@ClosedAfterGuideTestDispatcher,
36+
target,
37+
if (nThreads == 1) name else name + "-" + threadNo.incrementAndGet()
38+
)
39+
}
40+
})
41+
42+
override fun dispatch(context: CoroutineContext, block: Runnable) {
43+
executor.execute(wrapTask(block))
44+
}
45+
46+
override fun close() {
47+
(executor as ExecutorService).shutdown()
48+
}
49+
50+
override fun toString(): String = "ThreadPoolDispatcher[$nThreads, $name]"
51+
}

kotlinx-coroutines-core/jvm/test/knit/TestUtil.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
33
*/
44

55
package kotlinx.coroutines.knit
@@ -11,8 +11,6 @@ import kotlinx.knit.test.*
1111
import java.util.concurrent.*
1212
import kotlin.test.*
1313

14-
fun wrapTask(block: Runnable) = kotlinx.coroutines.wrapTask(block)
15-
1614
// helper function to dump exception to stdout for ease of debugging failed tests
1715
private inline fun <T> outputException(name: String, block: () -> T): T =
1816
try { block() }
@@ -176,4 +174,4 @@ private inline fun List<String>.verify(verification: () -> Unit) {
176174
}
177175
throw t
178176
}
179-
}
177+
}

0 commit comments

Comments
 (0)