DEV Community

Kevin Galligan for Touchlab

Posted on

Kapture - Kotlin/Native State Capture

Kotlin/Native state is different than what most Kotlin developers would be used to. To be shared between threads, state must be "frozen", which makes it immutable in the runtime.

For background on Kotlin/Native concurrency and state, see Practical Kotlin Native Concurrency.

Once the developer gets the general idea of how this works, it's not too complicated. However, capturing and freezing state unintentionally is a problem. Especially when combined with concurrency libraries.

Library Model

If you use the KN primitive Worker to implement concurrency, you'll need to be very explicit about state transfer or freezing.

In practice, few developers will use Worker directly. All current concurrency libraries opt to take a lambda argument that will be frozen, then run on another thread.

That makes concurrency in KN very simple. New developers often complain about the restrictions of the KN model, but KN forces you to rethink your architecture and be intentional about your mutable state. If you learn to work with the model, it's actually a good thing.

However, because lambdas can capture state, and freezing aggressively freezes everything it touches, you can easily wind up freezing state unintentionally. This can be very frustrating and lead to a lot of the beginner trouble with KN's model.

Take the following example:

class Product(val id:Long){
    fun saveOrder(order:Order){
        background {
            saveToDb(id, order)
        }
    }
}

I've been bitten by this problem on multiple occasions. The method background is going to take the lambda argument and freeze it, and run saveToDb(id, order) on another thread. That means id and order will be frozen.

Visually it looks like you're just freezing those two values. order might be a problem, because we don't know if the Order type should be frozen, but the real problem here is id is actually attached to Product. We wind up freezing the whole Product class.

Seeing what state is captured is not always obvious. That is relatively easy to determine with static analysis, and could be surfaced to the developer in the IDE.

However, not all state is problematic to freeze. In the simple example above, although Product is frozen, as presented, that's not a problem. There's nothing about Product that would be mutable anyway.

However, maybe it looked more like this:

class Product(val id:Long){

    var quantity:Int = 0

    fun saveOrder(order:Order){
        background {
            saveToDb(id, order)
        }
    }
}

Although perhaps not an architecturally good decision, the code above now has a real issue.

Kapture

Kapture is a code inspection Intellij plugin that finds "hot" functions, collects what state will be captured in the lambda arg(s), and evaluates if that state is problematic to freeze. If so, it'll create an error.

Basic id capture

The inspection will add an issue to id, and hovering over it will pop up a summary of what's wrong and offer some fixes.

Education

The goal of Kapture is not simply to warn you of captured state. The options in the fix list will include links to documentation explaining the issue and potential best-practice approaches to refactoring your state architecture.

For example, in the scenario above, it's better to cross thread boundaries with a named function and use function parameters to avoid capturing the parent class.

class Product(val id:Long){

    var quantity:Int = 0

    fun saveOrder(order:Order){
        save(id, order)
    }

    private fun save(id: Long, order: Order) = background { saveToDb(id, order) }
}

Hot Functions

Hot functions are functions that take function arguments and may cross thread boundaries. There are some built-in functions that Kapture recognizes. These include withContext from corountines, and methods from Flow. You can also annotate your own with @FreezingBlockCall. This tells capture to treat block as special and look at it's captured state.

@FreezingBlockCall
fun <R> background(block: () -> R): R = block()

Sometimes a function won't actually cross a thread boundary at runtime, but it can be difficult to know for sure at compile time. Kapture, in general, leans conservative and will warn you of potential issues in ambiguous cases.

If you're in the main thread and call the following, you won't actually switch threads (and coroutines shouldn't freeze the lambda), but Kapture treats it as a potentially thread-crossing method anyway.


withContext(Dispatchers.Main){
    //What if you were already in the main thread?
}

Mutability Analysis

How do we determine if something is immutable? In summary, Kapture analyses the captured data. Everything has to be final and immutable.

For example, the following is OK:

data class SomeData(val s:String)

However, this is not:

data class SomeData(val s:Any)

The val s is of type Any, which is not final.

There are many other rules, including special classes. For example, anything from Sqldelight is considered safe to freeze, so those classes are ignored. On the other end, it's relatively easy to capture test classes, so even if your test class has nothing mutable, it'll be flagged as an issue.

class ProductTest{

    val db : DbHelper = inject()

    @Test
    fun heyTest() = background {
        db.whatever() // <- issue here
    }
}

You can override that, or anything, with @Freezeable.

@Freezeable
class ProductTest{

    val db : DbHelper = inject()

    @Test
    fun heyTest() = background {
        db.whatever()
    }
}

Kapture can also see some calls in the init block, so this would also work:

class ProductTest{
    init {
        freeze()
    }

    val db : DbHelper = inject()

    @Test
    fun heyTest() = background {
        db.whatever()
    }
}

There are a number of detailed inspections which you can read about in the docs, and more to be added to increase the accuracy. However, the important point to remember is if we can't reasonably verify that you can freeze some state, you'll get an error. It is intentionally conservative.

80/20

KN verifies frozen at runtime, which has been a criticism of the model. Doing this at compile-time, like Rust, would be great, but would require a significantly different language. There's only so much the compiler can know about what you're doing without those language changes.

Kapture operates under the "pretty good" model. It'll make pretty good guesses, and you can override it when it's wrong. "Pretty good" isn't a virtue for compilers, but for developer tools, it's a different story.

Evaluation

We're currently looking for private testers to give feedback. We'd like to know if this is actually useful in practice and if it helps explain how the KN state model works. Please sign up here for access.

http://go.touchlab.co/Kapture-Devto

Special Thanks

This idea was kind of a combination of some slides in my concurrency talk, and chats with Eugenio Marletti and Scott Pierce in and around the Kotlin Slack.

Oldest comments (0)