Kotlin-native: WeakReference

Created on 7 Oct 2019  路  12Comments  路  Source: JetBrains/kotlin-native

Hello,

I've noticed some unexpected behavior with Kotlin WeakReferences when bridging between Swift/ObjC and Kotlin

ARC, as I understand it, has semantics that state when using a weak reference, as the last strong reference is removed, the value of the weak reference is deallocated.

It appears that the Kotlin WeakReference has semantics closer to that of a JVM WeakReference.

As I understand it, this isn't bad in and of itself, but when it comes to interop between Swift/ObjC this is awkward.

One use case would be a UIViewController that is assigned to a WeakReference with the expectation that as soon as the UIViewController is no longer needed, that the value of the WeakReference would also no longer be needed. This is important when it comes to forwarding events to the UIViewController where the UIViewController would update UI state based on the event. One would want the propagation of those events to stop as soon as the UIViewController goes out of scope.

What I've observed is that the WeakReference is held until I force GC in the Kotlin world. Which is how I'm coming to the conclusion that Kotlin WeakReferences are more similar to a JVM WeakReference than a Swift/ObjC WeakReference.

Is what I'm observing the intended behavior? Is there a way to achieve similar semantics of Swift/ObjC in relation to WeakReferences?

With the understanding that you interop with ARC, I would expect a different behavior.

If this was a design choice, it might be good to call this out specifically (or, if you have already... I couldn't find it).

Thanks!

Most helpful comment

Thanks for the response.

Just to provide some clarity to anyone who might run into something similar. This particular case, at least for me, is dealing with a Kotlin multiplatform project. Where we define a common WeakReference. When on the JVM, the WeakReference follows the memory semantics of the JVM. When on iOS, the K/N WeakReference follows the semantics of K/N and not that of Swift/ObjC's ARC. It makes sense what it's doing now, but it wasn't immediately clear. When passing a swift object that was allocated in swift to a K/N WeakReference, the lifecycle of the object changes from that of Swift/ObjC's ARC to that of K/N GC.

The confusion came from me in that I incorrectly assumed that K/N relied on Apple's ARC implementation for managing objects that were allocated in Swift/ObjC. While it does use the retain/release operations, when passing Swift/ObjC's objects into the Kotlin Runtime, the semantics of Apple's ARC can change depending on how you use that object.

All 12 comments

WeakReference in Kotlin/Native doesn鈥檛 have scope bound guarantees, but generally it is possible to keep strong reference and zero out it after leaving certain block (a la memScoped).

Given the super rough example below, how can I ensure the class VC doesn't receive an onEvent method call once the VC has been dismissed from the navigation stack using a WeakReference in kotlin? From my experiments, until I force GC in Kotlin, the VC class remains available in the WeakReference. Which is contrary to what would happen if I had a weak reference in swift.

interface ExampleListener {
  fun onEvent()
}

class ListenerRecord<T>(val ref: WeakReference<T>) {
  val value: T? get() = ref.value
  init { freeze() }
}

object Listeners<T> {
  private val listeners = AtomicReference(emptyList<ListenerRecord<T>>())

  fun register(listener: T) {
    listeners.set(listeners.value + listener)
  }

  fun notify(block: (listener: T) -> Unit) {
    val remove = mutableListOf<ListenerRecord<T>>()
    listeners.value.forEach {
       if (it.value == null) {
         remove.add(it)
       } else {
         block(it.value)
       }
    }
    listeners.set(listeners - remove)
  }
}
class VC: UIViewController, ExampleListener {

  func init() {
    Listeners.register(listener: self)
  }

  override func onEvent() {
    // do something with ui state
  }
}

Which, I think you're confirming that this is a design decision. And that WeakReferences are only cleaned up during a GC cycle.

Which means, WeakReferences don't interop with arc in the way I thought they would.

And, you're suggesting that I need something roughly equivalent to:

class VC: UIViewController, ExampleListener {

   func willShow() {
     Listeners.register(listener:self)
   }
   func willHide() {
     Listeners.unregsiter(listener: self)
   }
}

"And that WeakReferences are only cleaned up during a GC cycle." - not sure what you exactly mean by that, but generally delayed RC applied and cyclic garbage is analyzed as part of automated memory management routine, which usually happens automatically.

I mean, that in swift, when a weak reference loses all strong references it is deallocated almost immediately (and, in my use case, the listener stops receiving events).

In Kotlin, this seems to not be the case and that it is part of a GC cycle. In that, I can force it to be cleaned up immediately by calling kotlin.native.internal.GC.collect(). And, if I don't force GC collection, the listener continues to receive events.

Another way to explain what I'm seeing is that Kotlin's WeakReference (even though it's holding a Swift object) is less deterministic than what I see with a Swift weak reference. Which, isn't bad, it's just not what I expected with Kotlin's interop with ARC.

It's not related to WeakReference semantics, it's more about general memory manager guarantees in K/N, which doesn't promise that garbage is immediately collected.

Thanks for the response.

Just to provide some clarity to anyone who might run into something similar. This particular case, at least for me, is dealing with a Kotlin multiplatform project. Where we define a common WeakReference. When on the JVM, the WeakReference follows the memory semantics of the JVM. When on iOS, the K/N WeakReference follows the semantics of K/N and not that of Swift/ObjC's ARC. It makes sense what it's doing now, but it wasn't immediately clear. When passing a swift object that was allocated in swift to a K/N WeakReference, the lifecycle of the object changes from that of Swift/ObjC's ARC to that of K/N GC.

The confusion came from me in that I incorrectly assumed that K/N relied on Apple's ARC implementation for managing objects that were allocated in Swift/ObjC. While it does use the retain/release operations, when passing Swift/ObjC's objects into the Kotlin Runtime, the semantics of Apple's ARC can change depending on how you use that object.

Interesting thread. What is strange to me is that it seems this used to work. In my testing earlier this year, WeakReferences would be cleaned up almost as soon as they were eligible. But now I see the same behaviour as you.

I found this discussion in KotlinLang slack in which somebody asks for help for the same problem:
https://kotlinlang.slack.com/archives/C3SGXARS6/p1564146572072800?thread_ts=1564061867.068600&cid=C3SGXARS6

Note the following quote by @kpgalligan

Prior to 1.3.40, when object refs got to zero they鈥檇 be cleaned up immediately. Now there鈥檚 an allocation cache and a GC threshold before objects are cleaned up, which is OK except in interop cases where deinit is expected immediately

So it seems this approach used to work but now does not.

Regarding the architecture problem of an MVP approach in which the view is an iOS ViewController, and the presenter is a Kotlin object, there seem to be two potential approaches:

  1. (as suggested by the OP in that Slack thread) : passing an intermediate Swift object into Kotlin, which itself holds a reference to the ViewController. The Kotlin presenter would call methods on this intermediate object, which would simply forward them to the ViewController. Your intermediate objects are noisy and inelegant, however once you've set them up, you can forget about them.

  2. Something similar to what you suggest, unregistering the view when the view hides. But ViewControllers on iOS don't have a viewDidUnload, you need to implement deinit, which of course won't get called if a reference persists in Kotlin. So the solution then becomes, as a developer you need to be very careful to remember to manually deregister your view whenever you dismiss your ViewController.

Neither are optimal.

Yea, it's an unfortunate situation. I wasn't aware of that discussion. Thanks for sharing it. I landed on using a Swift wrapper object with a weak reference to the actual swift based class (it's a gross solution). As a result, I end up providing a Swift only framework that uses my K/N framework. In the Swift framework I provide some more friendly iOS utilities that make using K/N a little less frustrating for our iOS developers. In that I hide the fact that one must jump through hoops to keep from holding onto things that shouldn't be held on to.

Another unfortunate behavior that I've observed is that objects that hold implicit references (ex: a block used for a completion handler) end up holding onto view controllers for extended periods of time (which is super counter intuitive to iOS developers). So, then you end up having to mark everything as weak when you reference things like "self" from completion blocks (or provide other ugly work arounds ... which we've started doing).

So far, my impressions of Kotlin/Native's memory model are not great. I'm definitely bought in, in that we now have K/N running on many millions of devices every day. But, my frustration level with some of the nuances of interop with Obj-C/Swift ... continues to grow.

Which, is partially an expectation problem on my part, I suppose. But, I'm still holding onto hope that as more and more people adopt the technology that JetBrains will adjust some of it's opinions on how memory should be managed (and, equally as important to me, threading).

Was this page helpful?
0 / 5 - 0 ratings

Related issues

Cortlandd picture Cortlandd  路  4Comments

SBNTT picture SBNTT  路  4Comments

talanov picture talanov  路  3Comments

ghost picture ghost  路  4Comments

jonnyzzz picture jonnyzzz  路  4Comments