I have a question about main-safety. I get the ide...
# coroutines
u
I have a question about main-safety. I get the idea that any suspend function should be able to be called from main-thread/dispatcher scope. However, that means, what in practise? How far do you go? Every public suspend function should apply it's own withContext? Or be pragmatic, and "know" which functions will be sort-of ui related and only do it there? Another example might be "sync" routines, which are ran in a Syncer which has its own scope driven by IO dispatcher. Would you apply withContext even in some of the partial sync components?
Copy code
class Syncer(private val somePartialSyncComponentNeverRelatedToUi) {
   private val scope = CoroutineScope(<http://Dispatcher.IO|Dispatcher.IO>)

   fun sync() {
      scope.launch {
         somePartialSyncComponent.doWhatever()
      }
   }
}

class SomePartialSyncComponentNeverRelatedToUi {
   suspend func doWhatever {
      withContext here ??? <--------
   }
}
Is it not wasteful context switch?
a
In practice you achieve this transitively from other suspend functions you call and don't often have to think about it. It's only functions that directly perform blocking IO that have to worry about switching contexts to an IO dispatcher
e
Switch context where you know that you're doing work that needs to be done in that context. If your code is in some middle layer that e.g. just connects other layers, then likely you don't need to do any context switching. However, in the extreme layers, e.g. UI or IO, you do know in what contexts to suspend. That's where the switches should happen! Note that some libraries already switch to correct contexts, e.g. in AndroidX various UI stuff already happens on the (immediate) main dispatcher, and networking libs often are main safe too.
u
Okay I sort of see it, for Db and Networking access, but then again, it's also sort of arbirary disctinction what counts as "needing" such treatement. Mostly unsure about calculations I'll whip up and example
Copy code
class SendRepository {
	override suspend fun sendData(tuid: String, sim: Sim) {
        return withContext(<http://dispatcherProvider.io|dispatcherProvider.io>) {
            try {
            	val apiSim = SimDataRequestMapper(sim)
                apiClient.sendSimData(tuid, apiSim)
            } catch (ex: Exception) {
                throw when {
                    ex is ApiException && ex.errorCode == 400 && ex.errorMessage.equals(
                        "SIM_CARD_ALREADY_USED",
                        true
                    ) -> SimCardAlreadyActivatedException()
                    ex is ApiException && ex.errorCode == 400 &&
                            (ex.errorMessage.equals(
                                "SIM_ICC_PUK_MISMATCH",
                                true
                            ) || ex.errorMessage.equals(
                                "UNKNOWN_SIM",
                                true
                            )) -> SimDataMismatchException()
                    else -> ex
                }
            }
        }
    }
}

class ViewModel {
	fun foo() {
		scope.launch {
			try {
				_state.value = SENDING
				sendRepositroy.sendData(..)
				_state.value = SENT
			} catch() {
				_state.value = ERROR
			}
		}
	}
}
So you're saying the withContext(IO) in at the 3rd line is useless since retrofit does its IO off main thread If I were to remove it, that means the SimDataRequestMapper, runs on caller context, which in this case is main thread. Obviously it's just regular blocking mapping from domain to network type, nothing heavy, so it's probably okay ... But should it? Isn't that a waste of UI time? (Also the exception mapping, I purposely left it ugly) Whereas this SendRepository can also be called from a push message (FcmService context), in which case, I don't care since its on some worked thread anyways)
e
If the mapping or some other calculation could take a while, or would block, then that should be wrapped with the correct context switch. All the other code can just run in the calling context.
AFAIK, switching context is perfect for suspend execution in the calling context while the block that runs in the context can block, run or suspend however it likes. That way you can safely call suspend funs from e.g. the main dispatcher, as long as somewhere down the line a context switch happens, which suspends until the work in that context returns.
m
In my case, I usually use this pattern on Android. ViewModels can hold UseCases or Repository directly. Each UseCases or Repository can know what dispatcher they need. So, UseCase or Repository use
withContext(<http://Dispatcher.XXX|Dispatcher.XXX>)
syntax. In short, ViewModels don't need to know what dispatchers they need to use. They just call some suspending function in a desired coroutine scope (in this case, It's ViewModelScope). In addition, If some of DataSources need to change there context( := dispatcher), They freely use
withContext
syntax. (In my production code, all of the encryption/decryption logic must be executed in the dedicated single thread. So I create that dispatcher and the datasource which should be encrypted use this dispatcher.) --- Rule of thumb is "Callers don't need to care about the dispatchers that callee is going to use." (Below picture is my clumsy artwork 😅)
u
Which means all your Repo/Usecase functions have withContext(Disp.Io) as 1st line?
e
Unless a use case does heavy calculations (
Dispatchers.Default
), that seems unnecessary
u
How do you define heavy?
a
With a profiler. 🙂 The vast majority of data mapping operations you might perform in a mobile app for consumption by a UI are fine to do on the main thread. If you don't know for a fact that you're dealing with an operation that will take long enough to make thread hopping worth it, keep it simple.
https://ui.perfetto.dev is a great tool for visualizing the amount of time an operation takes between start/end tag points and how that relates to a single frame on the main thread
👍 1
u
Im aware how to profile, just asking whats heavy? obviously more than 16ms needs to be off thread. Is say 1ms worth off threading? 2ms? Hard to say? Or is anything okay that doesnt blow past the frame? Thats a bit hard to say ahead of time, for every device, no? Granted also, the 16ms ist not just for my calculation
a
Yep. How much is too much will always depend on how many other things are sharing the same frame time slice. These sorts of tradeoffs are generally resistant to blanket, "always do this" kinds of advice.
u
Or I can always wrap repository functions with withContext and not worry about it 😀
a
That's what we call a cargo cult 😛
The goal is, "don't blow the frame." But chances are good that the time you spend thinking about how to optimize a mapping calculation on your data by changing what thread it runs on is better spent focusing on what happens in response to that calculated data on the main thread: reconfiguring the UI to display that data.
And if you hop threads you start having to worry about data dependencies in the current frame. Is it more important that you don't blow a frame, or is it more important that everything shown in that frame is present, accounted for, and consistent? Is it more important that the UI doesn't flicker for a frame or three as data loads in incrementally?
Optimize the actual calculation before worrying about moving it to another thread, since that helps you meet those other same data dependency goals more easily as well.
u
Yea, I'm just still in rxjava thinking, where I developed a habbit of subscribeOn(IO) for every repo call
However, would you have withContext in say Database Dao? You're not sure who is going to use it, and chances are you'll blow the frame. Is here habitually putting withContext on every function okay?
Btw SqlDelight purposly exposes only blocking functions (discounting the observable queries), as not to get in mess Room did with transactions. Hence I worry a bit about my Daos exposing suspend functions, since somebody might compose them
a
Set expectations based on whether the function is marked
suspend
or not. If it's
suspend
, a caller should be able to assume it doesn't block a calling thread.
u
Hmm, is that true? As we discussed the Mapper above, it does block the caller for a bit Sounds like a argument for withContext everywhere
a
blocking computation vs. blocking io are pretty different cases. Always worry about blocking io. Worry about blocking computation only after you measure it to be a problem. Ideally expose blocking computation as a non-suspend function to communicate that and defer the decision to a caller that has more info.
u
Hmm how are they different? From caller's perspective it's all the same, whether the thread computes something or is parked, doesn't matter, no?
a
computation tends to be a lot more predictable and stay within an expected range of cost compared to io. io can swing wildly based on system load and other device conditions or properties.
it's much easier to profile computation and decide it's acceptable and have that be representative than with IO. With IO there are many factors that are different degrees of impossible to predict.
Computation may scale with input data, but IO scales with other IO load on the device, how full the flash filesystem is, how reliable/fast a network is, whether the storage device is failing...
u
Yes but still from perspective of api, its a blocking contract, and function returns whenever it returns
a
yes, but this is what I mean when I say there's no hard and fast blanket rule for it. You wouldn't put
myDataClass.copy(someProperty = someNewValue)
into a
withContext(Dispatchers.Default)
even though it is indeed performing blocking computation that will return whenever it returns. From there we're just quibbling over the shades of gray.
IO is easy to draw a blanket rule around. Computation has a lot of, "it depends."
u
Yea, so atleast the rule is "when its blocking, you need to know its implementation details to decide"
and given such mapper is part of a ˙suspend fun FooRepo.whatever` and the convention is that suspend functions are to be main-safe, that means such computation has been deemed as light and therefore okay to be run in caller context if happens to be main thread driven context
m
Wow, You've already talked many things. 🙂 Anyway, this is answer for you question. Yes, I'm using
withContext(<http://Dispatchers.IO|Dispatchers.IO>)
in the most of UseCase class because it is the boundary between UI and DataLayer (Most of usecases are executing I/O bound tasks). Of course, we can just run those kind of tasks without another Dispatcher (Execute directly in the main thread thanks to its suspending nature). But I don't want to put I/O bound tasks into the Ui related Main Queue which will be parked until it has signal from disk or socket. (BTW, AFAIK disk i/o is incapable of triggering data available signal unlike socket i/o - so it need polling internally.) If I have many pending i/o bound tasks(coroutines) in Ui Thread dispatching queue, There will be high chance that I can see jank in UI. Moreover, As using I/O dispatcher those coroutines can be scheduled in parallel. I'm not sure this is sufficient reason to use background dispatchers (I/O, Default, and so on...) for you.
u
Well, ofcourse io bound tasks are ran in io dispatcher, question was who is it apply the dispatcher, whether its the useCase of the io thing it self internally
m
Then, As I already mentioned earlier, I'd say It could be UseCase, Repository and everything that want to specify its desired coroutine context (especially, dispatcher). In this way, Callers don't need to care about what dispatchers they use for the suspending function. (In my case, I use
withContext(<http://Dispatchers.IO|Dispatchers.IO>)
in all of my usecase due to the fact that most of my usecases execute I/O bound tasks(disk, network). --- One more trivial thing that I want to share. See below. ViewModel (care about coroutine scope) -> UseCase (
withContext(IO)
) -> Repository (Accidently declare
withContext(IO)
) -> DataSource (
withContext(SingleThreaded)
) -> Api (Retrofit) or Database (Room) In this case, UseCase is kind of boundary between UI and Data layer. and A repository accidently use the same dispatcher with the usecase. AFAIK, there won't be context switching because coroutine scheduler is aware of the type of dispatcher. --- I declare data layer boundary and all of the low layers under the boundary just do their job believing they are on the I/O scheduler. and if some of them want to use another scheduler, they just freely specify
withContext(BlahBlah).
(Google I/O is starting soon! Enjoy! 🙂 )