-
Notifications
You must be signed in to change notification settings - Fork 18k
sync: add WaitGroup.Go #63796
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
Comments
This is a repeat of #18022, which was re-oriented to become a vet check, but I'm still highly in support of a The tailscale codebase has an extension to As an additional data point, the |
There's also #57534 to just add errgroup in std |
@dsnet Thanks for the link. GitHub search just doesn't work and didn't help me to (re)discover prior proposals. |
This comment has been minimized.
This comment has been minimized.
Maybe |
A WaitGroup with a Go method is not very different from https://pkg.go.dev/cmd/go/internal/par#Queue. I would kind of rather see that moved out of internal and into sync/par. It's a much higher level library than the sync primitives. |
I do think this would be nice to have on wait group. A very relevant piece of writing is this: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/. He argues that At a previous employer we used https://github.com/ConradIrwin/parallel instead of either waitgroup or I'm not sure that |
I was also inspired by the structured concurrency post when I wrote my helper library https://github.com/carlmjohnson/flowmatic. It's a little tricky because the standard library needs to have low level primitives so that people can build higher level abstractions like Map and whatnot, but it would be good to have a standard set of high level abstractions too. |
I've also wanted |
One possible argument against it is that use of it requires the function to close over variable that may be mutating over the lifetime of the asynchronously running goroutine. As it stands today, people can write the more safe: wg.Add(1)
go func(a int, b string) {
defer wg.Done()
...
}(a, b) where However, my personal experience is that I've encountered more bugs due to calling go func(a int, b string) {
wg.Add(1)
defer wg.Done()
...
}() |
Similar and related: #63941 |
Very useful feature, #63941 is the same, to provide developers with less error-prone api, community third-party libraries have also been implemented |
Very useful and straightforward, wondering if Add()/Done() is really needed when this Go() available. |
I'd like to get this back on the active proposal track as I believe the main concern with At Tailscale we have the I have discovered the vast majority of existing var wg sync.WaitGroup
defer wg.Wait()
for i, v := range ... {
wg.Add(1)
go func(i int, v T) {
defer wg.Done()
... // use i and v
}(i, v)
} However, this extra level of parameter passing is no longer necessary in modern Go. var wg syncs.WaitGroup
defer wg.Wait()
for i, v := range ... {
wg.Go(func() {
... // use i and v
})
} because |
Also, I should note that the bug that |
Just be careful when the passed loop-var parameters are modified in the Go method function body. [edit]: this note is actually only valid for traditional 3-clause for-loops. for i := 0, condition, postStatement {
wg.Go(func() {
... // Since Go 1.22, DON'T modify i here!!! No matter how the modification is synchronized.
// Before Go 1.22, the modification might be okay if it is synchronized well.
})
} |
I ran staticcheck's SA2000 analyzer across the module mirror corpus, and it turned up a number of bugs with a very low rate of false positives. Adding this checker (or something similar) to vet might be a worthwhile move if we don't pursue this proposal. Or even if we do. |
a naive search gets us 109k hits on github for correctly using wg.Add/go/wg.Done for the correct use, #39863 was also a dupe, which was rejected on the grounds that Go would only take |
Change https://go.dev/cl/632915 mentions this issue: |
This CL defines a new analyzer, "waitgroup", that reports a common mistake with sync.WaitGroup: calling wg.Add(1) inside the new goroutine, instead of before starting it. This is a port of Dominik Honnef's SA2000 algorithm, which uses tree-based pattern matching, to elementary go/{ast,types} + inspector operations. Fixes golang/go#18022 Updates golang/go#63796 Change-Id: I9d6d3b602ce963912422ee0459bb1f9522fc51f9 Reviewed-on: https://go-review.googlesource.com/c/tools/+/632915 Reviewed-by: Robert Findley <rfindley@google.com> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Change https://go.dev/cl/633704 mentions this issue: |
Based on the discussion above, this proposal seems like a likely accept. The proposal is to add a // A WaitGroup waits for a collection of tasks to finish.
type WaitGroup struct { ... }
// Go calls f on a new goroutine and adds that task to the WaitGroup.
// When f returns, the task is removed from the WaitGroup.
//
// If the WaitGroup is empty, Go must happen before a [WaitGroup.Wait].
// Typically, this simply means Go is called to start tasks before Wait is called.
// If the WaitGroup is not empty, Go may happen at any time.
// This means a goroutine started by Go may itself call Go.
// If a WaitGroup is reused to wait for several independent sets of tasks,
// new Go calls must happen after all previous Wait calls have returned.
//
// In the terminology of [the Go memory model](https://go.dev/ref/mem),
// the return from f "synchronizes before" the return of any Wait call that it unblocks.
func (*WaitGroup) Go(f func()) |
Change https://go.dev/cl/662635 mentions this issue: |
Should |
I think that's out of scope. WaitGroup.Go is a literal translation of the existing behaviour. |
I think on balance it should probably just be a literal translation of the existing behavior, but it's worth pointing out the danger in the documentation. |
This got committed prematurely. Given that this is in likely accept, I don't think we need to back it out right now, but we should if this doesn't go to accept next week. I'll reopen to track the proposal, with the intent to close it again if it goes to accept next week. |
I agree. I think if we were designing a new mechanism (cough errgroup), then we should strongly consider panic propagation, but since this is part of |
Change https://go.dev/cl/662975 mentions this issue: |
Speaking of panic propagation, see #73159.
My bad; sorry for complicating things. |
First of all, apologies for joining the discussion a bit late. I just have a few questions and thoughts about the proposal. Hopefully, a decision can be reached soon. Magic number
|
That's a reasonable alternative way to express the code. Sometimes you see Add(n) followed by n operations; sometimes you see Add(1) before each of n operations; both are fine. But both are simpler using the new Go method.
In hindsight one could imagine a cleaner decomposition of the various parts: for example, sync.CountingSemaphore (functionally identical to WaitGroup) for occasional low-level tasks, plus sync.WaitGroup (functionally identical to errgroup.Group) for everyday structured concurrency. The fact is that WaitGroup is already widely used for the latter role, and we can't change that, but we can at least make it more concise and less error-prone.
The vet check catches the specific mistake where the Add call is done inside the child goroutine. There are certainly other ways to make mistakes.
Sometimes, especially if it leads to a data race and you run your tests under the race detector. But not always.
Exactly. But WaitGroup is a very widely used type, so that 1% still leads to over a thousand mistakes in the corpus. In general, the criteria for vet checks do not include the relative frequency of mistakes, but the absolute number of mistakes, their severity, the precision with which we can detect them, and the cost of running the analyzer. In this case, it is very cheap to identify mistakes with almost perfect reliability, and they are (absolutely) quite numerous in the corpus.
That's true, but we already have a "modernizer" to automate the code transformation to use Go when it is appropriate.
Yes, it's a mere convenience function, and convenience always must be weighed against cognitive burden. I think the trade-off is reasonable in this case. I've certainly made the mistake of calling Add in the wrong place, and it took me a while to notice. More generally, what I've learned from sync.WaitGroup and especially errgroup.Group is that the paradigm of structured concurrency (for example, creating n tasks with lifetimes bounded by the parent) is more often than not what I want. Unstructured concurrency is less often needed. This blog post is relevant: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
No. I expect wg.Go would become the usual form in new code (not least because your IDE will remind you about it). |
Yes, and as existing uses are modernized, if you do see |
@adonovan Thank you very much for the answers to my numerous questions.
Okay, I see. The current API is too open and only in rare cases special calls of
I see where the design journey is headed. Okay, I am not entirely convinced by the arguments. But that's okay. Practically the majority (looking a the thumbs up emojis) wants this proposal. Let's assume that |
No change in consensus, so accepted. 🎉 The proposal is to add a // A WaitGroup waits for a collection of tasks to finish.
type WaitGroup struct { ... }
// Go calls f on a new goroutine and adds that task to the WaitGroup.
// When f returns, the task is removed from the WaitGroup.
//
// If the WaitGroup is empty, Go must happen before a [WaitGroup.Wait].
// Typically, this simply means Go is called to start tasks before Wait is called.
// If the WaitGroup is not empty, Go may happen at any time.
// This means a goroutine started by Go may itself call Go.
// If a WaitGroup is reused to wait for several independent sets of tasks,
// new Go calls must happen after all previous Wait calls have returned.
//
// In the terminology of [the Go memory model](https://go.dev/ref/mem),
// the return from f "synchronizes before" the return of any Wait call that it unblocks.
func (*WaitGroup) Go(f func()) |
By a stroke of good fortune / carelessness, the implementation is already complete. |
TL;DR
Add a
func (*WaitGroup) Go(task func())
method to launch a task in a goroutine tracked with async.WaitGroup
.Combined with the loopvar change (#60078), writing parallel code would be much less error prone.
Rationale
A very common use case for
sync.WaitGroup
is to track the termination of tasks launched in goroutines.Here is the classic example:
I propose to add a
func (*WaitGroup) Go(func())
method that would wrap:wg.Add(1)
: the1
looks like a magic valuego
keyword and the()
after the func body are magic for Go beginnersdefer wg.Done()
: thedefer
keyword and the appearance ofDone
before the call to the worker are magicA simple implementation:
The example above would be much reduced and many footguns avoided (the last remaining footgun is being addressed by #60078):
The full modified example, including an extended implementation of
sync.WaitGroup
, is available on the Go playground.(to handle the case of a task with arguments, I would recommend rewriting the
work
to use the builder pattern: https://go.dev/play/p/g1Um_GhQOyc)The text was updated successfully, but these errors were encountered: