Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Thread parking for Kotlin/Common #498

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open

Conversation

bbrockbernd
Copy link
Collaborator

Allows to pause and resume thread execution. On native platforms it is based on pthread_cond_wait, on JVM it uses LockSupport.

Threads can be pre unparked (calling unpark before park cancels the parking operation). And thread can be parker with a timeout.

Suspend the current thread by calling:

Parker.park()

Resume a thread by calling:

Parker.unpark(thread)

Get current thread reference by calling:

val thread = KThread.currentThread()

Parking with timout of 500 nano seconds:

Parker.parkNanos(500)

@bbrockbernd bbrockbernd marked this pull request as ready for review December 19, 2024 11:10

override fun createRef(): Long {
thread = Thread.currentThread()
atomicLong.set(0L)

Choose a reason for hiding this comment

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

It seems to me like the atomic long here is a protection against spurious wake-ups. Is that it? I don't understand the utility of this safety mechanism. Code using thread parking is often already structured in a way that takes spurious wake-ups into account.

If we do remove the safety mechanism, however, it means that JvmParkingDelegator can be stateless and use Thread as the reference that's passed around. That, in turn, means that there's no need to create a parking delegator instance per thread parker.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have made the it stateless. However I do still check the spurious wakeups to have similar semantics to the native implementation. Why native needs this check has to do with memory deallocation (which is discussed further there)

actual override fun wake(ref: Any) {
if (ref !is ParkingData) throw IllegalArgumentException("ParkingDelegator got incompatible parking object")
pthread_mutex_lock(ref.mut)
ref.wake.value = true

Choose a reason for hiding this comment

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

Ditto: if this is only a protection against spurious wake-ups, it's more efficient to omit it, because it's likely that the calling code can easily add the same logic on top of the provided primitive, and it will usually be able to do that without creating extra mutable state (like the AtomicBoolean here).

Copy link
Collaborator Author

@bbrockbernd bbrockbernd Jan 31, 2025

Choose a reason for hiding this comment

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

In the case for native, the calling code should call the destroyRef function to deallocate. There can exists a race condition where:

  • thread B (unparker) calls unpark and changes the state to free.
  • thread A (parked) spuriously wakes up and, since the state changed to free, asumes it was woken up by a wake call. And continues to deallocate ParkingData.
  • thread B continues to make the actual wake call, however ParkingData is destroyed.

The current parking implementation expects that when woken up there has been a wake call and therefore the reference will not be used anymore. So far I have not seen an easy way to solve this without protecting spurious wakeups, and since parking threads is anyways "expensive", I don't think it can hurt to leave it here?

WDYT?

Choose a reason for hiding this comment

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

Ok, I understand now that having this shared mutable state in some form is unavoidable, thanks! Yes, I agree that with the current implementation of ThreadParker, this change will lead to a data race. I think this can be avoided by reworking ThreadParker to only have one piece of atomic state (atomic<FREE | UNPARKED | ParkingData>), but I'm not sure if it would lead to anything useful.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Even with a single atomic state it seems difficult to me since we would still have to deallocate the native pthread_cond references. If we let the unparking thread deal with deallocation there exists a different race where the unpark call happens after the state change of park but before the actual delegator.wait(...) call.

}
}
private val thisKthread = KThread()
actual fun currentThreadId(): Long = 1

Choose a reason for hiding this comment

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

If we do decide to avoid spurious wake-ups, this implementation is incorrect. But in any case, I think it's also useless to provide it: I'm not sure what anyone using thread synchronization on single-threaded runtimes even expects. As part of the mutex implementation, this particular implementation makes sense, but not for other primitives.

Proposal: create a source set combining JVM and Native (in kotlinx.coroutines, we call it concurrent), and instead of providing parking in common code, only provide it in concurrent. Also, with this source set, it's easier to share tests between jvmTest and nativeTest: for most of them, it seems possible to put them in concurrentTest.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good suggestion! Added the sourceSet.
About the tests, spawning threads on Jvm and Native have different API's correct? Or would you suggest creating an expect/actual constructinon for thread manipulation?

Choose a reason for hiding this comment

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

We do expect/actual in our test source sets all the time, and it works without issues: https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/concurrent/test/ConcurrentTestUtilities.common.kt.

Comment on lines +65 to +74
STATE_FREE -> {
if (!state.compareAndSet(currentState, STATE_UNPARKED)) continue
return
}

STATE_PARKED -> {
if (!state.compareAndSet(currentState, STATE_FREE)) continue
delegator.wake(atomicRef.value!!)
return
}
Copy link

@dkhalanskyjb dkhalanskyjb Jan 31, 2025

Choose a reason for hiding this comment

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

Minor, style, subjective:

Suggested change
STATE_FREE -> {
if (!state.compareAndSet(currentState, STATE_UNPARKED)) continue
return
}
STATE_PARKED -> {
if (!state.compareAndSet(currentState, STATE_FREE)) continue
delegator.wake(atomicRef.value!!)
return
}
STATE_FREE -> if (state.compareAndSet(currentState, STATE_UNPARKED)) return
STATE_PARKED -> if (state.compareAndSet(currentState, STATE_FREE)) {
delegator.wake(atomicRef.value!!)
return
}

If you like this option, the same trick can be used to simplify the code above.

@dkhalanskyjb dkhalanskyjb self-requested a review January 31, 2025 14:42
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.

3 participants