nim-lang / RFCs

A repository for your Nim proposals.
135 stars 26 forks source link

Proposal to add 'owned' refs to Nim #144

Closed Araq closed 4 years ago

Araq commented 5 years ago

Owned refs

This is a proposal to introduce a distinction between ref and owned ref in order to control aliasing and make all of Nim play nice with deterministic destruction.

The proposal is essentially identical to what has been explored in the Ownership You Can Count On paper.

Owned pointers cannot be duplicated, they can only be moved so they are very much like C++'s unique_ptr. When an owned pointer disappears, the memory it refers to is deallocated. Unowned refs are reference counted. When the owned ref disappears it is checked that no dangling ref exists; the reference count must be zero. The reference counting can be enabled with a new runtime switch --checkRefs:on|off.

Nim's new returns an owned ref, you can pass an owned ref to either an owned ref or to an unowned ref. owned ref models the spanning tree of your graph structures and is a useful tool also helping Nim's readability. The creation of cycles is mostly prevented at compile-time.

Some examples:


  type
    Node = ref object
      data: int

  var x = Node(data: 3) # inferred to be an ``owned ref``
  let dangling: Node = x # unowned ref
  assert dangling.data == 3
  x = Node(data: 4) # destroys x! But x has dangling refs --> abort.

We need to fix this by setting dangling to nil:


  type
    Node = ref object
      data: int

  var x = Node(data: 3) # inferred to be an ``owned ref``
  let dangling: Node = x # unowned ref
  assert dangling.data == 3
  dangling = nil
  # reassignment causes the memory of what ``x`` points to to be freed:
  x = Node(data: 4)
  # accessing 'dangling' here is invalid as it is nil.
  # at scope exit the memory of what ``x`` points to is freed

The explicit assignment of dangling = nil is only required if unowned refs outlive the owned ref they point to. How often this comes up in practice remains to be seen.

Detecting the dangling refs at runtime is worse than detecting it at compile-time but it also gives different development pacings: We start with a very expressive, hopefully not overly annoying solution and then we can check a large subset of problems statically with a runtime fallback much like every programming language in existance deals with array index checking.

This is how a doubly linked list looks like under this new model:


  type
    Node*[T] = ref object
      prev*: Node[T]
      next*: owned Node[T]
      value*: T

    List*[T] = object
      tail*: Node[T]
      head*: owned Node[T]

  proc append[T](list: var List[T]; elem: owned Node[T]) =
    elem.next = nil
    elem.prev = list.tail
    if list.tail != nil:
      assert(list.tail.next == nil)
      list.tail.next = elem
    list.tail = elem
    if list.head == nil: list.head = elem

EDIT: Removed wrong proc delete.

Nim has closures which are basically (functionPointer, environmentRef) pairs. So owned also needs to apply to closures. This is how callbacks can be done:


  type
    Label* = ref object of Widget
    Button* = ref object of Widget
      onclick*: seq[owned proc()] # when the button is deleted so are
                                  # its onclick handlers.

  proc clicked*(b: Button) =
    for x in b.onclick: x()

  proc onclick*(b: Button; handler: owned proc()) =
    onclick.add handler

  proc main =
    var label = newLabel() # inferred to be 'owned'
    var b = newButton() # inferred to be 'owned'

    b.onclick proc() =
      label.text = "button was clicked!"

    createUI(label, b)

main is transformed into something like:


  proc main =
    type
      Env = ref object
        label: owned Label
        b: owned Button

    var env: owned Env
    env.label = newLabel()
    env.b = newButton()

    b.onclick proc(envParam: Env) =
      envParam.label.text = "button was clicked!"

    createUI(env.label, env.b)

This seems to work out without any problem if envParam is an unowned ref.

Pros and Cons

This model has significant advantages:

And of course, disadvantages:

Immutability

This RFC is not about immutability, but once we have a clear notion of ownership in Nim, it can be added rather easily. We can add an opt-in rule like "only the owner should be allowed to mutate the object".

Possible migration period

Your code can either use a switch like --newruntime and needs to use owned annotations or else you keep using Nim like before. The standard library needs to be patched to work in both modes. owned is ignored if --newruntime is not active. We can also offer an --owned switch that enables the owned checks but does use the old runtime.

SixteNim commented 5 years ago

There are some (a couple) of views on the delete procedure.

  1. delete gets an incoming Node "elem" that is a shared_ptr but can't be used later anymore. Problem: delete can't set it to zero because it's a value. But there could be an attribute kill in the param list that allows the callee to dereference the count of the Node. Or, better in this case, the expected refcount of "elem" has to be 1 or less. Anyway, the kill attribute gives safety to both the caller and the callee. It statically assures that "elem" can't be used anymore. And it even seems to avoid a refcount incrementation (on the heap) , when the "elem" unowned ptr is created on the call site. (*)

  2. delete as a a function, that removes a given node from a list and returns it as owned. This does not affect the refcount in the delete function! Then, it is up to the caller to deal with the returned node. To get rid of it, "elem" has to be set to null. When the returned (owned) Node gets out of scope, the refcount will be checked for zero and then deallocation takes place. In Pony, they have a thing called "destructive read"; e.g. a=b=a exchanges the values of a and b. The exchange at call site, in delete ,would be:

result owned = elem.prev.next = elem.next, whereby: result owned = elem.prev.next <=> result owned = elem (logically)

EDIT: changed back to prev , some remarks added...

(*) (In other functions, the Node that "elem" points to could survive - it could be rebound to another owned ptr on the heap. Depending on context, this can lead to further deallocation or to exchange/"destructive read type" described above (pt 2). However, since the caller can't see what really happens in the called procedure, the caller has to nullify the "elem" anyway. With static analysis, no assignment will be performed. Instead, the Node simply becomes inaccessible. In all cases, it is presumed that the "elem" has been drawn from the var List. If the elem itself was passed as a "val" parameter , the caller itself beeing a callee, then it's not clear for me how to avoid the dangling pointer. It is a chicken and egg problem...)

rayman22201 commented 5 years ago

@SixteNim I looked at the example more closely, and I agree with you that the delete implementation described in Araq's linked list example does not work. IIUC, the program would abort on this line:

# This will abort because elem.prev.next has a non-zero reference count at re-assignment time.
    if elem.prev != nil: elem.prev.next = elem.next

The Gel paper does not explicitly describe any delete operation for the linked list, but I believe your 2nd solution is the closest to the philosophy of the paper, based on section 2.1 Fields and the take operator they describe.

Essentially the the delete procedure has to take ownership of the element, and return it to the caller.

Update: I talked to Araq about this on IRC. (see logs here: https://irclogs.nim-lang.org/01-04-2019.html#20:41:42)

I prefer this option as it gives the caller more control, but it is a large API change with semantics that people may not be familiar with (especially coming from other languages).

This isn't as intuitive as I would like, but does provide the correct semantics.

andreaferretti commented 5 years ago

@rayman22201 @SixteNim Returning an owned node may work for this case, even if it implies a change in the API. But this is just an example. It is not clear to me how to generalize this mechanism.

In general, a procedure may delete anything that is reachable through its arguments via an owned ref. Should we then force the programmer to return a tuple of owned refs containing everything that may be deleted in the procedure? This would make for some strange APIs, especially if one already wants to return something else in the procedure. I am not even sure that this can be enforced statically, so it probably would boil again to discipline.

SixteNim commented 5 years ago

In Araq's case, a sink keyword can do the work. With sink, the callee expects "refcount zero"(*). This is only possible, if the caller marks the ref and _affected _aliases__ as unaccessible.

delete needs to be declared with elem :Node sink . That clearly states that elem is not accessible longer after the "delete" call. And no other alias affected with it . The elem itself has to be either an owned pointer or an ephemeral ref or an alias. The number of aliases is not relevant. But they need to be grouped together. If this is statically possible, then they are sink-able. If not, then the ref has to be passed with refcount(+1), it becomes "unsinkable" and therefore this should raise an error at compile time.

   #from import
       proc gimme_da_object  (list  :List, key :int)  :owned ref = #........
       proc showme_da_object (list  :List, key :int)  :ref = #........
       proc myfun(mynode  :Node sink) = #.....
   #end import

   #body current proc starts now
   var a  :Node = gimme_da_object(mylist, key1)   # owned ref
   let b  :Node = showme_da_object(mylist , key2) # normal ref and ephemeral at this point
   let c  :Node = showme_da_object(mylist , key3) # etc....
   let d  :Node = showme_da_object(mylist , key4)
   let e  :Node = if random()>0.6:  a   else:  b
   let f  :Node = if random()>0.5:  c   else:  d
   let g  :Node =  a

   #lousy lousy, but let's try something....
   a.data = 42  # ok.
   var x = f.data  # ok.
   var y = e.data # dito
   myfun e   # no! ... ok. lets try something else
   myfun g  # this seems to work, nice...
   x = e.data  # oh no! (Why?)
   x = c.data  # no problem
   myfun f # works! surprise surprise !
   y = c.data # oh no!

   # we are done. No declared var or val can be used anymore. No deallocation necessary though. 

(*) Double linked lists typically have a minimal refcount "1" , due to the prev_pointer. This needs to be handled by the delete procedure.

andreaferretti commented 5 years ago

@SixteNim I have troubles parsing your example, can you explain what you mean by ephemeral ref and alias? Also, what does it mean The number of aliases is not relevant. But they need to be grouped together?

SixteNim commented 5 years ago

@andreaferretti ephemeral ref is a ref, that has been "drawn" from the heap and exists on the heap as an owned ptr . So, the ref doesn't own the object. However, the ref itself is not an alias. But when the ephemeral ref is copied, then there is an alias. And there can be a couple more and many more. Given sufficient statical analysis (something that Araq presumes too), they altogether add to the refcount of the object with max. one. In the sink case it is zero. Otherwise, one. In my example given above, the refcount of e is one. But any other ref or aliased ref can be passed with refcount zero to the myfun function. ( I should declare the gimme_da_object though....) Grouping: When an object gets out of scope , then the owner and every alias of the owner gets affected. They together are a "group", in fact, they are a set.

rayman22201 commented 5 years ago

@andreaferretti

I am not even sure that this can be enforced statically, so it probably would boil again to discipline.

The Gel paper shows that this is in fact enforcable. As @SixteNim says, the refs and aliases do form a set. The Gel paper specifically describes it as follows, in the introduction of the paper: "all objects in a running Gel program belong to a set of ownership trees".

My first option is viable for any collection, it is a general solution. You are correct that you could end up in a situation where you are returning a tuple of unlinked references up the call stack. I think that is highly unlikely to happen in practice, and is a "code smell", but that's just my opinion. I did say that it was the more opinionated solution, and not everyone would like it.

In general, a procedure may delete anything that is reachable through its arguments via an owned ref.

This is not true! The condition that "a delete can only occur if the ref-count is 0" still applies, and is enforced! A procedure may delete anything that is reachable through it's arguments via an owned ref, if that ref has a ref-count of 0.

The second option, using in/out parameters is more viable, and what the Gel paper uses. It's not explicit in the paper, but based on the fact that Gel is based on C#, this is the obvious solution that they use.

Using that option, an arbitrary procedure could have in/out parameters for the aliased / unowned refs, which can be disarmed by setting them to nil. It's pass by reference, so the caller would see this change, and the ref-count would be correctly adjusted.

In a way, this is the inverse of the first option I proposed. Instead of passing a set of owned refs back up the call stack, you pass a set of un-owned refs down to the procedure.

Either way, it will be enforced by the compiler and runtime. So it is not purely left to "programmer discipline".

Deletion is a special case, even Rust and Swift have trouble handling deletion for the general case. The api's will have to change in some way to compensate with this memory management scheme. But I don't expect them to be particularly "strange" or unusable in practice. I truly believe that is FUD.

As @SixteNim points out, you could use sink to verify common ownership pre-conditions. sink is a special form of ownership transfer (one that guarantees no aliases exist at the time of transfer).

Araq brings up a good semantic point about deletion and sink though. In his words:

"'sink' says "I'm taking over", it's for add-like operations" but "delete doesn't say "I'm taking over", it says "I'm gonna nuke this one""

SixteNim commented 5 years ago

Well, an alternate solution for Araq's delete can be given with the precursor of the object-to-delete. This works for both singly and doubly linked lists isn't it. In both cases, delete would work without the sink annotation, accepting unowned references. delete recovers the deletable object with precursor.next then. The compiler might still optimize , avoiding unnecessary (costly) increments / decrements of the precursor's refcounter on the heap.

delete doesn't deal with owned nodes. All nodes in a list are already bound to an owned ref on the heap, so the delete can never take an owned Node . However, there are cases where a Node sink is required. E.g. a proc could take such a Node and shift it to a new place. Surprisingly, it is even possible with Node (unowned) via prev (if available), that points to to the Node with the owned Node pointing to the object itself. Having both the object and the "owner", the callee can do many things with it.

Araq commented 5 years ago

I think we need a variation of sink T that was called kill T, then proc delete becomes


proc delete(list: var List; elem: kill Node) = ...

with the obvious semantics: It's ensured statically that after delete(list, n) the n is not accessed afterwards. In this sense unowned ref has an assignment operator but passing it to a kill parameter is a move-only passing mode. I don't really like this parameter passing mode but it's justified by the overall design paradigm here: Memory safety is attached to a container and every operation of the container needs to be precise about its semantics. Who knows, maybe the lack of kill in Rust is what costs it the required expressivity to model lists.

That said, it remains to be seen how general kill T really is and what other type extensions or parameter passing modes are required though my gut feeling is that with kill it's finally complete...

andreaferretti commented 5 years ago

@rayman22201

This is not true! The condition that "a delete can only occur if the ref-count is 0" still applies, and is enforced!

Sorry, by enforceable I was meaning enforceable at compile time, and the ref count is of course not available at compile time.

I truly believe that is FUD.

I don't mean to throw FUD, I am just genuinely worried about how a Nim without GC will look, and how much more complex it will become.

As @SixteNim points out, you could use sink

Sorry, I am not familiar with sink. I tried to read Araq blog post about it, but it was not very understandable for me.

@Araq

I think we need a variation of sink T that was called kill T

This exemplifies my worry better than anything I could come up with. Your proposal for a new runtime originally addedsink T. Then, it turns out a new owenrship concept was needed, and owned T is being proposed here. But even for a simple example this is not working, so now we have kill T.

If Nim becomes what you are describing, I don't think it is for me. I don't mean to be polemic, and I will restrain from commenting further on this issue unless I am asked explicitly, but the semantics is just getting more and more complex, and I am still not sure all cases are covered. I don't know, maybe coming from C++ all these concepts are natural, but I don't think I can manage to program in a language where I can pass parameters in 5 subtly different ways (T, var T, sink T, owned T, kill T).

Araq commented 5 years ago

Your proposal for a new runtime originally added sink T. Then, it turns out a new owenrship concept was needed, and owned T is being proposed here. But even for a simple example this is not working, so now we have kill T.

owned T is not needed, the destructors work fine without them. But if we go the extra mile the complexity decreases further since we can avoid a GC mechanism. That's the idea anyway, maybe it doesn't work out, but the idea is to reduce the complexity, quite ironic, isn't it. For example we can then remove from the language:

but I don't think I can manage to program in a language where I can pass parameters in 5 subtly different ways (T, var T, sink T, owned T, kill T).

SixteNim commented 5 years ago

@andreaferretti Nothing wrong with your concerns. You were the first who pointed out in:

The doubly linked list example is in the paper, and it seems to agree with @Araq description. In the paper, I could not find an implementation of delete, though. I am not sure whether delete, as implemented in the original post by @Araq, would pass the type checker from the paper.

that the original paper is somewhat incomplete. E.g. he delete function is lacking (something they can't do with their proposal). In fact, their proposal is a rather simple generational GC, strictly bound to the stack. Top on stack = young, deep in the stack = old (*). So they basically model the heap as an extended stack. Thanks to the heap, values on the stack can now have variable size. What's new : They introduce runtime checks for a static design. Sounds odd. And it is. However, there is a reason behind it: They emphasize that data with long lifetime ("old") should not have bindings to short living data. A Gel program that obeys the rule will never have a runtime error caused by wrong refcount.

You basically will not see the changes at the application side. It is self-explanatory if you delete something, you can't use it anymore. But the compiler doesn't understand "delete". The compiler has to follow requirements/capabilities rules. When you call a delete and the compiler finds kill T - then it will remove the T and any alias of it from the scope (or disabling it with a flag). The basic difference between sink and kill : sink worked only for owned refs. But kill works for unowned. In this case, a delete function has to recover the ownership of the ref. In the middle of a doubly linked list, it's easy. At the head of a doubly linked list it's not - the ownership has to be found somewhere else. ( In the List param. of course). With the recent GC in NIM, any inconsistency leads to the same result: Don't deallocate. However, these inconsistencies lead to data races. No one informs you and you feel good. In many cases, these inconsistencies are of minor grade: some minor glitches, but okay and no one remarks them. Rarely, they are not minor longer.

@Araq When to proc body contains an owned ref (via new ) and and alias has been made, the compiler should take notice of it. In this case, a kill T basically transfers an "owned ref". Will this raise an error? Furthermore, a T passed as a parameter can't be transformed into a kill T . Otherwise, this would break consistency and definitely lead to a runtime refcount error - the compiler should report this early. Still, if kill T was already passed with kill, it's fine.

(*) and young dies soon, the older the longer (on the stack)

Araq commented 5 years ago

In fact, their proposal is a rather simple generational GC, strictly bound to the stack. Top on stack = young, deep in the stack = old (*). So they basically model the heap as an extended stack. Thanks to the heap, values on the stack can now have variable size.

This description is a unique way of putting it and I am still thinking whether it's true. :-) Can you elaborate? The "generational" aspect seems completely off, instead I would call it a "one bit" reference counting GC so stuff is freed as soon as it's dead and the RC is stored in the pointer value avoiding most of the RC overhead. And the RC here is the nil/not nil aspect of the owned ref, not the RC value they use for unowned refs.

SixteNim commented 5 years ago

@Araq

Can you elaborate? The "generational" aspect seems completely off, instead I would call it a "one bit" reference counting GC

There is no GC. Anything that goes out of scope gets deallocated. Period. The scope is the stack. There is a timeline: The top of the stack contains the youngest part of the scope. The "generational part" comes from an implicit barrier: "Young" data bound to an "old" ref is forbidden, otherwise you get a runtime crash. (You should get a compiletime error though). Generational GC's are focused on such barriers on memory regions, keeping them separate by "age". No memory regions for Bacon/Dingle though, they take the stack instead. That seems simpler to me. With Bacon/Dingle: The Scope is the Stack is the Reachability is the (implicit) Refcount (1)

They could do something else: with refcount-on (the explicit refcount) and for refcount>0, deallocation could be postponed and reported later. So you could still have a running (but potentially faulty) program. This would justify the effort of a tracking refcount in my opinion.

And the RC here is the nil/not nil aspect of the owned ref, not the RC value they use for unowned refs.

... And we just came to the conclusion that an owned ref should not be set to zero dynamically but statically instead. It is the breaking point of the Bacon/Dingle system. There is a subtle difference between sink and kill : both exclude access after the resp. call. But kill has to check for an owned alias. If so, the owned becomes (statically) unowned.

The main questions regarding Nim are: It is doable? (yes). Are improvements necessary ? (probably). Can Araq do it? (yes). So what....

Araq commented 5 years ago

Er, that misses quite a lot, no offense. Say I have an a: array[N, owned ref T], if I update slot a[10] then the old memory is freed (and that's good!). That's not stack-like at all.

SixteNim commented 5 years ago

Say I have an a: array[N, owned ref T]

I assume that the array is on the stack. This is access to fields. Static analysis can't do much here. Formally, when you update a slot, you set the owned to nil (deallocation takes place), the source , another owned . gets then transferred to the a[10] . The source, formerly owned, becomes either disabled like kill ( the best solution, if statically possible) or remains as an unowned . Unfortunately(*) in this case, the refcount of a[10] has to be incremented (costly). With field access, things become less effective. Unavoidably, when the a finally leaves the scope, every single slot of it has to be checked for further deallocation. It's so sad.

(*) you could prevent it by setting the source unowned to nil though

Araq commented 5 years ago

I assume that the array is on the stack.

No, it doesn't matter. It could be on the heap. For example, it could be your list of open browser tabs. You close a tab, the owned pointer is set to nil, all the memory the tab consumed is freed. For me this models a heap accurately and has nothing to do with a "simple generational GC" as it doesn't use memory regions.

SixteNim commented 5 years ago

It could be on the heap.

Fine, what is the difference to a ref Object then?

For me this models a heap accurately

I think the recent GC for Nim does the same. Bacon/Dingle look at the scope differently. And they reverse the ptr ref relationship. In Nim, ref is counted , ptr is not. Recent Nim uses ref for owned and ptr for unowned. There can be many ownerships though aka shared ptr. No unique_ptr . With Bacon/Dingle, ref becomes unowned and counted, but ptr is now splitted in two uncounted refs: the ptr remains and the other uncounted ptr is called owned. In some aspects, it is an U-Turn. But anyway: The problem are the atomic pointers/references in the scope, not the refs on the heap. We already run in difficulties. These difficulties ar NOT Nim related. Static analysis becomes important. If done well, the potential may be enormous. If not, things will not getting better. High frequent up/down counting on the heap is a bad decision.

Araq commented 5 years ago

Fine, what is the difference to a ref Object then?

The point is that Bacon/Dingle do not model a stack, they model a heap. And it's not split into regions like a generational GC would do it, if you want to compare it to a GC, it's closer to reference counting than anything else.

SixteNim commented 5 years ago

We should discuss potential traps and flaws of the Bacon/Dingle model regarding Nim.

The point is that Bacon/Dingle do not model a stack, they model a heap. And it's not split into regions like a generational GC would do it, if you want to compare it to a GC, it's closer to reference counting than anything else.

Your GC is heap-based . The stack is a pointer-pool only , an array of u64 , potential refs to the heap isn't it. The Checkpoints for deallocation are zero-refs on the heap resulting in potentially further deallocation. The GC allows for sharing of refs arbitrarily. If Nim would take the Bacon/Dingle model as published, it would abandon the GC. It's not a "simply better GC".

The point is that Bacon/Dingle do not model a stack, they model a heap. And it's not split into regions like a generational GC would do it, if you want to compare it to a GC, it's closer to reference counting than anything else.

It is basically a malloc/free with unique_ptr. Every object on the heap can be reached by a chain of unique_ptr , Presumed, that the program is correct, nothing else is needed. If an owned gets out of scope, deallocation takes place after checking the ptr for nil. The same happens if a ownedgets nilified deliberately. Nice, but let's find out now where the pitfalls are.

andreaferretti commented 5 years ago

I had promised to avoid more commenting but there is one point I think is important from this comment of @SixteNim

It is self-explanatory if you delete something, you can't use it anymore

This is not self-explanatory at all. I am deleting an item from a list, not wiping it out of existence, at least not intentionally. This is something I can do with a GC

list.delete(node)
# use node for something else, unrelated to its presence inside `list`

Maybe I am just moving it from a container to another

list1.delete(node)
list2.add(node)

The only solution really seems to return an owned reference to the caller, like

let node1 = list1.delete(node)
list2.add(node1)
SixteNim commented 5 years ago

@andreaferretti

list1.delete(node) list2.add(node)

The ´´delete´´ is an unlink. It should be: let enode = list1.unlink(node) let enode being owned Node (inferred) and then: list2.add(enode)

unlink gets a unowned and returns an owned Node. The unowned should be passed as kill Node. The caller gets the node back however! list2.add(enode) expects an owned as shown.

Araq commented 5 years ago

Nice, but let's find out now where the pitfalls are.

What do you think we have been doing in this thread? ;-)

disruptek commented 5 years ago

I may be all wet on this, but shouldn't the goal be to clarify dialog with the user? I agree with @mratsim that we need language constructs to codify our dispose intent, especially since this is a particularly significant notation (and perhaps because dangling = nil is so insignificant).

Explicit language isn't just easier for us to read and reason about; it's easier for the compiler to analyze and optimize, too. The rewards are more accurate static analysis, more precise debugging information, and superior optimization. These aren't Rusty hoops we have to jump through; these are the means by which we may sidestep those hoops, and (not least) they help the compiler better serve our actual intent.

If the specificity with which we can notate semantics improves, then I don't see how it will matter which backend the user selects -- the compiler will be able to do the Right Thing given the circumstances, both with libraries that assume a GC and with a scope-based MM technique and with whatever new-fangled automanual gizmo arrives in 2.0.

Araq commented 5 years ago

@disruptek It shall be called disarm.

Wh1teDuke commented 5 years ago

I chose nim for the reason of being a fast and elegant language that was easier to read and understand than c/c++ (while being almost as fast), but faster and less verbose than c#/java without sacrificing safety. I'm happy with the ptr/ref dichotomy, and though is not perfect I can work around it, since for the majority of my projects and code ref is enough. It removes the weight off my shoulders.

I'd be happy to know that v1.0 will be released under the initial assumptions on resource management, or else to know that the change will not happen overnight. And, if possible, to prioritize fixing the most important bugs first before any library adaptations.

runvnc commented 5 years ago

I'm really happy to hear about this as it seems to be a major improvement to the language. I actually always assumed that Araq would find some nice ergonomic way to add more safety and efficiency.

However, I may need to scroll through again but from the discussion I am not 100% certain exactly where this leaves the 1.0 release and whether it actually is going into 2.0. Can I assume that there will be a 1.0 with libraries set up for GC that is available for use while we learn the new system and it gets refined?

I wonder if there is a way to distinguish between these major runtime discrepancies when it comes to packages and dependency management.

Araq commented 5 years ago

The current plan is:

Worst case: it turns out that everything fails and owned is not worth its costs. Then you're left with pointless annotations about ownership that are neither checked nor enforced until we remove them again.

cooldome commented 5 years ago

Just as untested idea: is it possible to use existing system.reset instead of introducing new system.disarm?

SixteNim commented 5 years ago

Nim v1 will ship with the existing GCs, what else.

Seems reasonable. Is there still some hope for a vtable implementation ? Like Rusts "fat pointer" ?

Araq commented 5 years ago

Just as untested idea: is it possible to use existing system.reset instead of introducing new system.disarm?

Seems too risky, firstly it's not clear what reset is for. Secondly, disarm would only exist for unowned refs.

Seams reasonable. Is there still some hope for a vtable implementation ? Like Rusts "fat pointer" ?

There are Nimble packages that provide these things and v1 needs to be about stabilizing what we have.

danieloff commented 5 years ago

I'm very interested in trying this feature if/when it is functional enough. There always seem to be some comments/fear about C# and managed languages, not having gc... etc. I'll not speculate here, but I did want to just mention my personal perspective as a potential nim user. I've got 3 years professional c# usage on 3d house design/engineering software. And 7 years as a pro c++ developer for mining optimization software. The only way I'm going to be able to touch these "less popular" languages is in my free time. Or if I can sell it to my boss :), of course. I love learning about them. Read the rust book. Read the nim book. Ported a good portion of the corange engine to rust as a hobby experiment to see what made the language tick. I would like to get into nim more. As far as I'm concerned there is little difference with coding a gc language, or a non gc from my user perspective: if the container object is thin & is on the stack, heap allocations are hidden behind some abstraction and there is some deterministic hands off destruction. All written by whoever is providing the structure. I know there are a lot of details left out there, but for "getting stuff done", it serves me. Pythonic just seems to be the syntax my mind likes best and nim has the macro system going for it, regardless of the semantics of object destruction. And it is compiled. I just wanted to chime in that this particular proposal was something that really struck me as intriguing for how I personally like to code.

StefanSalewski commented 5 years ago

The owned refs conceps seems to be similar to Pointer Ownership Model of David Svoboda

https://resources.sei.cmu.edu/asset_files/WhitePaper/2013_019_001_55008.pdf

https://resources.sei.cmu.edu/library/asset-view.cfm?assetid=55000

jwollen commented 5 years ago

@Araq your blog post mentions backing refs with type-specific memory allocators. Right now in --newruntime they still uses alloc0.

Would refs eventually use Allocators, just like seq? If so would that abstraction change? E.g. similar to c++ std::allocator<T> and std::memory_resource?

Araq commented 5 years ago

There is also -d:useMalloc for --newruntime and then it uses malloc with its ways to "override" it at linking time.

jwollen commented 5 years ago

That's slightly ugly, since both the default allocator and new use malloc, so having malloc call into an allocator isn't ideal. Also I wouldn't want to get rid of the default allocation strategy, but mix it with others.

I would instead suggest for new to use the current local allocator, like seq. refs already behave much like intrusive pointers, so i would go all the way and have them carry around their allocator. Would you be ok with such a PR?

Araq commented 5 years ago

Bah, it's ok I guess, but we should have allocators that are page-aligned (or more than that) so that we can do bitmasking of pointers to access the underlying allocator, no need to carry the allocator around as a separate internal pointer everywhere.

johnperry-math commented 5 years ago

Question: Why is it necessary to have a runtime error for dangling unowned refs? Could the runtime not simply set them to nil? This would require the use of unowned refs to be guarded by nil, which I think can be checked statically -- for instance, Kotlin and Eiffel do it, if I understand correctly.

Apologies if this is a dumb question, but it came to mind.

Araq commented 5 years ago

Setting weak pointers to nil automatically is much more expensive, you would need to keep a data structure around just to be able to do this. This is particularly problematic for unowned refs on the stack, it would require a register/unregister pair for every stack slot that has an unowned ref.

johnperry-math commented 5 years ago

@Araq Had I but paused a moment to think of that, it would have been obvious. Thank you.

duneroadrunner commented 5 years ago

you would need to keep a data structure around just to be able to do this

But not necessarily a separate data structure. This is implemented by, for example, SaferCPlusPlus' "registered pointers" by making each pointer object double as a node in a linked list that tracks all the references currently targeting the given object.

The registering and unregistering does add some expense. SaferCPlusPlus provides both "auto-self-nulling" registered pointers and non-owning alias counting "norad" pointers like the references Nim seems to be adopting, and simple micro-benchmarks for them. The micro-benchmarks indicate that the copy and assignment operations of "auto-self-nulling" pointers are, as expected, more expensive than those of "alias counting" pointers, but not by as much as I might have thought. (Shared-owning (non-atomic) reference counting pointers appear to be in the same performance ballpark as well.) In practice, my experience is that there's rarely a noticeable performance difference in the application overall.

With this proposal, it seems to me that the primary use case for these non-owning references would be dynamic data structures with cyclic references. I suspect that in most of those cases either the differences in overhead of any pointer operations would be small relative to the cost of the accompanying memory allocation operations, or, for cache-coherency reasons, the organizational structure of the nodes (if not the nodes themselves) would be stored in a contiguous vector, in which case indexes could/would take the place of the non-owning pointers. No?

That said, I don't disagree with the choice of using "alias counting" as the safety mechanism. But I don't think it's an either-or situation. There are (maybe rare but) legitimate use cases for weak pointers that can handle the "untimely" destruction of their target. I think that ultimately you're going to want to provide both.

Araq commented 5 years ago

Most forms of weak pointers, including "auto niling" pointers solve a different problem, a "modelling" problem. For instance in a game your entities can "die" and then all the subsystems are "notified" about this. I don't think it's a good general purpose model -- when I write a BTree implementation etc I don't want to be notified about my now-dangling pointers, I want to fix my implementation so that it doesn't dangle.

Of course, with Nim's destructors and assignments you can implement all these things, you can also implement traditional reference counting, but the proposal is about what Nim's default, builtin ref should do.

duneroadrunner commented 5 years ago

I don't want to be notified about my now-dangling pointers, I want to fix my implementation

Is this part of a general "Don't attempt to recover from program logic errors." principle? Is there an inconsistency with out-of-bounds array index errors throwing an exception rather than terminating the program? I wouldn't disagree that "bailing immediately" is probably the most appropriate course of action in most cases when encountering a logic error. But I don't know, I could imagine there being situations where attempting to handle it more gracefully would be, while not ideal, still preferable.

with Nim's destructors and assignments you can implement all these things

Yeah, that's a good point. If there is sufficient demand for these alternatives they'll likely end up being implemented anyway. Without adding clutter to the base language.

Araq commented 5 years ago

Is this part of a general "Don't attempt to recover from program logic errors." principle?

Yes.

Is there an inconsistency with out-of-bounds array index errors throwing an exception rather than terminating the program?

No, it is not, from https://nim-lang.org/docs/manual.html#definitions

"Whether a checked runtime error results in an exception or in a fatal error is implementation specific. Thus the following program is invalid; even though the code purports to catch the IndexError from an out-of-bounds array access, the compiler may instead choose to allow the program to die with a fatal error."

In fact, we restructured the exception handling to make this more explicit.

andreaferretti commented 5 years ago

Whether a checked runtime error results in an exception or in a fatal error is implementation specific

Wow, that's strong. Is there a performance reason for doing so?

Araq commented 4 years ago

This RFC was succeeded by https://github.com/nim-lang/RFCs/issues/177