Why isn't send(_:) method atomic?

I'm amazed by how simple to use TCA to implement new features. However when I trace the source code it really hides lots of complicated tasks behind the scene.

I've noticed one question about Store. For the method send(_:), why isn't it atomic?

  func send(_ action: Action) {
      if self.isSending {
          assertionFailure(
          """
          The store was sent an action recursively. This can occur when you run an effect directly \
          in the reducer, rather than returning it from the reducer. Check the stack (⌘7) to find \
          frames corresponding to one of your reducers. That code should be refactored to not invoke \
          the effect directly.
           """
          )
      }
     self.isSending = true
     let effect = self.reducer(&self.state, action)
     self.isSending = false

A store in real app may receive actions from multiple threads at the same time, so if I do this to simulate what real app behaves:

DispatchQueue.concurrentPerform(iterations: 10) { _ in
    viewStore.send(.someAction)
}

The assertion failure will be triggered, and this is not the case that an action is sent recursively. I think it makes more sense to be an atomic method (thread-safe). Such as adding a lock.

Just curious why and how should I use TCA for the scenario that a store may receive actions from multiple threads at the same time?

1 Like

Hey @profat,

We have this explained in a few spots in the repo, but perhaps not in the right spots. I think that assertion failure message should be updated to better explain why that can be triggered.

In general, we do not consider the store's/viewStore's send method to be thread safe, as documented here. It should only ever be called from a single thread (typically the main thread unless you have a special case of a store that isn't used for UI). Instead of calling send from multiple threads you should push that threading and asynchrony into effects. That allows you to achieve basically the same thing, but it's a simpler model to understand and makes your feature more testable.

We also have further information in our README that describes why we do not attempt to make send thread safe, here.

Does that answer your question?

3 Likes

I just opened this PR to the project to attempt to better clarify why we have that assertion failure. Do you think that makes things a bit clearer?

1 Like

Yeah, it's clearer! And your explanation totally answer my question, thank you!

hi @mbrandonw, and we actually can make send(_:) alway run on some specific thread other than main thread, right? Just some effect needs to be delivered on main thread and you don't want UIKit issue introduced by thread hop, so the main thread may be the best choice.

Right, send can be called from a non-main thread, but then all send invocations must be done on that same thread. However, this does mean that all state changes will also be delivered on that thread. So if you observe the state in UIKit you will need to make sure to do .receive(on: DispatchQueue.main) before making any changes to your view, and you won't be able to use SwiftUI since @ObservableObject must deliver its values on the main thread.

We have seen examples of people having a non-UI Store instance that is basically responsible for doing some heavy number crunching and interfacing with an external service. A store like that is fine to send actions on a background thread.

1 Like

@mbrandonw do I understand correctly the if all sends either in UI or in Effects performed from required queue, there is no need to have .receive(on: in the code, as this will be given?
I am asking since in my code I am having such approach where I ensure that actions are sent properly, as amount of places where actions are sent is smaller than amount of places where I observer state and it is easier to control.

I'm not 100% I understand the situation you are describing. If you are saying that you will send all actions on the main thread and all effects deliver their output on the main thread (without using receive(on:)), then yes, you will not need to use receive(on:) anywhere. However, if you are saying you send actions on the main thread but effects will deliver their output on a different thread, then this is not allowed.

Essentially there is no threading or queuing in the Store whatsoever, so whatever queue you send an action on or an effect's output is delivered on will be the queue that state changes will be delivered on. This means that the store can only be sent actions (either manually or via an effect) on a single queue thread ever. This is why almost always you will want to send actions on the main thread and force effects to deliver output on the main thread.

Is that any clearer?

1 Like

Sorry for unclear communication. I did mean that if all sends on UI performed on main thread, as well as effects end with .dispatchAsync(on: .main) there is no need to force viewStore to be observed on the main. Thanks for confirming.

1 Like