Repository navigation
weak: new package providing weak pointers #67552
Description
What is a weak pointer?
Weak pointers (or weak references, as they are referred to in other languages) allow developers to reference memory without preventing the garbage collector from reclaiming it. To prevent visible dangling references, weak pointers become nil when the referenced memory is collected. Weak pointers can be converted to regular ("strong") pointers which do prevent the garbage collector from reclaiming memory, and allow typical use and access to that memory.
Weak pointers are typically a bit harder to work with than regular pointers because they can become nil at any time. Virtually every weak-to-strong conversion must be guarded by a nil check. Often, weak pointers will become nil at unexpected times.
Nonetheless, weak pointers exist in many languages because they are useful. The main use-cases for weak pointers are related to efficient memory management and reclamation. For example, efficiently managing memory for canonicalization maps, or for memory whose lifetime is bound to the lifetime of another object (akin to JavaScript's WeakMap). Another good use case for weak pointers is for hinting to the GC that it's OK to drop some resources because they're cheap to reconstruct later, especially if those resources have a large memory footprint.
Proposal
I propose adding a new weak package to the standard library with the following API.
// Pointer is a weak pointer to a value of type T.
//
// Two Pointer values compare equal if the pointers
// that they were created from compare equal. This property is retained even
// after the object referenced by the pointer used to create a weak reference
// is reclaimed.
//
// If multiple weak pointers are made to different offsets within same object
// (for example, pointers to different fields of the same struct), those pointers
// will not compare equal.
// If a weak pointer is created from an object that becomes unreachable, but is
// then resurrected due to a finalizer, that weak pointer will not compare equal
// with weak pointers created after resurrection.
//
// Calling Make with a nil pointer returns a weak pointer whose Value method
// always returns nil. The zero value of a Pointer behaves as if it was created
// by passing nil to Make and compares equal with such pointers.
type Pointer[T any] struct { ... }
// Make creates a weak pointer from a pointer to a value of type T.
func Make[T any](ptr *T) Pointer[T] { ... }
// Value returns the original pointer used to create the weak pointer.
// It returns nil if the value pointed to by the original pointer was reclaimed by
// the garbage collector.
// If a weak pointer points to an object with a finalizer, then Value will
// return nil as soon as the object's finalizer is queued for execution.
func (p Pointer[T]) Value() *T { ... }
Design discussion
Representation
The weak pointer we propose would be represented via an indirection object under the hood. This means that each pointer that has any weak pointers associated with it also has an associated 8 byte allocation that contains a single pointer. This pointer is the actual weak reference. This representation is very CPU efficient, since the GC can atomically set a single pointer to make all weak references to an object nil.
This 8 byte allocation is rarely a problem in practice since weak pointers are most often useful for memory efficiency anyway. For example, canonicalization maps are deduplicating memory already, so the extra 8 bytes per canonical copy is relatively cheap. However, it may be worth calling out this detail in the documentation as the C# language does.
This representation has other benefits. One benefit is that the API's equality semantics fall out very naturally. Weak pointers created from the same pointer remain equal even after that pointer no longer exists. This makes it possible to build maps keyed by weak pointers. Another benefit is its simplicity: very little of the GC has to change to support weak pointers.
Avoiding object resurrection
A subtle but important property of the weak pointer implementation is that it must not enable object resurrection. This would create many complications, including the fact that weakly-referenced objects would take 2 GC cycles to reclaim; and they could not be involved in cycles, or they would never be reclaimed (much like finalizers).
A simple way to implement weak references that maintains this property is to guard weak-to-strong conversions with ensuring the object is swept. A swept object cannot be resurrected, because it is always definitely reachable or unreachable, nothing in between. This means the weak-to-strong conversion path may need to sweep the object, but this happens at most once per GC cycle, and the Go runtime's sweeper is quite fast, so the cost shouldn't be noticeable in practice.
Why this specific interaction with finalizers?
The interaction between weak pointers and finalizers that resurrect objects is subtle. There are two choices: the weak pointer can go nil before the finalizer runs, or it can track the object's resurrection, and only go nil once the object is well and truly dead. In C# jargon, this is the difference between "short" weak reference and a "long" weak reference.
At first glance, the latter seems clearer since the weak pointer really only goes nil once nothing can reference the object. However, this semantics has a big problem, and it's subtle.
Today, the decision to resurrect an object in a finalizer is internal to an API’s implementation. If we allowed weak-to-strong conversions after finalization, then clients of an API could resurrect an object without the cooperation of its implementation, at which point the object's internals may be in an inconsistent state. This creates the potential for a race that is not immediately apparent to either the API authors or the client. Worse still, these races will be very rare and unlikely to be caught by testing, even with the race detector enabled. If the weak pointer goes nil before the finalizer runs, then such races are impossible.
It is telling that other garbage collected languages either make "short" weak references the default and do not recommend their "long" form (C#), or only offer the "short" form to begin with (Java).
Risks
Breaking the GC abstraction
The unique.Handle proposal explicitly rejected weak pointers as an alternative on the grounds that they are difficult to use. Fundamentally, this is because they break the GC abstraction by revealing the timing with which memory is actually reclaimed, and this is mainly a problem because that timing is unpredictable. The GC abstraction of "infinite memory" (only allocation, no apparent free) is important in general for composability.
While it is true that weak pointers are more difficult to use than regular pointers, we believe that we have picked an API (building on the experiences of other languages) that minimizes surprises, maintains composability, and has a simple implementation. This was discovered during the implementation of unique.Handle and it was compelling enough that it led to this proposal.
Although weak pointers will be misused in some cases, providing documentation, examples, and advice in the GC guide will go a long way to mitigating that.
Activity
thediveo commentedon May 21, 2024
glad this is being resurrected *scnr*
cespare commentedon May 21, 2024
How does this interact with the unique package (#62483)? Isn't it possible for a user to implement unique (or at least solve the same problems that unique solves) using weak pointers? If we add weak, will we regret having both weak and unique?
mknyszek commentedon May 21, 2024
@cespare I don't think we'll regret having both. The main reason to have
uniquein std is that canonicalization maps benefit from being as global as possible, both for performance (bigger potential for sharing data) and composability (handles are always comparable, regardless of their origin). That alone makes it worth it, I think.A secondary reason is that it's not actually possible to safely implement the concurrent map underlying
uniqueoutside of the standard library today. #54670 (maphash.Comparable) would change that. While I thinkmaphash.Comparableis still important for the ecosystem, it's not going to be easy to make that as efficient as the hashing the std maps use. (See the issue for a breakdown of the problem by Austin.)Weak pointers are still useful for lots of other cases where globalness is less useful and maybe even not what you want at all. For example, implementing weak-keyed maps, which are generally used for attaching data to memory you don't own the lifecycle of. There's also plenty more reasonable ad-hoc use-cases that don't neatly fall into something std would support out of the box.
Merovius commentedon May 22, 2024
What does
Make((*T)(nil))do? Useless and the answer is probably "return aPointerthat always returnsnil", but just to make sure.mknyszek commentedon May 22, 2024
@Merovius Yep, that's right. Thanks! Updated the proposal.
apparentlymart commentedon May 23, 2024
Although a
weak.Pointerthat is immediately nil is a little strange, I've written programs with similar features in other languages where it's been helpful to represent an "optional weak pointer" without adding another level of indirection. I probably wouldn't complain too much if it weren't possible, but it has enough marginal use that I don't think it's worth overcomplicating things to forbid it.(For example, a Rust analog would be
std::rc::Weak::new(), which returns aWeakwhereupgradealways returnsNone, which is directly analogous to aweak.PointerwhereValuealways returnsnil.)Is the zero value of
weak.Pointer[T]equivalent to what you'd get from passingniltoweak.Make?mknyszek commentedon May 23, 2024
From my perspective, I believe that's what the semantics would have to imply. In the implementation, yes, I'd probably just have it return the zero value of the weak pointer.
EDIT: Oh, it occurs to me that passing nil for a specific type could have a different value than the zero value. (Some per-type sentinel value.) I don't think we should do that. I think the zero value of a
weak.Pointer[T]should compare equal with the result ofweak.Make[T](nil). Updated the proposal.LukeXeon commentedon May 27, 2024
go/src/internal/weak/pointer.go
Line 51 in 3776465
I see "weak pointer" under the "internal" package. Is there any progress on this proposal currently?
mknyszek commentedon May 28, 2024
@LukeXeon This proposal is awaiting review. If accepted, the
internal/weakpackage already contains an implementation that mostly implements the semantics laid out here. It could just be moved out ofinternalwith a few minor tweaks. Note that, if the proposal is accepted, this would land in Go 1.24, not the upcoming Go 1.23 (it's already too late for Go 1.23).eihigh commentedon Jun 10, 2024
Unlike server programs that allocate and release memory for each request and persist it in the DB, a weak pointer is essential for client programs that hold multiple containers or partially release items. I feel that Go lacks support for such programs.
(As an aside, #64777 is another example of a feature needed for client programs)
rsc commentedon Jun 27, 2024
In our discussion yesterday, @bradfitz asked if weak pointers made it possible to maintain a map of expensive derived information about something without preventing the GC of that something. Here is a worked example of how weak pointer enables that:
I am assuming runtime.AddCleanup from #67535, which is guaranteed to work with any pointer f.
(SetFinalizer might break things if f already had a finalizer, or if f was inside another object.)
43 remaining items
weak: move internal/weak to weak, and update according to proposal
gopherbot commentedon Dec 18, 2024
Change https://go.dev/cl/637555 mentions this issue:
_content/doc/gc-guide: add section on object death features_content/doc/gc-guide: add section on object death features
lmb commentedon Feb 6, 2025
@mknyszek is it intentional that weak pointers are freely convertible between each other?
See https://go.dev/play/p/UYCA76_Oxcv?v=gotip
mateusz834 commentedon Feb 6, 2025
@lmb Thanks, opened #71583 to mark this as a release-blocker.
thepudds commentedon Feb 6, 2025
I wonder if you could do something like:
type Pointer[T any] struct { - u unsafe.Pointer + u *T }I am about to give that a try.
gopherbot commentedon Feb 6, 2025
Change https://go.dev/cl/647215 mentions this issue:
weak: disallow type conversions of Pointer[T]thepudds commentedon Feb 6, 2025
#67552 (comment) was not a good idea. (I could blame my pre-coffee state, but probably better just to blame myself).
I instead sent https://go.dev/cl/647195, which follows the pattern that sync/atomic.Pointer used to avoid a similar problem.
mknyszek commentedon Feb 6, 2025
@lmb Nope, not intentional. Thanks all for the quick response here.
Just for completeness, the right fix is
_ [0]*T, which is what @thepudds' CL does. At the very least we should probably have the standard library's API check look for this. I suspect it's basically never going to be intentional to allow such conversions...