JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.02k stars 1.1k forks source link

Using `ComposeScene`s with anything but `MainUIDispatcher` results in a deadlock #4788

Open jakobkmar opened 3 weeks ago

jakobkmar commented 3 weeks ago

Describe the bug

MultiLayerComposeScene and SingleLayerComposeScene provide the option to set a coroutineContext parameter. FrameDispatcher also provides the option to pass a CoroutineScope or CoroutineContext.

However, using a scene with any other dispatcher other than MainUIDispatcher (provided by Skiko) will result in deadlock at some point when using the application.

Since the render call on a scene must also be called from the same thread, this also means that the FrameDispatcher must use MainUIDispatcher as well.

This causes several issues. For example, GLFW requires the use of a specific thread to be able to draw to a Window, which clashes with the requirement for MainUIDispatcher which is an AWT event queue.

The following seems to be unsupported for now: I consider this a major bug, since this makes it impossible to render multiple scenes at once using different threads (as MainUIDispatcher is always the same). Multiple scenes are needed if you wish to render multiple completely separate scenes in one Kotlin application. The coroutineContext paramters are also completly obsolete this way.

Please note: In past Compose versions (for example 1.2.0) ComposeScene worked fine with other dispatchers, so this is a new bug which has been introduced with recent updates.

To Reproduce

  1. create a custom MultiLayerComposeScene
  2. pass a Dispatchers.Default.limitedParallelism(1) to it
  3. use the same limited dispatcher for the FrameDispatcher
  4. render the scene continuously using the FrameDispatcher
  5. do something, e.g. scroll a lot etc
  6. observe deadlock in IntelliJ ThreadDump using debugger

Deadlock:

image

Detailed Thread Dump Stack Traces ``` "AWT-EventQueue-0 @coroutine#8@24000" tid=0x9a nid=NA waiting for monitor entry java.lang.Thread.State: BLOCKED blocks DefaultDispatcher-worker-5 @coroutine#5@23693 waiting for DefaultDispatcher-worker-5 @coroutine#5@23693 to release lock on <0x5e17> (a androidx.compose.runtime.SynchronizedObject) at androidx.compose.runtime.BroadcastFrameClock.getHasAwaiters(Synchronization.kt:33) at androidx.compose.runtime.Recomposer.getHasBroadcastFrameClockAwaitersLocked(Recomposer.kt:289) at androidx.compose.runtime.Recomposer.deriveStateLocked(Recomposer.kt:326) at androidx.compose.runtime.Recomposer.access$deriveStateLocked(Recomposer.kt:127) at androidx.compose.runtime.Recomposer$recompositionRunner$2$unregisterApplyObserver$1.invoke(Recomposer.kt:989) - locked <0x5e19> (a androidx.compose.runtime.SynchronizedObject) at androidx.compose.runtime.Recomposer$recompositionRunner$2$unregisterApplyObserver$1.invoke(Recomposer.kt:976) at androidx.compose.runtime.snapshots.SnapshotKt.advanceGlobalSnapshot(Snapshot.kt:1816) at androidx.compose.runtime.snapshots.SnapshotKt.advanceGlobalSnapshot(Snapshot.kt:1831) at androidx.compose.runtime.snapshots.SnapshotKt.access$advanceGlobalSnapshot(Snapshot.kt:1) at androidx.compose.runtime.snapshots.Snapshot$Companion.sendApplyNotifications(Snapshot.kt:584) at androidx.compose.ui.platform.GlobalSnapshotManager$ensureStarted$1.invokeSuspend(GlobalSnapshotManager.skiko.kt:46) ``` ``` "DefaultDispatcher-worker-5 @coroutine#5@23693" tid=0x73 nid=NA waiting for monitor entry java.lang.Thread.State: BLOCKED blocks AWT-EventQueue-0 @coroutine#8@24000 waiting for AWT-EventQueue-0 @coroutine#8@24000 to release lock on <0x5e19> (a androidx.compose.runtime.SynchronizedObject) at androidx.compose.runtime.Recomposer.composeInitial$runtime(Synchronization.kt:33) at androidx.compose.runtime.ComposerImpl$CompositionContextImpl.composeInitial$runtime(Composer.kt:3600) at androidx.compose.runtime.CompositionImpl.composeInitial(Composition.kt:633) at androidx.compose.runtime.CompositionImpl.setContentWithReuse(Composition.kt:625) at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcomposeInto(SubcomposeLayout.kt:502) at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcompose(SubcomposeLayout.kt:472) at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcompose(SubcomposeLayout.kt:463) at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcompose(SubcomposeLayout.kt:447) at androidx.compose.ui.layout.LayoutNodeSubcompositionsState$Scope.subcompose(SubcomposeLayout.kt:872) at androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScopeImpl.measure-0kLqBqw(LazyLayoutMeasureScope.kt:125) at androidx.compose.foundation.lazy.LazyListMeasuredItemProvider.getAndMeasure(LazyListMeasuredItemProvider.kt:48) at androidx.compose.foundation.lazy.LazyListMeasureKt.measureLazyList-5IMabDg(LazyListMeasure.kt:195) at androidx.compose.foundation.lazy.LazyListKt$rememberLazyListMeasurePolicy$1$1.invoke-0kLqBqw(LazyList.kt:313) at androidx.compose.foundation.lazy.LazyListKt$rememberLazyListMeasurePolicy$1$1.invoke(LazyList.kt:178) at androidx.compose.foundation.lazy.layout.LazyLayoutKt$LazyLayout$3$2$1.invoke-0kLqBqw(LazyLayout.kt:107) at androidx.compose.foundation.lazy.layout.LazyLayoutKt$LazyLayout$3$2$1.invoke(LazyLayout.kt:100) at androidx.compose.ui.layout.LayoutNodeSubcompositionsState$createMeasurePolicy$1.measure-3p2s80s(SubcomposeLayout.kt:709) at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:126) at androidx.compose.ui.graphics.SimpleGraphicsLayerModifier.measure-3p2s80s(GraphicsLayerModifier.kt:646) at androidx.compose.ui.node.LayoutModifierNodeCoordinator.measure-BRTryo0(LayoutModifierNodeCoordinator.kt:116) at androidx.compose.ui.node.LayoutNodeLayoutDelegate$performMeasureBlock$1.invoke(LayoutNodeLayoutDelegate.kt:252) at androidx.compose.ui.node.LayoutNodeLayoutDelegate$performMeasureBlock$1.invoke(LayoutNodeLayoutDelegate.kt:251) at androidx.compose.runtime.snapshots.Snapshot$Companion.observe(Snapshot.kt:132) at androidx.compose.runtime.snapshots.SnapshotStateObserver$ObservedScopeMap.observe(SnapshotStateObserver.kt:504) at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:260) at androidx.compose.ui.node.OwnerSnapshotObserver.observeReads$ui(OwnerSnapshotObserver.kt:133) at androidx.compose.ui.node.OwnerSnapshotObserver.observeMeasureSnapshotReads$ui(OwnerSnapshotObserver.kt:113) at androidx.compose.ui.node.LayoutNodeLayoutDelegate.performMeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:1617) at androidx.compose.ui.node.LayoutNodeLayoutDelegate.access$performMeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:36) at androidx.compose.ui.node.LayoutNodeLayoutDelegate$MeasurePassDelegate.remeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:620) at androidx.compose.ui.node.LayoutNode.remeasure-_Sx5XlM$ui(LayoutNode.kt:1145) at androidx.compose.ui.node.MeasureAndLayoutDelegate.doRemeasure-sdFAvZA(MeasureAndLayoutDelegate.kt:354) at androidx.compose.ui.node.MeasureAndLayoutDelegate.measureAndLayout-0kLqBqw(MeasureAndLayoutDelegate.kt:439) at androidx.compose.ui.node.RootNodeOwner$OwnerImpl.measureAndLayout-0kLqBqw(RootNodeOwner.skiko.kt:322) at androidx.compose.ui.node.LayoutNode.forceRemeasure(LayoutNode.kt:1219) at androidx.compose.foundation.lazy.LazyListState.onScroll$foundation(LazyListState.kt:352) at androidx.compose.foundation.lazy.LazyListState$scrollableState$1.invoke(LazyListState.kt:187) at androidx.compose.foundation.lazy.LazyListState$scrollableState$1.invoke(LazyListState.kt:187) at androidx.compose.foundation.gestures.DefaultScrollableState$scrollScope$1.scrollBy(ScrollableState.kt:166) at androidx.compose.foundation.gestures.ScrollingLogic$dispatchScroll$performScroll$1.invoke-MK-Hz9U(Scrollable.kt:693) at androidx.compose.foundation.gestures.ScrollingLogic$dispatchScroll$performScroll$1.invoke(Scrollable.kt:683) at androidx.compose.foundation.gestures.ScrollingLogic.dispatchScroll-3eAAhYA(Scrollable.kt:707) at androidx.compose.foundation.gestures.MouseWheelScrollNode.dispatchMouseWheelScroll(MouseWheelScrollable.kt:312) at androidx.compose.foundation.gestures.MouseWheelScrollNode.access$dispatchMouseWheelScroll(MouseWheelScrollable.kt:54) at androidx.compose.foundation.gestures.MouseWheelScrollNode$animateMouseWheelScroll$2.invoke(MouseWheelScrollable.kt:297) at androidx.compose.foundation.gestures.MouseWheelScrollNode$animateMouseWheelScroll$2.invoke(MouseWheelScrollable.kt:287) at androidx.compose.animation.core.SuspendAnimationKt.doAnimationFrame(SuspendAnimation.kt:361) at androidx.compose.animation.core.SuspendAnimationKt.doAnimationFrameWithScale(SuspendAnimation.kt:339) at androidx.compose.animation.core.SuspendAnimationKt.access$doAnimationFrameWithScale(SuspendAnimation.kt:1) at androidx.compose.animation.core.SuspendAnimationKt$animate$9.invoke(SuspendAnimation.kt:279) at androidx.compose.animation.core.SuspendAnimationKt$animate$9.invoke(SuspendAnimation.kt:278) at androidx.compose.animation.core.SuspendAnimationKt$callWithFrameNanos$2.invoke(SuspendAnimation.kt:304) at androidx.compose.animation.core.SuspendAnimationKt$callWithFrameNanos$2.invoke(SuspendAnimation.kt:303) at androidx.compose.runtime.BroadcastFrameClock$FrameAwaiter.resume(BroadcastFrameClock.kt:42) at androidx.compose.runtime.BroadcastFrameClock.sendFrame(BroadcastFrameClock.kt:71) - locked <0x5e17> (a androidx.compose.runtime.SynchronizedObject) at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke(Recomposer.kt:558) at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke(Recomposer.kt:551) at androidx.compose.runtime.BroadcastFrameClock$FrameAwaiter.resume(BroadcastFrameClock.kt:42) at androidx.compose.runtime.BroadcastFrameClock.sendFrame(BroadcastFrameClock.kt:71) - locked <0x5e18> (a androidx.compose.runtime.SynchronizedObject) at androidx.compose.ui.scene.BaseComposeScene.render(BaseComposeScene.skiko.kt:160) ```

