Skip to content

runBlocking + Channel deadlocks in GraalVM native image #4680

Description

@JohannesLichtenberger

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).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions