68

Why the Kotlin/Native memory model cannot hold. - ITNEXT

 4 years ago
source link: https://itnext.io/why-the-kotlin-native-memory-model-cannot-hold-ae1631d80cf6?gi=3ed1cfca88a8
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Why the Kotlin/Native memory model cannot hold.

Image for post
Image for post
Frozen is beautiful. (Photo by Giacomo Berardi)

So you’ve heard about Kotlin Multiplatform, that it’s different from what you’re usually seeing attached to the silver bullet that is Multiplatform:

  • It actually compiles to “native” binaries. Meaning JVM bytecode for Android or LLVM / binary bits for iOS.
  • It proposes an entire code architecture dedicated to Multiplatform that allows platform specific implementations.
  • It has amazing interoperability stories, whether with or from Swift, JS, C & of course Java.
  • It is developed and supported by the company that actually designed the language, so it is not “added” on top of the language, but baked in from its inception.

So maybe this time, it will be different. Maybe it’ll work. Maybe there is a silver bullet.
With joy at heart, you start porting your JVM dependent Kotlin code to a Multiplatform architecture, it compiles !

You run it…

Uncaught Kotlin exception: kotlin.native.concurrent.InvalidMutabilityException: mutation attempt of frozen com.Whatever@1aec038

This happens because, yeah, Kotlin/Native runtime actually forces a batch of very important threading related code sanity rules, the first being:

A datum is EITHER mutable OR shared.

If you need to share an object, you can freeze it, because:

A frozen datum cannot be mutated.

Also:

Freezing an object freezes everything it references.
A frozen object cannot be unfrozen.

So, what about “static” data, such as top level variables or objects?
Well:

Top-level objects are frozen by default.

This isn’t a story about how to get passed those limitations or how to properly work your code to work on Kotlin Multiplatform.
This is a story about the limitations themselves.

First of all, we need to understand the reason behind these limitations. They are philosophical and practical.

Philosophically, this is a paradigm shift. Without shareable mutable data, there is no mutex needed, which means no race condition as well as fixed performance. This model encourages us to think our code in a different way than we are used to: rather than sharing the resources between threads, we name a thread that is responsible for the resource and send actions to it. Only one thread accesses the resource, all other threads ask the owner thread to perform actions on it on their behalf. This model is called “Actor Based Programming” and is made really easy by the KotlinX Coroutines library (which, funny enough, do not support multi-threading on Kotlin/Native precisely because of these restrictions. They are, however, working on it).

Practically, enforcing this means that the runtime garbage collector is a lot more easy to write, optimizable and predictable.

I really like these constraints. I happen to think that the more constraints a programming language forces upon us, the more protected we are against releasing buggy apps.

So, what are the issues with these constrains?
Sadly, many. While the constrains themselves are sensible and very interesting, their implementation in the Kotlin/Native compiler & runtime is problematic. Here’s why:

1: These limitations are Kotlin/Native only

Kotlin/Native has value only as part of the Multiplatform experience. Sad but true. There’s no iOS programmer that makes an iOS only app in Kotlin rather than in Swift. Nor there is a high performance server programmer that will favor Kotlin/Native over C++ or Rust. Same goes for high performance desktop apps.
The only domain where Kotlin/Native might get some traction on its own is embedded. And that’s far from done.

Because those limitations are Kotlin/Native only, they are pushing programmers away from the Kotlin Multiplatform experience. Most of them are discouraged by the idea of having to rethink their entire architecture when they were trying to port an existing working code.

Also, because the corresponding API only exists in Kotlin/Native, this makes almost impossible to write some libraries with common code & common semantics.

2: These limitations are pure. We are practical.

The first time you start thinking in Actor Based Programming, your mind blows. You feel enlightened, intelligent. Breaking your code into actors means compartmentalizing resources and responsibilities. Each actor is atomic in its function, and every actor can communicate to other actors for requests that are outside of their atomic function. This is a very interesting, safe, and great way of coding… Until you start benchmarking.

Please understand that I am far from those who think that #PerfMatters everywhere, every time. I am more of an “efficiency over performance” guy. However, there are times where performance does really matter. Everything depends on the context, and we cannot rule out the necessity of performance in critical paths.

Have a look at this benchmark. Here’s what it prints:

With Mutex:
Completed 1000000 actions in 661 ms
Counter = 1000000
With Semaphore:
Completed 1000000 actions in 413 ms
Counter = 1000000
With Lock:
Completed 1000000 actions in 81 ms
Counter = 1000000
With Actor:
Completed 1000000 actions in 576 ms
Counter = 1000000

System Locks are 7 times faster than actors. This may be a big deal.
If the shared resource is not accessed by a critical path, then the performance of actors is absolutely fine. If it is, though, that’s problematic.
That’s because there’s only one actor that’s confined to one thread. The beauty of Actor Based Programming is also its weakness: every time you need to access the resource, there’s a message passing between threads.

Actors are great most of the time, but not every time.

Oh. This benchmark does not even show the benefit of using a ReadWriteLock as it is very context dependent, but with few writes and lots of reads, using such a lock can further increase the performance factor over actors.

3: These limitations are enforced at runtime

A code that compiles and works fine in JVM or JS will crash at runtime in Native.
All it takes is 5 lines of code:

object Foo { var bar = 21 }
fun main() {
Foo.bar = 42 // Crash happens here!
println(Foo.bar)
}

Remember? This is the work of “top-level objects are frozen by default” and “a frozen datum cannot be mutated”.

Because these limitations are enforced at runtime, neither the compiler nor the IDE will warn you that you are doing something verboten. It will compile, run fine in JVM & JS, and crash in Native.
In fact, there is no way the compiler & the IDE can warn you, because immutability is not part of the type system.

The impact of points 1 & 2 are similar: this discourages developers. Regarding the point of view, this can be a good or a bad thing. Someone might say “If you don’t like doing things rights, go away from my language”. Yes, forcing developers enforce good practices is a very good thing. We all love that nullability is embedded into the Kotlin type system, even if we have to write additional nullability checks.
Forcing developers into a paradigm shift may be a good thing, but I fear JetBrains has underestimated the cost of such a shift. I fear that by forcing developers into their “better” way, they are actually pushing them away. It’s a hunch, I have no data backing it. However, as a professional certified Kotlin trainer, I have seen the adoption of Kotlin/Native being delayed precisely because of this.

However, what bugs me the most is by far point 3. For the life of me, I cannot understand how JetBrains thought that it would be OK to have such an easy code compile and crash at runtime.
One of the main reasons Kotlin is so popular is precisely because a lot of crashes are detected at compile time, preventing the app from compiling and therefore deploying. This is why people love nullability, smart casts, (very) strong typing, etc.
Having immutability enforced at runtime feels like a huge mistake. It feels like letting the wolf into the sheepfold (french expression). Suddenly, code that compiles and work in any other platform crashes without so much of a warning in Kotlin/Native. Suddenly, it feels like the language is working against us, rather than with us. It feels like Kotlin has betrayed us.

There are two ways the situation can improve. Either fix the language or fix the runtime.

How could we fix the language? One approach would be to add const classes to the language, which would be described as “a class with only immutable data: either primitives or const objects”. One benefit of this approach is to allow for hashcode optimization. Then there would be no need for the freeze() API: const classes are the definition of frozen. Furthermore, “const” is already a language keyword.
Another way would be to implement ownership & concurrency primitives like Rust does. I’d love this, but I fear Kotlin has moved away from these principles too much already and that it’s too late (also, ownership in Rust is highly coupled with memory collection).

Sadly, fixing the language is by far a more complicated & daunting approach, so it looks like JetBrains is taking the easier path. Is it a good thing?
I should say that there’s no way to be sure that this new “relaxed mode” will actually ship. It may be just a test.
Fixing the runtime does lower the entry bar, as every programmer can continue to code the way they used to, but I can’t stop the feeling that we, as a language community, may be loosing the dream of “correct concurrency”. Maybe we haven’t tried hard enough?

Here comes the conclusion: in its current form, its memory model is hurting Kotlin/Native. It makes it very difficult to write multiplatform code that has the same semantic regardless of the platform, it drives developers away because of it, and it makes it feel like the language is working against us by crashing at runtime enforcing arbitrary rules that aren’t shown by tooling.

It cannot hold.
I really hope it won’t.

This doesn’t mean that the paradigm itself is wrong. Just the implementation. There are loads of ways to implement this in a correct, less frustrating way, and I sincerely hope the Kotlin language designers will take this road.
And if it doesn’t, well, compiler plugins may be a way for us to try new things!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK