Skip to content

Instantly share code, notes, and snippets.

@mikehearn
Created April 6, 2023 08:25
Show Gist options
  • Save mikehearn/1913202829403f65331123f0473a7901 to your computer and use it in GitHub Desktop.
Save mikehearn/1913202829403f65331123f0473a7901 to your computer and use it in GitHub Desktop.
A class that makes it easy to wrap state such that it's only accessible when locked.
package hydraulic.kotlin.utils
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
/**
* A wrapper class that makes it harder to forget to take a lock before accessing some shared state.
*
* Simply define an anonymous object to hold the data that must be grouped under the same lock, and then pass it
* to the constructor. You can now use the [locked] method with a lambda to take the object lock in a
* way that ensures it'll be released if there's an exception. Kotlin's scoping rules will ensure you can only
* access the fields by using either [locked] or `__unlocked`, thus making it clear at each use-site which it is.
* You should generally not use `__unlocked`, it is public only because [locked] is an inlined function.
*
* This technique is not infallible: if you capture a reference to the fields in another lambda which then
* gets stored and invoked later, there may still be unsafe multi-threaded access going on, so watch out for that.
* This is just a guard rail that makes it harder to slip up.
*
* Example:
*
*```
* private val state = Locker(object { var count: Int })
* val current = state.locked { count++ }
* ```
*
* **IMPORTANT:** The above short syntax relies heavily on Kotlin's type inference. In particular, the type of the
* `Locker` that's parameterised by an anonymous object is non-denotable and thus you _cannot_ write an explicit
* type for it. An attempt to do so will cause the lambdas to break. If this matters (e.g. you want to pass the box
* as a parameter), just define a named class instead of using an anonymous object.
*
* @param content The object to take ownership of and synchronize on.
*/
public class Locker<out T>(content: T) {
/** @suppress */
@JvmSynthetic
@PublishedApi
internal val __unlocked: T = content
/**
* Holds the lock whilst executing the block as an extension function on type [T].
*
* @param reentrancy If false and the current thread already holds the lock, throws [IllegalStateException]
* with a message stating that "You may not call back into this object". This method is useful if you're invoking a
* user supplied callback and want to ensure the user doesn't re-invoke methods on your class whilst you're in
* the middle of processing a previous call. Defaults to 'true', meaning re-entrancy is allowed (the Java default).
*/
@Suppress("DEPRECATION")
@OptIn(ExperimentalContracts::class)
public inline fun <R> locked(reentrancy: Boolean = true, block: T.() -> R): R {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
if (!reentrancy && isLocked) throw IllegalStateException("You may not call back into this object.")
return synchronized(__unlocked as Any) { __unlocked.block() }
}
/**
* Returns true if the current thread holds the lock i.e. is inside a [locked] block.
*/
public inline val isLocked: Boolean get() = Thread.holdsLock(__unlocked)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment