Description
kotlinx.coroutines.runBlocking combined with Channel.receive() deadlocks in GraalVM native image when Channel.trySend() is called from a different thread. The same code works correctly on JVM.
Environment
- GraalVM: Oracle GraalVM 25.0.2+10.1 (Java 25 LTS)
- SubstrateVM: serial gc, compressed references
- kotlinx-coroutines: 1.8.1
- Kotlin: 2.3.20
- OS: Ubuntu 24.04 (linux/amd64)
Minimal reproducer pattern
// Thread A (Vert.x worker thread):
vertx.executeBlocking {
runBlocking {
for (event in channel) { // suspends on channel.receive()
processEvent(event) // blocking DB operation
}
}
}
// Thread B (Vert.x event loop):
parser.handler { event ->
channel.trySend(ShredderEvent(event)) // non-blocking send from different thread
}
Observed behavior
JVM: Works correctly. trySend() from Thread B resumes the suspended receive() continuation on Thread A via BlockingEventLoop.dispatch() → LockSupport.unpark().
GraalVM native image: BlockingCoroutine.joinBlocking parks the worker thread indefinitely via LockSupport.parkNanos. Events sent via trySend() from the event loop thread never resume the suspended coroutine. The thread remains blocked:
WARNING: Thread vert.x-worker-thread-10 has been blocked for 433842 ms
io.vertx.core.VertxException: Thread blocked
at com.oracle.svm.core.posix.headers.Pthread.pthread_cond_timedwait(Pthread.java)
at com.oracle.svm.core.posix.thread.PosixParker.park0(PosixPlatformThreads.java:377)
at jdk.internal.misc.Unsafe.park(Unsafe.java:56)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:271)
at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:98)
at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:69)
...
Workaround
Replacing kotlinx.coroutines.channels.Channel with java.util.concurrent.LinkedBlockingQueue and removing runBlocking resolves the deadlock:
vertx.executeBlocking {
while (true) {
val event = queue.take() // JDK blocking, no coroutines
if (event is StreamEnd) break
processEvent(event)
}
}
LinkedBlockingQueue.take() uses ReentrantLock + Condition.await() which works correctly in native image.
Analysis
The issue appears to be in the cross-thread dispatch path of BlockingEventLoop. When trySend() adds an element to the channel buffer and resumes a waiting receiver, the continuation is dispatched via BlockingEventLoop.dispatch() which calls LockSupport.unpark(parkingThread). In GraalVM native image, this unpark does not reliably wake the thread parked in joinBlocking.
This may be a GraalVM SubstrateVM issue with LockSupport.unpark() cross-thread signaling, or a kotlinx.coroutines issue with how BlockingEventLoop stores/retrieves the parking thread reference in native image.
Impact
This prevents using the standard coroutine Channel pattern for producer-consumer communication in any GraalVM native image application that uses runBlocking.
Project
Encountered in SirixDB — a temporal versioned document store. The streaming JSON shredder uses this pattern for back-pressure between Vert.x HTTP parsing (event loop) and blocking database writes (worker thread).
Description
kotlinx.coroutines.runBlockingcombined withChannel.receive()deadlocks in GraalVM native image whenChannel.trySend()is called from a different thread. The same code works correctly on JVM.Environment
Minimal reproducer pattern
Observed behavior
JVM: Works correctly.
trySend()from Thread B resumes the suspendedreceive()continuation on Thread A viaBlockingEventLoop.dispatch()→LockSupport.unpark().GraalVM native image:
BlockingCoroutine.joinBlockingparks the worker thread indefinitely viaLockSupport.parkNanos. Events sent viatrySend()from the event loop thread never resume the suspended coroutine. The thread remains blocked:Workaround
Replacing
kotlinx.coroutines.channels.Channelwithjava.util.concurrent.LinkedBlockingQueueand removingrunBlockingresolves the deadlock:vertx.executeBlocking { while (true) { val event = queue.take() // JDK blocking, no coroutines if (event is StreamEnd) break processEvent(event) } }LinkedBlockingQueue.take()usesReentrantLock+Condition.await()which works correctly in native image.Analysis
The issue appears to be in the cross-thread dispatch path of
BlockingEventLoop. WhentrySend()adds an element to the channel buffer and resumes a waiting receiver, the continuation is dispatched viaBlockingEventLoop.dispatch()which callsLockSupport.unpark(parkingThread). In GraalVM native image, thisunparkdoes not reliably wake the thread parked injoinBlocking.This may be a GraalVM SubstrateVM issue with
LockSupport.unpark()cross-thread signaling, or a kotlinx.coroutines issue with howBlockingEventLoopstores/retrieves the parking thread reference in native image.Impact
This prevents using the standard coroutine Channel pattern for producer-consumer communication in any GraalVM native image application that uses
runBlocking.Project
Encountered in SirixDB — a temporal versioned document store. The streaming JSON shredder uses this pattern for back-pressure between Vert.x HTTP parsing (event loop) and blocking database writes (worker thread).