Skip to content

refactor(RimeDispatcher): use SpscArrayQueue to improve performance#2030

Open
WhiredPlanck wants to merge 1 commit into
osfans:developfrom
WhiredPlanck:queue
Open

refactor(RimeDispatcher): use SpscArrayQueue to improve performance#2030
WhiredPlanck wants to merge 1 commit into
osfans:developfrom
WhiredPlanck:queue

Conversation

@WhiredPlanck

Copy link
Copy Markdown
Collaborator

Pull request

Issue tracker

Fixes will automatically close the related issues

Fixes #N/A

Feature

Describe features of this pull request

JCTools is a lightweight library providing high performance lockless queue such as SpscArrayQueue, while "Spsc" means "Single producer and single consumer". It's more appropriate than ConcurrentLinkedQueue or LinkedBlockingQueue for the dedicated event dispatcher, in our case, RimeDispatcher, to process API calling events.

Code of conduct

Code style

Build pass

  • make debug

Manually test

  • Done

Code Review

  1. No wildcards import
  2. Manual build and test pass
  3. GitHub Action CI pass
  4. At least one contributor review and approve
  5. Merged clean without conflicts
  6. PR will be merged by rebase upstream base

Daily build

Login and download artifact at https://github.com/osfans/trime/actions

Additional Info

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces the JCTools queue implementation to optimize RimeDispatcher’s internal job dispatching path, aiming to reduce overhead compared to standard Java concurrent queues.

Changes:

  • Add org.jctools:jctools-core to the version catalog and app dependencies.
  • Refactor RimeDispatcher to use SpscArrayQueue plus a Semaphore-based wakeup mechanism instead of a blocking queue.
  • Adjust dispatcher stop/unblock behavior to work with the new queueing model.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
gradle/libs.versions.toml Adds the jctools-core library entry to the version catalog.
app/build.gradle.kts Adds jctools-core as an app implementation dependency.
app/src/main/java/com/osfans/trime/core/RimeDispatcher.kt Switches dispatcher queueing from a blocking queue to SpscArrayQueue + Semaphore signaling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/src/main/java/com/osfans/trime/core/RimeDispatcher.kt
Comment thread app/src/main/java/com/osfans/trime/core/RimeDispatcher.kt
@Bambooin

Copy link
Copy Markdown
Collaborator

Trime is frozen with #2030 and #2029 patches, I built with these two patches together.

@WhiredPlanck

Copy link
Copy Markdown
Collaborator Author

Trime is frozen with #2030 and #2029 patches, I built with these two patches together.

Frozen? You mean the app cannot response with user's interaction?

@Bambooin

Copy link
Copy Markdown
Collaborator

Yes.

Case one:

  1. Install the build.
  2. Open the Trime.
  3. The UI is frozen.

Case two:

  1. Install the build.
  2. Try to input something in Firefox.
  3. No keyboard shows up.

@WhiredPlanck

Copy link
Copy Markdown
Collaborator Author

Yes.

Case one:

  1. Install the build.
  2. Open the Trime.
  3. The UI is frozen.

Case two:

  1. Install the build.
  2. Try to input something in Firefox.
  3. No keyboard shows up.

I connot reproduce. Everything is OK with default schemas and themes. I need time to take investigate, but I'm on a business trip.

@goofyz

goofyz commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Copied from copilot analysis:

Now let me analyze the PR changes in detail:

Potential Deadlock Analysis

Yes, there is a critical deadlock risk in this PR. Here's the issue:

The Problem

The refactored code introduces a classic semaphore + queue signaling deadlock pattern:

// In the dispatcher loop (lines 88-101 in the PR)
while (isActive && isRunning.get()) {
    semaphore.acquireUninterruptibly()  // ⚠️ Blocks waiting
    while (true) {
        val block = queue.poll() ?: break
        block.run()
    }
}

And in stop() (lines 109-118):

if (isRunning.compareAndSet(true, false)) {
    runBlocking {  // ⚠️ Blocks on the calling thread
        semaphore.release()
        mutex.withLock {  // ⚠️ Tries to acquire mutex
            val rest = queue.toList()
            queue.clear()
        }
    }
}

The Deadlock Scenario

  1. Thread 1 (dispatcher/rime-main thread): Is in the while (isActive && isRunning.get()) loop, executing a job in block.run()
  2. Thread 2 (caller): Calls stop() from another thread and acquires the mutex.withLock
  3. Thread 1 finishes the job, tries to exit the inner while loop and loops back to semaphore.acquireUninterruptibly()
  4. Thread 1 now tries to enter mutex.withLock { ... } (which was implicitly held during the start loop)
  5. Deadlock: Thread 1 waits for mutex (held by Thread 2), Thread 2 waits for Thread 1 to finish (via runBlocking)

Root Cause

The original code's queue.take() was blocking and atomic—it naturally blocked the dispatcher thread when the queue was empty. The new pattern splits this into:

  • A semaphore acquire (external synchronization)
  • A queue poll (separate operation)

This breaks the original mutex protection because the dispatcher loop continues to run while stop() holds the mutex.

Recommended Fix

The start() dispatcher loop should not hold the mutex for the entire duration. Instead:

fun start() {
    internalScope.launch {
        if (isRunning.compareAndSet(false, true)) {
            mutex.withLock {
                Timber.d("nativeStartup()")
                controller.nativeStartup()
            }
            try {
                while (isActive && isRunning.get()) {
                    semaphore.acquireUninterruptibly()
                    while (true) {
                        val block = queue.poll() ?: break
                        block.run()
                    }
                }
            } finally {
                mutex.withLock {
                    Timber.i("nativeFinalize()")
                    controller.nativeFinalize()
                }
            }
        }
    }
}

Or better yet: Use a different synchronization mechanism (like CountDownLatch or a monitor variable) instead of Mutex, since the mutex isn't meant for long-duration holds.

@WhiredPlanck

WhiredPlanck commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

@goofyz I just adapted from fcitx5-android, actually I think the queue may not be the worst performance bottleneck now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants