Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

go/types: guarantee that a finite number of distinct instances are reachable via the API #52728

Closed
findleyr opened this issue May 5, 2022 · 17 comments
Labels
FrozenDueToAge NeedsFix The path to resolution is known, but the work has not been done. release-blocker
Milestone

Comments

@findleyr
Copy link
Contributor

findleyr commented May 5, 2022

In #52715, we see an example of unexpanded types escaping type-checking, that result in an infinite number of reachable types when interrogated via Underlying (though only a finite number of identical types).

It would be nice if we could avoid such infinite expansion, as it is bound to cause problems for our users (see e.g. https://go.dev/cl/404335). One way to achieve this would be to provide wrappers for Underlying and Method that accept a Context. Another option would be to enforce that unexpanded types do not escape the API, though this can result in a lot of unnecessary work when underlying types or instance methods are not needed.

CC @griesemer @adonovan @mdempsky

@findleyr findleyr added the NeedsInvestigation Someone must examine and confirm this is a valid issue and not a duplicate of an existing one. label May 5, 2022
@mvdan mvdan closed this as completed May 5, 2022
@mvdan mvdan reopened this May 5, 2022
@mvdan
Copy link
Member

mvdan commented May 5, 2022

My apologies, I meant to hit the "subscribe" button, not the "close" button!

@findleyr
Copy link
Contributor Author

findleyr commented May 5, 2022

Just to add a bit more context:

Laziness serves at least three purposes:

  1. To avoid type expansion before underlying types are fully set-up.
  2. To avoid infinite recursion in the type checker.
  3. To avoid inefficiency due to generic types that expand enormously. The way I think about this is: we want type checking to scale with the source being type-checked, not the expanded source after instantiating.

Of these:

  • (1) is convenient but can lead to subtle bugs. We've seen this in the past with interface completion, and are seeing it now with type expansion (e.g. https://go.dev/issue/52529). We should probably work toward a more phased approach to type construction.
  • (2) probably shouldn't be necessary anymore, with our checks for monomorphizability and infinite expansion.
  • (3) is a real concern, and an argument for preserving laziness.

@dominikh
Copy link
Member

dominikh commented May 5, 2022

With regard to point 3 it would be interesting to think about how many clients of the type-checker end up having to expand generic types, anyway.

Since #52715 spawned this conversation, I'm also curious how the type-checker, which itself needs to know method sets, can avoid expanding types?

@findleyr
Copy link
Contributor Author

findleyr commented May 5, 2022

With regard to point 3 it would be interesting to think about how many clients of the type-checker end up having to expand generic types, anyway.

Yes, this is a good point. I would guess that in some clients (such as gopls) most types get expanded anyway.

I'm also curious how the type-checker, which itself needs to know method sets, can avoid expanding types?

The type-checker expands types whenever necessary. It just so happens that the method set is not needed in the example in question:
https://go.dev/play/p/hmJjiNWzm9G

@findleyr
Copy link
Contributor Author

findleyr commented May 6, 2022

CC @timothy-king as well.

The more I think about this, the more I think it was just a mistake not to guarantee that reachable types are always finite. Because of instantiation we can't guarantee that instances are canonical, but we can guarantee that they are finite.

I think there's actually a way to achieve this lazily, without pinning the types.Context of the package. Right now during the type-checking pass we avoid type duplication during e.g. calls to Underlying by pinning a *Checker to the Named type. This allows us to use a regular API, but still share state during expansion. However, at the end of type-checking we nil-out the *Checker to avoid unnecessarily pinning memory to the created types.

It occurred to me that we can do something similar during lazy expansion, even if we don't have a *Checker available. We already create a new context during expansion. I would propose that we save that *Context to the Named type and any instances created during its expansion. We can nil it out when we detect that the *Named type is fully expanded, because it won't be necessary anymore.

In other words, we introduce a new invariant. Exactly one of the following hold for a named type N:

  • N is fully expanded, meaning its underlying and all methods have been instantiated.
  • N has a pinned *Checker
  • N has a pinned *Context

As a result of this change, we would guarantee that any instance reachable from N shares a context with N. In the specific example of #52715, it would work as follows:

  • <Tree[int]> would initially have a pinned *Checker.
  • When we clean up after type-checking, we replace the *Checker with a *Context containing exactly one instance (<Tree[int]>), which we can call C. (N.B. we could probably create this context upon the first expansion, but for simplicity of the invariant let's assume that it is created now).
  • When we call NewMethodSet, we access <Tree[int]>.Underlying(), which triggers an expansion and creation of Node[int]. We save C as the context of <Node[int]>. C now has two instances: <Tree[int]> and <Node[int]>.
  • We then access <Node[int]>.Underlying() for the <Node[int]> instance above. Since it has C pinned, it finds (and re-uses) <Tree[int]>.

In other words, each instance is expanded in a shared *Context. During type-checking, this is shared across the package. After type-checking this is shared across a "lineage", starting with the first type that was expanded. It's still possible to have identical named types with different pointer identity, but there will be a finite set of such types. While expanding it's true that this lineage grows, but anyway its maximum size is proportional to the number of types that would be created if we were to expand eagerly. This seems like a reasonable compromize.

@findleyr findleyr added this to the Go1.19 milestone May 6, 2022
@griesemer
Copy link
Contributor

This seems like it could work. Worth a try.

@gopherbot
Copy link

Change https://go.dev/cl/404885 mentions this issue: go/types: ensure that named types never expand infinitely

@findleyr findleyr changed the title go/types: consider providing functions to evaluate Underlying / Methods in a Context, or guaranteeing finite types via the API go/types: guarantee that a finite number of distinct instances are reachable via the API May 13, 2022
@findleyr
Copy link
Contributor Author

This seemed to work out well in the associated stack of CLs, but of course these sorts of changes require great care. It is possible that I will land this stack on Monday (within the parameters of the freeze grace period, since the stack was mailed before the freeze), but even if I don't I think this is a sufficiently severe problem that it warrants being made a release blocker for 1.19. I am not sure if it should be back-ported to 1.18, since the fix is not small and the problem with NewMethodSet and LookupFieldOrMethod has been avoided.

@gopherbot
Copy link

Change https://go.dev/cl/404874 mentions this issue: go/types, types2: remove redundant calls to Named.resolve

gopherbot pushed a commit that referenced this issue May 24, 2022
The resolved status of a Named type should be owned by its API, and
callers should access resolved data via methods. Remove several
instances where Named.resolve is explicitly invoked, only to be followed
by a method that also resolves.

Also make two minor cleanups:
- Remove the tparams parameter to Checker.newNamed, as it was unused.
- Include position information when assertions fail, so that one doesn't
  need to go digging in the panicking stack to find the assertion
  location.

Updates #52728

Change-Id: Icbe8c89e9cfe02d60af7d9ba907eaebe1f00193e
Reviewed-on: https://go-review.googlesource.com/c/go/+/404874
Run-TryBot: Robert Findley <rfindley@google.com>
Reviewed-by: Robert Griesemer <gri@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
@gopherbot
Copy link

Change https://go.dev/cl/404875 mentions this issue: go/types: eliminate Named.once in favor of monotonic state

@gopherbot
Copy link

Change https://go.dev/cl/404884 mentions this issue: go/types, types2: store Named instance information separately

@gopherbot
Copy link

Change https://go.dev/cl/404883 mentions this issue: go/types, types2: eliminate methodList in favor of just using Named.mu

@dmitshur
Copy link
Contributor

dmitshur commented Jun 1, 2022

Checking in on this as it's currently blocking beta 1. Any updates? Thanks.

@griesemer
Copy link
Contributor

This has been implemented. The number of changes is significant, so we're not sure yet whether this should go in or not.

@findleyr
Copy link
Contributor Author

findleyr commented Jun 1, 2022

We're planning to make a decision tomorrow on whether to land this.

@dmitshur dmitshur added NeedsDecision Feedback is required from experts, contributors, and/or the community before a change can be made. and removed NeedsInvestigation Someone must examine and confirm this is a valid issue and not a duplicate of an existing one. labels Jun 2, 2022
@griesemer
Copy link
Contributor

Update: We will try to land this by Monday (pending availability of @findleyr).

@findleyr findleyr self-assigned this Jun 3, 2022
@gopherbot
Copy link

Change https://go.dev/cl/410416 mentions this issue: go/types, types2: only set instance context if packages match

gopherbot pushed a commit that referenced this issue Jun 6, 2022
Introduce a monotonic state variable to track the lifecycle of a named
type, replacing the existing sync.Once. Having a single guard for the
state of underlying and methods will allow for cleaning-up when the type
is fully expanded. In the future, this state may also be used for
detecting access to information such as underlying or methods before the
type is fully set-up, though that will require rethinking our
type-checking of invalid cyclic types.

Also remove support for type-type inference. If we ever support this
feature in the future (inference of missing type arguments for named
type instances), it will likely involve additional machinery that does
not yet exist. Remove the current partial support to simplify our
internal APIs. In particular, this means that Named.resolver is only
used for lazy loading. As a result, we can revert the lazy loader
signature to its previous form.

A lot of exposition is added for how Named types work. Along the way,
the terminology we use to describe them is refined.

Some microbenchmarks are added that were useful in evaluating the
tradeoffs between synchronization mechanisms.

Updates #52728

Change-Id: I4e147360bc6e5d8cd4f37e32e86fece0530a6480
Reviewed-on: https://go-review.googlesource.com/c/go/+/404875
Run-TryBot: Robert Findley <rfindley@google.com>
Reviewed-by: Robert Griesemer <gri@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
gopherbot pushed a commit that referenced this issue Jun 6, 2022
In order to clean up context after fully expanding a type (in subsequent
CLs), we must use a common mutex. Eliminate the lazy methodList type,
which keeps a sync.Once per method, in favor of Named.mu.

Updates #52728

Change-Id: I2d13319276df1330ee53046ef1823b0167a258d8
Reviewed-on: https://go-review.googlesource.com/c/go/+/404883
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Robert Findley <rfindley@google.com>
Reviewed-by: Robert Griesemer <gri@google.com>
gopherbot pushed a commit that referenced this issue Jun 6, 2022
Separate instance information into an instance struct, to reduce memory
footprint for non-instance Named types. This may induce a sense of
deja-vu: we had a similar construct in the past that was removed as
unnecessary. With additional new fields being added that only apply to
instances, having a separate struct makes sense again.

Updates #52728

Change-Id: I0bb5982d71c27e6b574bfb4f7b886a6aeb9c5390
Reviewed-on: https://go-review.googlesource.com/c/go/+/404884
Reviewed-by: Robert Griesemer <gri@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
@dmitshur dmitshur added NeedsFix The path to resolution is known, but the work has not been done. and removed NeedsDecision Feedback is required from experts, contributors, and/or the community before a change can be made. labels Jun 6, 2022
gopherbot pushed a commit that referenced this issue Jun 9, 2022
In CL 404885, we avoid infinite expansion of type instances by sharing a
context between the expanding type and new instances created during
expansion. This ensures that we do not create an infinite number of
identical but distinct instances in the presence of reference cycles.
This pins additional memory to the new instance, but no more
(approximately) than would be pinned by the original expanding instance.

However, we can do better: since type cycles are only possible within a
single package, we only need to share the local context if the two types
are in the same package. This reduces the scope of the shared local
context, and in particular can avoid pinning the package of the
expanding type to the package of the newly created instance.

Updates #52728

Change-Id: Iad2c85f4ecf60125f1da0ba22a7fdec7423e0338
Reviewed-on: https://go-review.googlesource.com/c/go/+/410416
Run-TryBot: Robert Findley <rfindley@google.com>
Reviewed-by: Robert Griesemer <gri@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
@golang golang locked and limited conversation to collaborators Jun 22, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
FrozenDueToAge NeedsFix The path to resolution is known, but the work has not been done. release-blocker
Projects
None yet
Development

No branches or pull requests

6 participants