Patrick Jackson · blog

ConcurrentStore: Reads That Don't Wait

1,444 words7 min read#kotlin#kotlin-multiplatform#redux#reduxkotlin#concurrency

Back in 2020 I wrote Redux on Multithreaded Platforms, which ended with a recommendation: use createThreadSafeStore. It fixed a real bug (the counter test that failed horribly when 100 coroutines each dispatched 1000 increments) and it has served people well for six years.

It also had a problem I glossed over at the time. This post is me fixing it.

The bug that wasn’t a bug

ThreadSafeStore is a wrapper that puts synchronized(this) around every function on the store. Every one. dispatch, subscribe, replaceReducer, and getState.

That last one is the problem. It means a read from any thread blocks for the entire duration of an in-flight dispatch, and a dispatch isn’t just the reducer. It’s the reducer plus every listener that dispatch fans out to. If one of those listeners is doing layout work on the main thread, a background thread calling store.state waits for it.

It was thread-safe the way a single-lane bridge is collision-safe. Nothing crashes. Everybody waits.

I even said so in the original KDoc (“This does have a performance impact for JVM/Native”) and then in the 2020 post I noted that dispatch calls took 1–3ms longer, and that on a cold Android launch some dispatches took 100ms more. I wrote “these are not very scientific tests” and moved on. Those numbers were a symptom, and I should have chased them.

Lock-free reads, serialized writes

redux-kotlin-concurrent is the replacement. The strategy is called CallerSerialized:

  • Writes serialize on a reentrant lock, exactly like before.
  • Reads hit an atomic mirror of the state and never block.

The whole idea is in one function:

override val getState: GetState<State> = {
if (context.isActive) inner.getState() else mirror.value
}

If you’re on the thread that is currently dispatching, you get the live inner state, which is what middleware and listeners need, and what core Redux does. If you’re on any other thread, you get the mirror, and you get it without taking a lock.

context is a DispatchContext, and it’s an expect/actual: a ThreadLocal depth counter on JVM and Native, and a plain var on JS and wasm, because those platforms are single-threaded and there’s nothing to guard.

The ordering within a dispatch is fixed and it matters:

the reducer commits → the mirror is published → listeners are signaled → the writer lock releases

A callback therefore always observes state at least as new as the dispatch that woke it. No lost wakeups. That guarantee is what lets granular subscriptions and the Compose bindings diff old-against-new and trust the answer.

The trade

Off-thread reads are now eventually consistent. While a dispatch is in flight on another thread, a reader may briefly see the previous state instead of blocking until the new one lands.

You’re trading strict read synchronization for non-blocking reads. In a UI app that’s almost always the trade you want: a frame that renders one dispatch stale is invisible, and a frame that blocks is jank. But if you have code that relied on getState blocking in order to be sure it saw the latest value, that code needs to subscribe instead.

Getting it

implementation("org.reduxkotlin:redux-kotlin-concurrent:1.0.0-alpha04")
val store = createConcurrentStore(reducer, initialState)

It’s also in redux-kotlin-bundle, so if you took the one-line install you already have it.

Reading and dispatching from a background thread

To read: call store.state. It doesn’t block, on any thread, ever.

To write: call store.dispatch(action). Writes serialize on the lock, including middleware that re-dispatches from a foreign thread.

That’s the entire practice. Here is the modern version of the 2020 test, which lives in the repo:

@Test
fun concurrent_main_and_foreign_dispatch_has_no_lost_updates() {
val hits = AtomicLong()
val store = createConcurrentStore(reducer, S())
store.subscribe { hits.incrementAndGet() }
val t1 = Thread { repeat(5_000) { store.dispatch(Inc) } }
val t2 = Thread { repeat(5_000) { store.dispatch(Inc) } }
t1.start(); t2.start(); t1.join(30_000); t2.join(30_000)
assertEquals(10_000, store.state.count)
assertEquals(10_000L, hits.get(), "Every dispatch notifies exactly once (Inline context)")
}

Same shape as the failing counter from six years ago. It passes, and now the reads alongside it aren’t queuing behind the writer.

Never dispatch inside a reducer

This one is a real behavior change, so I’m giving it its own heading.

Core Redux has always had a guard against dispatching while the state is being reduced. At some point ours got commented out. It’s back, and it throws:

IllegalStateException: You may not dispatch while state is being reduced

If you have a reducer that dispatches (and you might, because for a while nothing stopped you) it will now fail loudly. Move the follow-up into middleware or a subscriber, which is where it belonged.

I like this one for a reason beyond correctness. A coding agent writing a reducer that dispatches used to get silent state corruption, which is the single worst thing you can hand a machine that is trying to verify its own work. Now it gets an exception with the reason in the message. Loud failure is a feature when your collaborator can’t see the screen.

Where your callbacks run

This is the part people get wrong, so it’s worth being precise: where a subscriber callback runs is decided by the store’s NotificationContext, not by the thread you subscribed on.

The default is NotificationContext.Inline: the callback runs synchronously on whichever thread dispatched. That matches old ThreadSafeStore behavior, and for a non-UI store it’s fine.

A UI app should supply a main-thread context. This is the only platform-specific code the Redux layer needs, and it’s one function:

// androidMain
public actual fun mainNotificationContext(): NotificationContext {
val handler = Handler(Looper.getMainLooper())
return coalescingNotificationContext(
isOnTargetThread = { Looper.myLooper() == Looper.getMainLooper() },
post = { block -> handler.post(block) },
)
}
// iosMain
public actual fun mainNotificationContext(): NotificationContext =
coalescingNotificationContext(
isOnTargetThread = { NSThread.isMainThread() },
post = { block -> dispatch_async(dispatch_get_main_queue()) { block() } },
)

Then wire it in once:

val store = createConcurrentStore(
reducer,
initialState,
notificationContext = mainNotificationContext(),
)

One contract you must not break: a NotificationContext has to deliver serially. Posted blocks for a given store run one at a time, in order, with a happens-before edge between them. If you hand blocks to a multi-threaded executor, the diff-based consumers (granular subscriptions, and therefore selectorState and fieldState) will lose or duplicate diffs, and they will do it quietly. Serial delivery isn’t a suggestion.

Migrating

OldNew
createThreadSafeStore(...)createConcurrentStore(...)
createTypedThreadSafeStore(...)createTypedConcurrentStore(...)
store.asThreadSafe()store.asConcurrent()
createThreadSafeStoreEnhancer()the enhancer = parameter

The deprecations ship ReplaceWith, so the IDE will do most of this for you. There’s one gotcha it can’t help with:

// before — enhancer was positional
val store = createThreadSafeStore(reducer, initialState, applyMiddleware(logging))
// after — enhancer is the 5th parameter now. Name it.
val store = createConcurrentStore(reducer, initialState, enhancer = applyMiddleware(logging))

notificationContext and onError come first now, so an unnamed third argument won’t compile. Name it.

redux-kotlin-threadsafe still works and still ships. It’s deprecated, and I plan to retire the module in a post-1.0 release.

Where this design came from

I didn’t arrive at this alone or on the first try. The concurrent store went through a review that turned up seven separate concerns, and two of the fixes are worth knowing about because they changed the contract:

Publish-then-signal. The mirror is now published as the first thing the fan-out hook does, before any listener is signaled. Before that, a diff-based subscriber could compare old-against-old, find no change, get no further signal, and stay stale until the next dispatch. That’s a lost wakeup, it’s nasty to reproduce, and it’s exactly the kind of bug that makes people distrust a state library.

unsubscribe() now wins. After it returns, no new callback invocation begins. A callback already running on another thread finishes, but you won’t get a new one. This is a deliberate divergence from core Redux’s snapshot delivery, and it’s the behavior you actually want when you’re tearing down a screen.

There are benchmark harnesses in the repo for read contention and writer throughput. I’ve not committed results, and I’m not going to quote a speedup number I’ve not measured properly: I did that in 2020 and I’m not doing it again. The structural claim is the one I’ll defend: reads don’t take the writer’s lock anymore.

Full details are in the ConcurrentStore doc and the Threading guide.

Happy dispatching, from any thread and any agent.


Sources of truth: reduxkotlin.org · github.com/reduxkotlin/redux-kotlin · the TaskFlow sample

Part 3 of ReduxKotlin 1.0.