Expected behavior

It should be possible to pass a custom dispatcher to the scene APIs. E.g. your own Dispatchers.Default.limitedParallelism(1). Alternatively, it could be possible to configure what MainUIDispatcher actually is under the hood.

This is needed for having multiple and completely separate scenes at once in one Kotlin application. (not supported, but it is still needed for reasons explained above)

Custom scenes should not depend on the AWT event queue, since they won't be rendered to an AWT or Swing Window anyways.

Affected platforms

Versions

Additional context

m-sasha commented 3 weeks ago

Unfortunately this is not currently a supported use-case.

We tried to allow concurrent use of ComposeScene here, but ran into limitations (mentioned in that ticket).

It's possible that we have since introduced our own, additional, limitations, since we've stopped trying to support concurrent use of ComposeScene ourselves.

jakobkmar commented 3 weeks ago

Hasn't the linked issue been caused by multiple threads being used for the same scene due to Dispatchers.Unconfined being the default context for ImageComposeScene? If yes, then this is not what I want. I do not want to use the same scene concurrently, but multiple scenes each on their own separate (but only that single) thread.

m-sasha commented 3 weeks ago

No, it's about using multiple scenes concurrently.

Why do you need to use multiple ComposeScenes each in their own thread?

jakobkmar commented 3 weeks ago

I have been using multiple scenes for a server-side GUI, where the server handles multiple users at once.

The performance degraded severely now that everything has to use MainUIDispatcher.

m-sasha commented 3 weeks ago

Can you try using a single-thread dispatcher instead of Dispatchers.Default.limitedParallelism(1), e.g.

Executors.newSingleThreadExecutor().asCoroutineDispatcher()

?

jakobkmar commented 3 weeks ago

Yes, I tried that as well - it results in the same deadlock between that single-thread and the AWT event queue.

jakobkmar commented 3 weeks ago

Also, does this mean that using Compose to render into GLFW windows (for example using LWJGL) is unsupported now as well? (you have to use the glfw thread there, which clashes with the requirement for MainUIDispatcher)

m-sasha commented 2 weeks ago

We can't support this use-case well because of the aforementioned limitations upstream (i.e. you could still get deadlocks before), and it's complicated to support it badly.

jakobkmar commented 2 weeks ago

The LWJGL thing mentioned in my last comment is something different though, no concurrent use of ComposeScenes, still not possible anymore due to the newly introduced requirement for MainUIDispatcher. Is this use-case also not supported, even though there is only a single ComposeScene in use?

I don't think it caused any issues or deadlocks before - it was basically the same just with the difference that the entire logic did not have to run on MainUIDispatcher (= AWT event queue).

No multithreading there, no concurrent scenes - it simply needs a different (single) thread because you cannot start a GLFW window in the AWT event queue.

m-sasha commented 2 weeks ago

Is this use-case also not supported, even though there is only a single ComposeScene in use?

@igordmn Can you answer? Is this a use-case we need to support?

I don't think it caused any issues or deadlocks before

It did; perhaps you just haven't encountered them. See the issues described here: https://github.com/JetBrains/compose-multiplatform/issues/1396

jakobkmar commented 2 weeks ago

In all shared code snippets in the linked issue there are always thousand concurrent scenes started on different threads. I am not sure how one scene on a single thread could lead to the same issues described there.

m-sasha commented 2 weeks ago

There are global structures in Compose that are shared between all instances, as explained in these two tickets: https://issuetracker.google.com/issues/283162626 https://issuetracker.google.com/issues/283216580

jakobkmar commented 2 weeks ago

But I am not talking about multiple instances right now. Only a single scene, a single application, a single thread - so only one instance.

The main issue is that a requirement for MainUIDispatcher was introduced, which is not related to the concurrent scene issue. For example, requiring MainUIDispatcher makes Compose not usable with GLFW anymore, which it previously was. This is not at all related to the concurrent scene issues, since there are no concurrent scenes in a single thread and single scene GLFW application.

I opened this issue because of the requirement for MainUIDispatcher, not because of scene concurrency.

jakobkmar commented 2 weeks ago

Also, this is not an upstream issue, since upstream does not work with the AWT event queue and therefore cannot be the reason why it is required (except if somewhere in upstream Compose the coroutineContext is ignored and MainUIDispatcher has been hardcoded).

m-sasha commented 2 weeks ago

I don't know whether supporting GLFW is an important goal for us. @igordmn ?

MatkovIvan commented 2 weeks ago

We discussed it with @igordmn recently - it was a conscious, explicit decision to not depend on a specific dispatcher. ComposeScene should allow to be reused in GLFW or JavaFX environments. So I guess the issue is valid

m-sasha commented 2 weeks ago

The problem is likely that GlobalSnapshotManager runs on the AWT thread.

igordmn commented 2 weeks ago

ComposeScene is by design should be independent of any hardcoded Main thread. The issue that it interferes with GlobalSnapshotManager and have races with it is a Compose Runtime bug that needs to be fixed. Though, it is not trivial, and isn't in priority unfortunately.

A simple alternative fix can be adding ability to redefine GlobalSnapshotManager's dispatcher

igordmn commented 2 weeks ago

A simple alternative fix can be adding ability to redefine GlobalSnapshotManager's dispatcher

Or:

  1. make a flag as suggested here
  2. make a @InternalComposeUiApi public fun startGlobalSnapshotManager(coroutineDispatcher)