-
Notifications
You must be signed in to change notification settings - Fork 17.7k
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
sync: add OnceFunc, OnceValue, OnceValues #56102
Comments
Alternative API: type Server struct {
once sync.TryOnce[*sql.DB]
}
func (s *Server) db() (*sql.DB, error) {
return s.once.Do(func() (*sql.DB, error) {
return sql.Open("sqlite", dbPath)
})
} |
I have one of these in https://github.com/carlmjohnson/syncx. I think it’s suitable for the standard library, but it should probably come as part of a std wide addition of generics, not a one off. |
I played around with this in a large codebase. It's a nice idea. I do prefer @icholy's API suggestion. It seems that the ergonomics are about the same and also that it would be a little harder to accidentally misuse. I could see people writing
whereas the equivalent mistake with
vs.
When I was looking at how
For (3) and (4),
or even
|
FWIW, I wrote the closure version and find it much more ergonomic. It wouldn't occur to me to write
since it obviously should be |
Also I don't think it makes sense for this to return an error for the reasons given here: #53696 (comment)
|
@carlmjohnson wrote:
I proposed a A few examples I quickly pulled from the Go core (there are many more, I didn't want to look exhaustively):
Your circumstances may call for a different error handling mechanism (crashing), but others prefer to handle the error every time the resource is requested. Then upstream callers can decide whether it's crash-worthy or not. I think that without the |
@icholy suggested:
Let me just expand your suggested API to do exactly what the other examples are doing, so that it's a fair comparison:
I like that the type has a usable zero value, which means you don't need a constructor for I like that baking the once-ness into the type declaration, instead of just using a plain closure, gives some indication on sight that it's a once-initialized value. Here's a variation on what you suggest, which is arguably less boilerplatey, as we don't need to store the closure state anywhere. The
But to immediately argue against this: an advantage of baking the state ( With all that said, my main objection to these proposals (compared to my original proposal) is that they make it harder to substitute a different initialization function that doesn't use |
@cespare my instinct is that having fewer things is better than more things. If someone wants to call a |
I do think that a db method is nicer than a function as a struct field. That strikes me as unusual-looking about your original example. Also, the original example doesn't look like the common use cases I see for So we have something like this today:
With
and with
Neither has a real advantage in terms of conciseness. Adjusting either of these to get rid of the once-ness would be trivial. One thing I notice about
I think this would be a mistake, though. So both in your original example and here, |
Actually the case in which I use it is globals; I chose the struct form as it's as slightly more complex context. This is how you would use
This IMO is way more concise than any of the alternatives. (Sorry sent this before I had finished writing it.) I think that function variables are fine in the context of private globals. If you wanted to export it and protect it from mutation outside the package, you could write:
I think this kind of argument is not vey helpful in this context. We have new possibilities with generics; we should argue on the pros/cons, not by the established practices that pre-date the tools we have available today. |
Ah -- I'd been taking it as self-evident that promoting function variables over functions should be avoided. My mistake. I think that replacing functions with variables is a poor practice on the merits:
Therefore, I think that APIs should not encourage using vars where functions or methods would have traditionally been used, and thus I think that a struct type is a better API here than a higher-order function. |
I think this is true, for most uses of global function variables, and most certainly exported ones. I'd go further to suggest that global variables as a whole are best avoided where possible. But I don't think function variables should be avoided as a whole. For instance, look at this pitfall you describe:
One good solution to this is to actually use a function variable, rather than mutating global state. For example, if you want to test some code that works with time, passing it a mock So from here I'm assuming that function variables do have some value, and should be used where appropriate. In the context of this proposal, where
I think, on the whole, if you're writing programs with globals like this then you're already vulnerable to most of the pitfalls you describe. If anything, Other concerns you raised:
That may be true in isolation but we'd need to benchmark the different approaches described here to make any efficiency arguments.
These stack traces seem equally helpful to me: before and after |
This proposal has been added to the active column of the proposals project |
This is clearly a very common operation that we should make easier. The only discussion seems to be whether to include the error in the function signature. So maybe there should be two forms: one with the error and one without. Generalizing "with error" to two values may make sense. But if so, what are the names? OnceVal / OnceError and Once1 / Once2 both seem a bit strange. Maybe we should find a name that's not "Once"? sync.Lazy[T], sync.Lazy2[T] - lazy is maybe overused, or maybe it should have the function ahead of time So lots of ideas, none of them great. |
What about a single type with 2 methods: type Memo[T any] struct {
val T
err error
once sync.Once
}
func (m *Memo[T]) Do(f func() T) T {
m.once.Do(func() {
m.val = f()
})
return m.val
}
func (m *Memo[T]) DoErr(f func() (T, error)) (T, error) {
m.once.Do(func() {
m.val, m.err = f()
})
return m.val, m.err
} |
This reminds me of #37739, but implemented with generics instead of as a language change and tailored specifically for use with concurrency. Maybe it makes sense to split the concurrency support out? In other words, have a lazy value interface somewhere non-concurrency specific and a package something
type Lazy[T any] interface {
Eval() T
} package sync
type Lazy[T any] struct {
lazy something.Lazy[T]
} |
To try to move things forward, what do people think of
? It seems like the name should begin with Once so that people find it when they are looking for sync.Once. |
edit: I like the two type approach the best. |
I prefer icholy's suggestion of one type with two methods to two types. |
Having one type with two methods leaves open the possibility to use both with one OnceValue. That seems like a source of bugs. I can imagine legitimate ways to use both methods together, but the (T, error) form subsumes them. |
Just to sum it up, here are the variations: One type, one method: type OnceValue[T any] struct { ... }
func (*OnceValue[T]) Do(func() (T, error)) (T , error) Two types, one method: type OnceValue[T any] struct { ... }
func (*OnceValue[T]) Do(func() T) T
type OnceValueErr[T any] struct { ... }
func (*OnceValueErr[T]) Do(func() (T, error)) (T, error) One type, two methods: type OnceValue[T any] struct { ... }
func (*OnceValue[T]) Do(func() T) T
func (*OnceValue[T]) DoErr(func() (T, error)) (T, error) |
The one type, two methods version seems fine to me. To prevent misuse |
One type, two methods seems out-of-place, because you have to call one of the methods consistently to use it correctly. So really there are two kinds of OnceValue: the kind that can only use Do, and the kind that can only use DoErr. Giving them the same Go type means the compiler can't help you make sure you are using the type correctly. In contrast, what we usually do in Go is use different types for different kinds of values, and then the type system and the compiler do help you, and this possible runtime panic is eliminated at compile time. I think that excludes "one type, two methods". "One type, one method" seems not quite right, because sometimes we will be caching things that can't possibly fail, and it's annoying to have to discard the error that can't happen anyway. Yes, code that can fail is common, but so is code that can't fail. That leaves "two types, one method", which is why I suggested OnceValue and OnceValueErr. |
Change https://go.dev/cl/479095 mentions this issue: |
Change https://go.dev/cl/481062 mentions this issue: |
Currently, when the inliner is determining if a function is inlineable, it descends into the bodies of closures constructed by that function. This has several unfortunate consequences: - If the closure contains a disallowed operation (e.g., a defer), then the outer function can't be inlined. It makes sense that the *closure* can't be inlined in this case, but it doesn't make sense to punish the function that constructs the closure. - The hairiness of the closure counts against the inlining budget of the outer function. Since we currently copy the closure body when inlining the outer function, this makes sense from the perspective of export data size and binary size, but ultimately doesn't make much sense from the perspective of what should be inlineable. - Since the inliner walks into every closure created by an outer function in addition to starting a walk at every closure, this adds an n^2 factor to inlinability analysis. This CL simply drops this behavior. In std, this makes 57 more functions inlinable, and disallows inlining for 10 (due to the basic instability of our bottom-up inlining approach), for an net increase of 47 inlinable functions (+0.6%). This will help significantly with the performance of the functions to be added for #56102, which have a somewhat complicated nesting of closures with a performance-critical fast path. The downside of this seems to be a potential increase in export data and text size, but the practical impact of this seems to be negligible: │ before │ after │ │ bytes │ bytes vs base │ Go/binary 15.12Mi ± 0% 15.14Mi ± 0% +0.16% (n=1) Go/text 5.220Mi ± 0% 5.237Mi ± 0% +0.32% (n=1) Compile/binary 22.92Mi ± 0% 22.94Mi ± 0% +0.07% (n=1) Compile/text 8.428Mi ± 0% 8.435Mi ± 0% +0.08% (n=1) Change-Id: Ie9e38104fed5689a94c368288653fd7cb4b7a35e Reviewed-on: https://go-review.googlesource.com/c/go/+/479095 Reviewed-by: Than McIntosh <[email protected]> TryBot-Result: Gopher Robot <[email protected]> Reviewed-by: Matthew Dempsky <[email protected]> Auto-Submit: Austin Clements <[email protected]> Run-TryBot: Austin Clements <[email protected]>
FYI: https://go.dev/cl/479095 had to be rolled back, due to problems encountered during google-internal testing (see #59404). Working now on resolving that. |
Change https://go.dev/cl/482356 mentions this issue: |
[This is a roll-forward of CL 479095, which was reverted due to a bad interaction between inlining and escape analysis since fixed in CL 482355.] Currently, when the inliner is determining if a function is inlineable, it descends into the bodies of closures constructed by that function. This has several unfortunate consequences: - If the closure contains a disallowed operation (e.g., a defer), then the outer function can't be inlined. It makes sense that the *closure* can't be inlined in this case, but it doesn't make sense to punish the function that constructs the closure. - The hairiness of the closure counts against the inlining budget of the outer function. Since we currently copy the closure body when inlining the outer function, this makes sense from the perspective of export data size and binary size, but ultimately doesn't make much sense from the perspective of what should be inlineable. - Since the inliner walks into every closure created by an outer function in addition to starting a walk at every closure, this adds an n^2 factor to inlinability analysis. This CL simply drops this behavior. In std, this makes 57 more functions inlinable, and disallows inlining for 10 (due to the basic instability of our bottom-up inlining approach), for an net increase of 47 inlinable functions (+0.6%). This will help significantly with the performance of the functions to be added for #56102, which have a somewhat complicated nesting of closures with a performance-critical fast path. The downside of this seems to be a potential increase in export data and text size, but the practical impact of this seems to be negligible: │ before │ after │ │ bytes │ bytes vs base │ Go/binary 15.12Mi ± 0% 15.14Mi ± 0% +0.16% (n=1) Go/text 5.220Mi ± 0% 5.237Mi ± 0% +0.32% (n=1) Compile/binary 22.92Mi ± 0% 22.94Mi ± 0% +0.07% (n=1) Compile/text 8.428Mi ± 0% 8.435Mi ± 0% +0.08% (n=1) Updates #56102. Change-Id: I1f4fc96c71609c8feb59fecdb92b69ba7e3b5b41 Reviewed-on: https://go-review.googlesource.com/c/go/+/482356 Reviewed-by: Cuong Manh Le <[email protected]> Run-TryBot: Than McIntosh <[email protected]> Reviewed-by: Cherry Mui <[email protected]> TryBot-Result: Gopher Robot <[email protected]>
Change https://go.dev/cl/484860 mentions this issue: |
[This is a roll-forward of CL 479095, which was reverted due to a bad interaction between inlining and escape analysis, then later fixed fist with an attempt in CL 482355, then again in 484859 .] Currently, when the inliner is determining if a function is inlineable, it descends into the bodies of closures constructed by that function. This has several unfortunate consequences: - If the closure contains a disallowed operation (e.g., a defer), then the outer function can't be inlined. It makes sense that the *closure* can't be inlined in this case, but it doesn't make sense to punish the function that constructs the closure. - The hairiness of the closure counts against the inlining budget of the outer function. Since we currently copy the closure body when inlining the outer function, this makes sense from the perspective of export data size and binary size, but ultimately doesn't make much sense from the perspective of what should be inlineable. - Since the inliner walks into every closure created by an outer function in addition to starting a walk at every closure, this adds an n^2 factor to inlinability analysis. This CL simply drops this behavior. In std, this makes 57 more functions inlinable, and disallows inlining for 10 (due to the basic instability of our bottom-up inlining approach), for an net increase of 47 inlinable functions (+0.6%). This will help significantly with the performance of the functions to be added for #56102, which have a somewhat complicated nesting of closures with a performance-critical fast path. The downside of this seems to be a potential increase in export data and text size, but the practical impact of this seems to be negligible: │ before │ after │ │ bytes │ bytes vs base │ Go/binary 15.12Mi ± 0% 15.14Mi ± 0% +0.16% (n=1) Go/text 5.220Mi ± 0% 5.237Mi ± 0% +0.32% (n=1) Compile/binary 22.92Mi ± 0% 22.94Mi ± 0% +0.07% (n=1) Compile/text 8.428Mi ± 0% 8.435Mi ± 0% +0.08% (n=1) Updates #56102. Change-Id: I6e938d596992ffb473cf51e7e598f372ce08deb0 Reviewed-on: https://go-review.googlesource.com/c/go/+/484860 Run-TryBot: Than McIntosh <[email protected]> TryBot-Result: Gopher Robot <[email protected]> Reviewed-by: Matthew Dempsky <[email protected]> Reviewed-by: Cuong Manh Le <[email protected]>
CL 484860 broke the x/benchmarks builder: https://build.golang.org/?repo=golang.org%2fx%2fbenchmarks Failure: https://build.golang.org/log/11803116ada45552b52ed62dd86f4255cdba5d87 What's happening is this call produces a closure that appears to be inlined into a compiler-generated init function. The closure it produces later gets installed here. The end result is this function ends up dereferencing a nil pointer. |
I reopened the issue in case we want to roll back this CL, but feel free to close and I can file a new issue instead. |
Nevermind, filed #59680. Closing this issue. |
Change https://go.dev/cl/492017 mentions this issue: |
[This is a roll-forward of CL 479095, which was reverted due to a bad interaction between inlining and escape analysis, then later fixed first with an attempt in CL 482355, then again in CL 484859, and then one more time with CL 492135.] Currently, when the inliner is determining if a function is inlineable, it descends into the bodies of closures constructed by that function. This has several unfortunate consequences: - If the closure contains a disallowed operation (e.g., a defer), then the outer function can't be inlined. It makes sense that the *closure* can't be inlined in this case, but it doesn't make sense to punish the function that constructs the closure. - The hairiness of the closure counts against the inlining budget of the outer function. Since we currently copy the closure body when inlining the outer function, this makes sense from the perspective of export data size and binary size, but ultimately doesn't make much sense from the perspective of what should be inlineable. - Since the inliner walks into every closure created by an outer function in addition to starting a walk at every closure, this adds an n^2 factor to inlinability analysis. This CL simply drops this behavior. In std, this makes 57 more functions inlinable, and disallows inlining for 10 (due to the basic instability of our bottom-up inlining approach), for an net increase of 47 inlinable functions (+0.6%). This will help significantly with the performance of the functions to be added for #56102, which have a somewhat complicated nesting of closures with a performance-critical fast path. The downside of this seems to be a potential increase in export data and text size, but the practical impact of this seems to be negligible: │ before │ after │ │ bytes │ bytes vs base │ Go/binary 15.12Mi ± 0% 15.14Mi ± 0% +0.16% (n=1) Go/text 5.220Mi ± 0% 5.237Mi ± 0% +0.32% (n=1) Compile/binary 22.92Mi ± 0% 22.94Mi ± 0% +0.07% (n=1) Compile/text 8.428Mi ± 0% 8.435Mi ± 0% +0.08% (n=1) Change-Id: I5f75fcceb177f05853996b75184a486528eafe96 Reviewed-on: https://go-review.googlesource.com/c/go/+/492017 Reviewed-by: Matthew Dempsky <[email protected]> TryBot-Result: Gopher Robot <[email protected]> Run-TryBot: Than McIntosh <[email protected]> Reviewed-by: Cherry Mui <[email protected]> Reviewed-by: Cuong Manh Le <[email protected]>
This adds the three functions from golang#56102 to the sync package. These provide a convenient API for the most common uses of sync.Once. The performance of these is comparable to direct use of sync.Once: $ go test -run ^$ -bench OnceFunc\|OnceVal -count 20 | benchstat -row .name -col /v goos: linux goarch: amd64 pkg: sync cpu: 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz │ Once │ Global │ Local │ │ sec/op │ sec/op vs base │ sec/op vs base │ OnceFunc 1.3500n ± 6% 2.7030n ± 1% +100.22% (p=0.000 n=20) 0.3935n ± 0% -70.86% (p=0.000 n=20) OnceValue 1.3155n ± 0% 2.7460n ± 1% +108.74% (p=0.000 n=20) 0.5478n ± 1% -58.35% (p=0.000 n=20) The "Once" column represents the baseline of how code would typically express these patterns using sync.Once. "Global" binds the closure returned by OnceFunc/OnceValue to global, which is how I expect these to be used most of the time. Currently, this defeats some inlining opportunities, which roughly doubles the cost over sync.Once; however, it's still *extremely* fast. Finally, "Local" binds the returned closure to a local variable. This unlocks several levels of inlining and represents pretty much the best possible case for these APIs, but is also unlikely to happen in practice. In principle the compiler could recognize that the global in the "Global" case is initialized in place and never mutated and do the same optimizations it does in the "Local" case, but it currently does not. Fixes golang#56102 Change-Id: If7355eccd7c8de7288d89a4282ff15ab1469e420 Reviewed-on: https://go-review.googlesource.com/c/go/+/451356 TryBot-Result: Gopher Robot <[email protected]> Run-TryBot: Austin Clements <[email protected]> Reviewed-by: Andrew Gerrand <[email protected]> Reviewed-by: Keith Randall <[email protected]> Reviewed-by: Caleb Spare <[email protected]> Auto-Submit: Austin Clements <[email protected]>
Updates #56102. Change-Id: I2ee2dbc43b4333511d9d23752fdc574dfbf5f5bd Reviewed-on: https://go-review.googlesource.com/c/go/+/481062 Reviewed-by: Andrew Gerrand <[email protected]> Auto-Submit: Austin Clements <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Joedian Reid <[email protected]>
This is a proposal for adding a generic
OnceFunc
function to thesync
package in the standard library.In my team's codebase we recently added this function to our private
syncutil
package:(I put this in the (temporary) module
github.com/adg/sync
, if you want to try it out.)This makes a common use of
sync.Once
, lazy initialization with error handling, more ergonomic.For example, this
Server
struct that wants to lazily initialize its database connection may usesync.Once
:While with
OnceFunc
a lot of the fuss goes away:Playground links: before and after.
If there is interest in this, then I suppose it should first live in
x/exp
(as with theslices
andmaps
packages) so that we can play with it.This seems to me like a great example of how generics can be used in the standard library. I wasn't able to find an overall tracking bug for putting generics in the standard library, otherwise I'd have referenced it here.
The text was updated successfully, but these errors were encountered: