Hey there! I have a question about how coroutines ...
# coroutines
m
Hey there! I have a question about how coroutines ensure the happens-before relationship.. There’s an article by Roman Elizarov where he explains that suspending functions perform some synchronization, which makes accessing mutable state safe as long as it happens from the same coroutine. There’s also the
limitedParallelism(1)
pattern, which, according to the documentation, guarantees sequential task execution and establishes a happens-before relationship between them. So it makes shared state thread-safe even when accessed from different coroutines. So, I was wondering: • Is this something that the
LimitedDispatcher
explicitly enforces, or is it just a side effect of the lack of parallelism? In other words, if I don’t use
limitedParallelism
, but it just so happens that two different coroutines on the same dispatcher are executed sequentially, does that mean one coroutine is guaranteed to see modifications made by the other? • I’ve been struggling to reproduce any memory visibility issues.. It seems that once a coroutine suspends, any other coroutine can immediately observe changes made to shared state (without additional synchronization). Am I just lucky, or are there some subtleties in how this works that you could give insight into? Thanks in advance 😊
r
Perhaps there is confusion about the word
synchronization
. My understanding is that your linked article uses the term to refer to an abstract system that ensures one thread waits for another thread. For example if you use an explicit lock, semaphore, etc. to ensure that some mutable shared state is only mutated by one thread at a time, you've built "synchronization" around the shared state. Java has the
synchronized
keyword to make this pattern easier (docs). Regarding Kotlin, take this example:
Copy code
suspend fun parent() {
    child1()
    child2()
}

suspend fun child1() { println("child 1") }

suspend fun child2() { println("child 2") }
When that article has the code example with
yield()
and mentions that Kotlin does synchronization, it just means that Kotlin won't let
child2
start until
child1
is done (child1 "happens before" child2), even though we're in a context of concurrency and they may actually run on different threads. If you have shared mutable state visible to multiple coroutines or threads, there's just the one place in memory where that is defined, mutated, etc. "synchronization" is not referring to some copying of shared state between threads, or anything like that. It just means we prevent two pieces of code from touching something at the same time or in an unpredictable order
To try to concretely answer your questions...
limitedParallelism(1)
gives thread-safety purely by nature of only letting one thread run at a time. If only one thread is allowed to run at a time, then you know you'll never have 2 that are mutating the same shared state in the same instant. If you have two coroutines and you somehow have the guarantee that one can only start after the other finishes, you are also indeed guaranteed that state mutations made by the first will be visible to the second. No, you're not just lucky. Shared state is shared state... There's no sneaky duplication going on behind the scenes.
c
Everything above is correct, but I'd like to emphasize: coroutines exists to be as cheap as possible because we want to avoid anything being shared. Instead of sharing state between coroutines, have non-shared state that a single coroutine can access, and use messaging (through channels or flows) to send data between them. It's a much much simpler mental model, and it's why there's not much information on synchronization—coroutines were created for us to avoid synchronisation!
gratitude thank you 1
m
Hm, let me rephrase my questions. In Java, according to the Java Memory Model, unless there’s explicit synchronization, different threads can easily not see modifications to shared state made by other threads or observe them in random order. With coroutines, this is not a problem as long as shared state is accessed from the same coroutine, or when using
limitedParallelism
. I’d like to better understand how coroutines synchronize under the hood. Could it be that suspension itself establishes a happens-before relationship even across different coroutines (for example, if they’re using the same dispatcher)?
c
I’d like to better understand how coroutines synchronize under the hood. Could it be that suspension itself establishes a happens-before relationship even across different coroutines (for example, if they’re using the same dispatcher)?
I'm not an expert on this topic, since as I mentioned the point of coroutines is to avoid shared state. However; • You can create explicit happens-before through
Mutex
and
Semaphore
• (for example, if they’re using the same dispatcher) → not for any dispatchers, no. Single-threaded dispatchers yes, but I don't think there are any such guarantees for other kinds of dispatchers.
d
Two coroutines launched in
Dispatchers.Default
or
<http://Dispatchers.IO|Dispatchers.IO>
can access the same state in parallel, without establishing happens-before. But in one coroutine, even when it switches between threads in the course of its execution, [the part before] the suspension happens-before [the part after] the resumption.
m
Thanks, I really appreciate you taking the time to reply. I'm well aware of Mutexes, and I understand that coroutines on
Dispatchers.Default
or
<http://Dispatchers.IO|Dispatchers.IO>
can run in parallel and access shared state concurrently. That said, I was asking something else. I'm specifically trying to understand what suspension does under the hood in terms of memory visibility. Does suspension (perhaps unintentionally) establish a happens-before relationship between some coroutines? For example, if I can prove that one coroutine was launched after another coroutine suspended, is it guaranteed that it will observe the changes made by the first, even without any additional synchronization?
d
Could you show me an example of being able to prove that another coroutine suspended?
m
I don’t mean a "real" proof, just practical evidence from the program’s behavior (like logging the timestamps) 😅
Copy code
var sharedBoolean = true

fun test() {
    coroutineScope.launch(default) {
         sharedBoolean = false
         delay(1000)
    }
    coroutineScope.launch(default) {
         delay(1000)
         if (sharedBoolean) {
             fail("oh no")
        }
    }
}
d
Nope, this won't create a happens-before.
thank you color 1
The best way to explain what suspension does in terms of memory visibility, I think, is to look at how coroutines are implemented: https://github.com/Kotlin/KEEP/blob/master/proposals/coroutines.md#implementation-details In essence, suspension is nothing special, it's just returning a value that means "this function hasn't actually returned, it's suspended". To resume a
suspend
function's execution, its continuation object is passed to someone else (maybe simply to its dispatcher, maybe to someone who will resume it as a callback), and that is done in a manner that establishes the happens-before relationship.
👀 1
m
Sorry for taking so long to reply. I think this answers my question. 👍 Following your advice, I spent some time reading the sources, and I really appreciate how clean the code is and how easy it is to explore! To summarize, I'll answer my questions from the beginning (I hope I didn't get everything wrong..)
Is this (thread-safety) something that the
LimitedDispatcher
explicitly enforces, or is it just a side effect of the lack of parallelism?
It's LimitedDispatcher. It has an internal "queue" that establishes a happens-before relationship when dispatching tasks if
limitedParallelism = 1
I’ve been struggling to reproduce any memory visibility issues.. Am I just lucky?
Yes. If you don't use any synchronization, single-threaded dispatchers, or limited parallelism = 1, you can encounter the same memory visibility issues as in Java. Suspension itself does not create a happens-before relationship
r
sorry about chiming in late, but i had recently worked with this API. I saw that the folks here helped you gain clarity on
LimitedDispatcher
, I believe its still worth mentioning this caveat with you.
limitedParallelism(1)
is that its able to serialize non-suspending code. for e.g., if you have a function that delays (which is suspending):
Copy code
import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(10) { id ->
        launch { demo(id + 1) }
    }
    joinAll()
}

val confinedDispatcher = Dispatchers.IO.limitedParallelism(1)
var counter = 0

suspend fun demo(id: Int) = withContext(confinedDispatcher) {
    val duration = (10..100).random()
    println(">>>> Enter: $id; counter value: $counter; delay value: $duration")
    delay(duration.toLong())
    counter++
    println(">>>> Exit: $id; counter value: $counter")
}
delay
frees up the dispatcher to process other coroutines. if you run this code, while
counter
will print out 10 at the end of the program, the
counter
value observed by each coroutine in
demo
looks out of order.