├── .gitignore ├── SECURITY.md ├── .github ├── dependabot.yml └── workflows │ ├── golangci-lint.yml │ ├── gotest.yml │ └── codeql.yml ├── go.mod ├── panic_demo └── panic_demo.go ├── NOTICE ├── .golangci.yaml ├── go.sum ├── errors.go ├── errors_test.go ├── racy_collect_test.go ├── CONTRIBUTING.md ├── cleanupsdemo └── cleanups_demo.go ├── CODE_OF_CONDUCT.md ├── group_test.go ├── LICENSE ├── group.go ├── collect_test.go ├── README.md ├── safety_test.go └── collect.go /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | coverage.html 3 | coverage.out 4 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please report all vulnerabilities to security@wandb.com. 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wandb/parallel 2 | 3 | go 1.21.0 4 | 5 | require github.com/stretchr/testify v1.11.1 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /panic_demo/panic_demo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/wandb/parallel" 7 | ) 8 | 9 | func main() { 10 | g := parallel.Unlimited(context.Background()) 11 | g.Go(bear) 12 | g.Wait() 13 | } 14 | 15 | func bear(_ context.Context) { 16 | g := parallel.Unlimited(context.Background()) 17 | g.Go(foo) 18 | g.Wait() 19 | } 20 | 21 | func foo(_ context.Context) { 22 | bar() 23 | } 24 | 25 | func bar() { 26 | panic("baz") 27 | } 28 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Weights and Biases, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 10m 3 | linters: 4 | enable: 5 | - asasalint 6 | - asciicheck 7 | - errname 8 | - gofmt 9 | - goimports 10 | - gosimple 11 | - govet 12 | - ineffassign 13 | - revive 14 | - staticcheck 15 | - typecheck 16 | - unused 17 | - gci 18 | linters-settings: 19 | goimports: 20 | local-prefixes: "github.com/wandb/parallel" 21 | gci: 22 | sections: 23 | - standard 24 | - default 25 | - prefix(github.com/wandb/parallel) 26 | - blank 27 | - dot 28 | custom-order: true 29 | revive: 30 | rules: 31 | - name: empty-block 32 | disabled: true 33 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: [ "main" ] 5 | pull_request: 6 | branches: [ "main" ] 7 | permissions: 8 | contents: read 9 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 10 | # pull-requests: read 11 | jobs: 12 | golangci: 13 | name: lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | # the default behavior of checkout is to merge master into the PR branch, 18 | # this forces checkout to run the actual HEAD 19 | with: 20 | ref: ${{ github.event.pull_request.head.sha }} 21 | - uses: actions/setup-go@v5 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v4 24 | with: 25 | skip-cache: true 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 6 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /.github/workflows/gotest.yml: -------------------------------------------------------------------------------- 1 | name: "go test" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | paths: 9 | - '**/*.go' 10 | 11 | jobs: 12 | test: 13 | name: "go test" 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | go-version: [ '1.23.x', '1.24.x', '1.25.x' ] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Setup Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ matrix.go-version }} 25 | cache-dependency-path: "go.sum" 26 | - name: "print go version" 27 | run: "go version" 28 | - name: "install dependencies" 29 | run: "go get -t ." 30 | - name: "build" 31 | run: "go build -v ./..." 32 | - name: "tests" 33 | run: "go test -count 100 ./..." 34 | - name: "covered tests" 35 | run: "go test -count 100 -coverprofile coverage.out ./..." 36 | - name: "race check" 37 | run: "go test -race -count 100 ./..." 38 | - name: "check cleanups demo" 39 | run: "go run ./cleanupsdemo" 40 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package parallel 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // MultiError is used internally to wrap multiple errors that occur 8 | // within a function 9 | type MultiError interface { 10 | error 11 | Unwrap() []error 12 | One() error 13 | } 14 | 15 | type multiError struct { 16 | errors []error 17 | } 18 | 19 | func (e *multiError) Error() string { 20 | return strings.Join(e.errorStrings(), "\n") 21 | } 22 | 23 | func (e *multiError) Unwrap() []error { 24 | return e.errors 25 | } 26 | 27 | func (e *multiError) errorStrings() (strings []string) { 28 | for _, err := range e.errors { 29 | if err != nil { 30 | strings = append(strings, err.Error()) 31 | } 32 | } 33 | return 34 | } 35 | 36 | func (e *multiError) One() error { 37 | return e.errors[0] 38 | } 39 | 40 | func NewMultiError(errs ...error) MultiError { 41 | if len(errs) > 0 { 42 | return &multiError{errors: errs} // concrete type not exposed 43 | } 44 | return nil 45 | } 46 | 47 | // Combine multiple errors into one MultiError, discarding all nil errors and 48 | // flattening any existing MultiErrors. If the result has no errors, the result 49 | // is nil. 50 | func CombineErrors(errs ...error) MultiError { 51 | var combined []error 52 | for _, err := range errs { 53 | if err == nil { 54 | continue 55 | } 56 | switch typedErr := err.(type) { 57 | case MultiError: 58 | combined = append(combined, typedErr.Unwrap()...) 59 | default: 60 | combined = append(combined, err) 61 | } 62 | } 63 | return NewMultiError(combined...) 64 | } 65 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package parallel_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/wandb/parallel" 10 | ) 11 | 12 | func TestMultiErrorMessage(t *testing.T) { 13 | assert.Equal(t, 14 | parallel.NewMultiError(), 15 | nil, 16 | ) 17 | assert.Equal(t, 18 | parallel.NewMultiError( 19 | errors.New("foo"), 20 | ).Error(), 21 | "foo", 22 | ) 23 | assert.Equal(t, 24 | parallel.NewMultiError( 25 | errors.New("foo"), 26 | errors.New("bar"), 27 | ).Error(), 28 | "foo\nbar", 29 | ) 30 | assert.Equal(t, 31 | parallel.NewMultiError( 32 | errors.New("foo"), 33 | errors.New("bar"), 34 | nil, 35 | errors.New("baz"), 36 | ).Error(), 37 | "foo\nbar\nbaz", 38 | ) 39 | // Naively created MultiErrors with nils in them should still contain those 40 | // nils; only CombineErrors tries to strip those out. 41 | assert.Equal(t, 42 | parallel.NewMultiError( 43 | errors.New("foo"), 44 | errors.New("bar"), 45 | nil, 46 | errors.New("baz"), 47 | ).Unwrap(), 48 | []error{ 49 | errors.New("foo"), 50 | errors.New("bar"), 51 | nil, 52 | errors.New("baz"), 53 | }, 54 | ) 55 | } 56 | 57 | func TestMultiErrorOne(t *testing.T) { 58 | assert.Equal(t, 59 | parallel.NewMultiError( 60 | errors.New("foo"), 61 | ).One(), 62 | errors.New("foo"), 63 | ) 64 | assert.Equal(t, 65 | parallel.NewMultiError( 66 | errors.New("foo"), 67 | errors.New("bar"), 68 | ).One(), 69 | errors.New("foo"), 70 | ) 71 | } 72 | 73 | func TestCombineErrors(t *testing.T) { 74 | // Nils shouldn't produce an error value 75 | assert.Equal(t, 76 | parallel.CombineErrors(), 77 | nil, 78 | ) 79 | assert.Equal(t, 80 | parallel.CombineErrors(nil, nil), 81 | nil, 82 | ) 83 | // Some errors should produce multierrors containing them 84 | assert.Equal(t, 85 | parallel.CombineErrors( 86 | nil, 87 | errors.New("foo"), 88 | ).Unwrap(), 89 | []error{errors.New("foo")}, 90 | ) 91 | assert.Equal(t, 92 | parallel.CombineErrors( 93 | nil, 94 | errors.New("foo"), 95 | errors.New("bar"), 96 | ).Unwrap(), 97 | []error{ 98 | errors.New("foo"), 99 | errors.New("bar"), 100 | }, 101 | ) 102 | // Should unwrap MultiErrors 103 | assert.Equal(t, 104 | parallel.CombineErrors( 105 | parallel.NewMultiError( 106 | errors.New("foo"), 107 | errors.New("bar"), 108 | ), 109 | nil, 110 | errors.New("baz"), 111 | ).Unwrap(), 112 | []error{ 113 | errors.New("foo"), 114 | errors.New("bar"), 115 | errors.New("baz"), 116 | }, 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /racy_collect_test.go: -------------------------------------------------------------------------------- 1 | //go:build !race 2 | 3 | package parallel 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | // The tests in this file are detected as racy by the race condition checker 15 | // because we are reaching under the hood to look at the group's result (see 16 | // "Hack" comments) so we can see when the group's functions have started 17 | // running. There's no good reason make those fields otherwise accessible, since 18 | // they are completely owned by the group and making this work in a "non-racy" 19 | // way would require extra complexity and overhead. 20 | 21 | func TestGatherErrCanceled(t *testing.T) { 22 | t.Parallel() 23 | ctx, cancel := context.WithCancel(context.Background()) 24 | g := GatherErrs(Unlimited(ctx)) 25 | g.Go(func(context.Context) error { 26 | return errors.New("shadowed") 27 | }) 28 | // Hack: wait for the error to be definitely collected 29 | for len(*g.(multiErrGroup).res) == 0 { 30 | runtime.Gosched() 31 | } 32 | cancel() 33 | err := g.Wait() 34 | assert.Errorf(t, err, "context canceled\nshadowed") 35 | // Because MultiError implements Unwrap() []error, errors.Is() will detect 36 | // context.Canceled 37 | assert.ErrorIs(t, err, context.Canceled) 38 | // Canceled will be the first error 39 | assert.Equal(t, []error{context.Canceled, errors.New("shadowed")}, err.Unwrap()) 40 | } 41 | 42 | func TestCollectWithErrsCanceled(t *testing.T) { 43 | t.Parallel() 44 | ctx, cancel := context.WithCancel(context.Background()) 45 | // Use a dummy executor so we ensure these run in order 46 | g := CollectWithErrs[int](Unlimited(ctx)) 47 | g.Go(func(context.Context) (int, error) { 48 | return 0, errors.New("oh no") 49 | }) 50 | // Hack: wait for the error to be definitely collected 51 | for len(g.(collectingMultiErrGroup[int]).res.errs) == 0 { 52 | runtime.Gosched() 53 | } 54 | cancel() 55 | _, err := g.Wait() 56 | // Because MultiError implements Unwrap() []error, errors.Is() will detect 57 | // context.Canceled 58 | assert.ErrorIs(t, err, context.Canceled) 59 | // Canceled will be the first error 60 | assert.Equal(t, []error{context.Canceled, errors.New("oh no")}, err.Unwrap()) 61 | } 62 | 63 | func TestFeedWithErrsCanceled(t *testing.T) { 64 | t.Parallel() 65 | ctx, cancel := context.WithCancel(context.Background()) 66 | // Use a dummy executor so we ensure these run in order 67 | g := FeedWithErrs[int](Unlimited(ctx), func(context.Context, int) error { return nil }) 68 | g.Go(func(context.Context) (int, error) { 69 | return 0, errors.New("oh no") 70 | }) 71 | // Hack: wait for the error to be definitely collected 72 | for len(*g.(feedingMultiErrGroup[int]).res) == 0 { 73 | runtime.Gosched() 74 | } 75 | cancel() 76 | err := g.Wait() 77 | // Because MultiError implements Unwrap() []error, errors.Is() will detect 78 | // context.Canceled 79 | assert.ErrorIs(t, err, context.Canceled) 80 | // Canceled will be the first error 81 | assert.Equal(t, []error{context.Canceled, errors.New("oh no")}, err.Unwrap()) 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "main" ] 9 | paths: 10 | - '**/*.go' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | # Runner size impacts CodeQL analysis time. To learn more, please see: 16 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 17 | # - https://gh.io/supported-runners-and-hardware-resources 18 | # - https://gh.io/using-larger-runners 19 | # Consider using larger runners for possible analysis time improvements. 20 | runs-on: 'ubuntu-latest' 21 | timeout-minutes: 30 22 | permissions: 23 | actions: read 24 | contents: read 25 | security-events: write 26 | 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | language: [ 'go' ] 31 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 32 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 33 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 34 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v3 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v2 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | 49 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 50 | # queries: security-extended,security-and-quality 51 | 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 60 | 61 | # If the Autobuild fails above, remove it and uncomment the following three lines. 62 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 63 | 64 | # - run: | 65 | # echo "Run, Build Application using script" 66 | # ./location_of_script_within_repo/buildscript.sh 67 | 68 | - name: Perform CodeQL Analysis 69 | uses: github/codeql-action/analyze@v2 70 | with: 71 | category: "/language:${{matrix.language}}" 72 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | ## Getting Started 3 | 1. Fork the repository on GitHub. 4 | 2. Clone the forked repository to your working environment. 5 | 3. Ensure you have a working go interpreter, preferably [of the newest release](https://go.dev/doc/install). 6 | 7 | ## How to Contribute 8 | 1. **Bug Reports**: If you find a bug, please open an issue on GitHub with a clear description of the problem, and include any relevant logs, screenshots, or code samples. 9 | 2. **Feature Requests**: If you have an idea for a new feature or improvement, please open an issue on GitHub with a detailed explanation of the feature, its benefits, and any proposed implementation details. 10 | 3. **Code Contributions**: If you'd like to contribute code directly, follow these steps: 11 | - Make sure you've set up the development environment as described above. 12 | - Create a new branch for your feature or bugfix. Use a descriptive branch name, such as `feature/new-feature` or `bugfix/issue-123`. 13 | - Make your changes, following the existing code style and conventions. 14 | - Add tests for your changes to ensure they work correctly and maintain compatibility with existing code. 15 | - Run tests and ensure they pass. 16 | - Update the documentation as necessary to reflect your changes. 17 | - Commit your changes with a clear and concise commit message. 18 | - Push your changes to your fork on GitHub. 19 | - Create a pull request from your fork to the main repository. In the pull request description, provide an overview of your changes, any relevant issue numbers, and a summary of the testing you've performed. 20 | - Address any feedback or requested changes from the project maintainers. 21 | 22 | ## Code standards 23 | Tests are configured on github actions, but are easy to run locally. Here's a simple checklist we want to reach for pull requests and general contributions: 24 | * Tests should pass! 25 | * The [linter](https://golangci-lint.run/usage/install/) should pass (`golangci-lint run`) 26 | * We aim for 100% branch coverage; coverage isn't completely deterministic, but we should expect 100% coverage after 10-100 runs (so, `go test -count 100 -coverprofile coverage.out ./...` for example). 27 | * We also expect non-racy tests to pass the `race` checker. 28 | * The cleanups demo acts as an additional test run without the `go test` harness so we can have a predictable floor of goroutines we expect to reach. It should also pass. 29 | * Any new functionality that has the potential to leak goroutines when discarded should be added to the cleanups demo. 30 | * All of the above should still be true when different values are set for the `bufferSize` constant in `group.go`, including zero. 31 | * Ideally, we want to preserve the library with zero non-test dependencies outside the standard library. 32 | 33 | We welcome feature requests and contributions for consideration, subject to the [code of conduct](/CODE_OF_CONDUCT.md). 34 | 35 | ## Code of Conduct 36 | Please be respectful and considerate of other contributors. We are committed to fostering a welcoming and inclusive community. Harassment, discrimination, and offensive behavior will not be tolerated. By participating in this project, you agree to adhere to these principles. Refer also to the explicit code of conduct in [`CODE_OF_CONDUCT.md`](/CODE_OF_CONDUCT.md). 37 | 38 | ## Contact 39 | If you have any questions, concerns, or need assistance, please reach out to the project maintainers through GitHub or the official communication channels. 40 | 41 | Thank you for your interest in contributing! 42 | -------------------------------------------------------------------------------- /cleanupsdemo/cleanups_demo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "runtime" 7 | "time" 8 | 9 | "github.com/wandb/parallel" 10 | ) 11 | 12 | func main() { 13 | const cycles = 100 14 | const batchSize = 100 15 | 16 | ctx := context.Background() 17 | 18 | // This demonstrates the library's cleanup functionality, where forgotten 19 | // executors that own goroutines and have been discarded without being awaited 20 | // will clean themselves up without permanently leaking goroutines. This is 21 | // part of the library's panic safety test suite. 22 | // 23 | // To see the counterexample of this test's success, comment out the contents 24 | // of the thunks that are registered with `runtime.SetFinalizer()` in 25 | // group.go and collect.go, which close channels and call cancel functions. 26 | 27 | println("leaking goroutines from group executors...") 28 | 29 | // Dependent contexts are always canceled, too. 30 | // 31 | // This binary generally tests that executors and collectors always clean up 32 | // after themselves, that any extra goroutines and channels and contexts 33 | // they open will be shut down. The easiest thing we can measure is running 34 | // goroutines, so to measure things that aren't goroutines (like unclosed 35 | // contexts) we can just start new goroutines that wait for contexts we 36 | // expect to be canceled. 37 | leakDependent := func(ctx context.Context) { 38 | // All canceleable child contexts are also canceled 39 | ctx, cancel := context.WithCancel(ctx) 40 | _ = cancel 41 | // Leak a goroutine whose halting depends on the given context being 42 | // canceled. 43 | go func() { 44 | <-ctx.Done() 45 | }() 46 | } 47 | 48 | // Leak just a crazy number of goroutines 49 | for i := 0; i < cycles; i++ { 50 | func() { 51 | g := parallel.Collect[int](parallel.Limited(ctx, batchSize)) 52 | for j := 0; j < batchSize; j++ { 53 | g.Go(func(ctx context.Context) (int, error) { 54 | leakDependent(ctx) 55 | return 1, nil 56 | }) 57 | } 58 | // Leak the group without awaiting it 59 | }() 60 | 61 | func() { 62 | defer func() { _ = recover() }() 63 | g := parallel.Feed(parallel.Unlimited(ctx), func(context.Context, int) error { 64 | panic("feed function panics") 65 | }) 66 | g.Go(func(ctx context.Context) (int, error) { 67 | leakDependent(ctx) 68 | return 1, nil 69 | }) 70 | // Leak the group without awaiting it 71 | }() 72 | 73 | func() { 74 | defer func() { _ = recover() }() 75 | g := parallel.Collect[int](parallel.Unlimited(ctx)) 76 | g.Go(func(ctx context.Context) (int, error) { 77 | leakDependent(ctx) 78 | panic("op panics") 79 | }) 80 | // Leak the group without awaiting it 81 | }() 82 | 83 | // Start some executors that complete normally without error 84 | { 85 | g := parallel.Unlimited(ctx) 86 | g.Go(func(ctx context.Context) { 87 | leakDependent(ctx) 88 | }) 89 | g.Wait() 90 | } 91 | { 92 | g := parallel.Limited(ctx, 0) 93 | g.Go(func(ctx context.Context) { 94 | leakDependent(ctx) 95 | }) 96 | g.Wait() 97 | } 98 | { 99 | g := parallel.Collect[int](parallel.Limited(ctx, batchSize)) 100 | g.Go(func(ctx context.Context) (int, error) { 101 | leakDependent(ctx) 102 | return 1, nil 103 | }) 104 | _, err := g.Wait() 105 | if err != nil { 106 | panic(err) 107 | } 108 | } 109 | { 110 | g := parallel.Feed(parallel.Unlimited(ctx), func(context.Context, int) error { 111 | return nil 112 | }) 113 | g.Go(func(ctx context.Context) (int, error) { 114 | leakDependent(ctx) 115 | return 1, nil 116 | }) 117 | err := g.Wait() 118 | if err != nil { 119 | panic(err) 120 | } 121 | } 122 | } 123 | 124 | println("monitoring and running GC...") 125 | 126 | numGoroutines := runtime.NumGoroutine() 127 | lastGoroutineCount := numGoroutines 128 | noProgressFor := 0 129 | for { 130 | println("number of goroutines:", numGoroutines) 131 | if numGoroutines == 1 { 132 | break 133 | } 134 | 135 | if numGoroutines >= lastGoroutineCount { 136 | noProgressFor++ // no progress was made 137 | if noProgressFor > 3 { 138 | println("GC is not making progress! :(") 139 | os.Exit(1) 140 | } 141 | // Don't update lastGoroutineCount if the value went *up* (why would it 142 | // do that? no idea, but might as well guard against it) 143 | } else { 144 | noProgressFor = 0 // progress was made 145 | lastGoroutineCount = numGoroutines 146 | } 147 | 148 | <-time.After(50 * time.Millisecond) 149 | runtime.GC() // keep looking for garbage 150 | numGoroutines = runtime.NumGoroutine() 151 | } 152 | println("tadaa! \U0001f389") 153 | os.Exit(0) 154 | } 155 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | support+coc@wandb.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /group_test.go: -------------------------------------------------------------------------------- 1 | package parallel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "runtime" 7 | "strings" 8 | "sync" 9 | "sync/atomic" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | type contextLeak struct { 17 | lock sync.Mutex 18 | ctxs []context.Context 19 | } 20 | 21 | func (c *contextLeak) leak(ctx context.Context) { 22 | c.lock.Lock() 23 | defer c.lock.Unlock() 24 | c.ctxs = append(c.ctxs, ctx) 25 | } 26 | 27 | func (c *contextLeak) assertAllCanceled(t *testing.T, expected ...error) { 28 | t.Helper() 29 | if len(expected) > 1 { 30 | panic("please just provide 1 expected error for all the contexts") 31 | } 32 | c.lock.Lock() 33 | defer c.lock.Unlock() 34 | for _, ctx := range c.ctxs { 35 | cause := context.Cause(ctx) 36 | if cause == nil { 37 | t.Fatal("context was not canceled") 38 | } 39 | if len(expected) == 1 { 40 | require.ErrorIs(t, cause, expected[0]) 41 | } 42 | } 43 | } 44 | 45 | func assertPanicsWithValue(t *testing.T, expectedValue any, f func()) { 46 | t.Helper() 47 | 48 | defer func() { 49 | p := recover() 50 | if p == nil { 51 | t.Fatal("didn't panic but should have") 52 | } 53 | assert.Equal(t, expectedValue, p.(WorkerPanic).Panic) 54 | panicText := p.(WorkerPanic).Error() 55 | firstPanicLine := strings.SplitN(panicText, "\n", 2)[0] 56 | assert.Equal(t, fmt.Sprintf("%#v", expectedValue), firstPanicLine) 57 | assert.True(t, strings.Contains(panicText, " executor stack trace(s), innermost first:\n")) 58 | }() 59 | 60 | f() 61 | } 62 | 63 | func TestGroup(t *testing.T) { 64 | for _, test := range []struct { 65 | name string 66 | makeExec func(context.Context) Executor 67 | }{ 68 | {"Unlimited", Unlimited}, 69 | {"Limited", func(ctx context.Context) Executor { return Limited(ctx, 10) }}, 70 | {"serial", func(ctx context.Context) Executor { return Limited(ctx, 0) }}, 71 | } { 72 | t.Run(test.name, func(t *testing.T) { 73 | testGroup(t, test.makeExec) 74 | }) 75 | } 76 | } 77 | 78 | func testGroup(t *testing.T, makeExec func(context.Context) Executor) { 79 | t.Parallel() 80 | t.Run("do nothing", func(t *testing.T) { 81 | t.Parallel() 82 | g := makeExec(context.Background()) 83 | g.Wait() 84 | }) 85 | t.Run("do nothing canceled", func(t *testing.T) { 86 | t.Parallel() 87 | ctx, cancel := context.WithCancel(context.Background()) 88 | defer cancel() 89 | g := makeExec(ctx) 90 | cancel() 91 | g.Wait() 92 | }) 93 | t.Run("sum 100", func(t *testing.T) { 94 | t.Parallel() 95 | var counter int64 96 | var leak contextLeak 97 | g := makeExec(context.Background()) 98 | for i := 0; i < 100; i++ { 99 | g.Go(func(ctx context.Context) { 100 | leak.leak(ctx) 101 | atomic.AddInt64(&counter, 1) 102 | }) 103 | } 104 | g.Wait() 105 | assert.Equal(t, int64(100), counter) 106 | leak.assertAllCanceled(t, errGroupDone) 107 | }) 108 | t.Run("sum canceled", func(t *testing.T) { 109 | t.Parallel() 110 | var counter int64 111 | ctx, cancel := context.WithCancel(context.Background()) 112 | defer cancel() 113 | g := makeExec(ctx) 114 | for i := 0; i < 100; i++ { 115 | if i == 50 { 116 | cancel() 117 | } 118 | g.Go(func(context.Context) { 119 | atomic.AddInt64(&counter, 1) 120 | }) 121 | } 122 | g.Wait() 123 | // Work submitted after the context has been canceled does not happen. 124 | // We cannot guarantee that the counter isn't less than 50, because some 125 | // of the original 50 work units might not have started yet. We also 126 | // cannot guarantee that the counter isn't *more* than 50 because in the 127 | // limited executor, some of the worker functions may select a work item 128 | // instead of seeing the done signal on their final loop. 129 | var maxSum int64 = 50 130 | if lg, ok := g.(*limitedGroup); ok { 131 | maxSum += int64(lg.max) // limitedGroup may run up to 1 more per worker 132 | } 133 | assert.LessOrEqual(t, counter, maxSum) 134 | }) 135 | t.Run("wait multiple times", func(t *testing.T) { 136 | t.Parallel() 137 | g := makeExec(context.Background()) 138 | assert.NotPanics(t, g.Wait) 139 | assert.NotPanics(t, g.Wait) 140 | }) 141 | } 142 | 143 | func testLimitedGroupMaxConcurrency(t *testing.T, name string, g Executor, limit int, shouldSucceed bool) { 144 | // Testing that some process can work with *at least* N parallelism is easy: 145 | // we run N jobs that cannot make progress, and unblock them when they have 146 | // all arrived at that blocker. 147 | // 148 | // Coming up with a way to validate that something runs with *NO MORE THAN* 149 | // N parallelism is HARD. 150 | // 151 | // We can't just time.Sleep and wait for everything to catch up, because 152 | // that simply isn't how concurrency works, especially in test environments: 153 | // there's no amount of time we can choose that will actually guarantee 154 | // another thread has caught up. So instead, we first assert that exactly N 155 | // jobs are running in the executor in parallel, and then we insert lots and 156 | // lots of poison pills into the work queue and *footrace* with any other 157 | // worker threads that might have started that could be trying to run jobs, 158 | // while also reaching under the hood and discarding those work units 159 | // ourselves. Golang channels are sufficiently fair such that if there are 160 | // multiple waiters all of them will get at least *some* of the items in the 161 | // channel eventually, which gives us a very high probability that any such 162 | // worker will choke on a poison pill if it exists. 163 | t.Run(name, func(t *testing.T) { 164 | t.Parallel() 165 | var blocker, barrier sync.WaitGroup 166 | // Blocker stops the workers from progressing 167 | blocker.Add(1) 168 | // Barrier lets us know when all the workers have arrived. If this 169 | // test hangs, probably it's because not enough workers started. 170 | barrier.Add(limit) 171 | 172 | jobInserter := Unlimited(context.Background()) 173 | jobInserter.Go(func(context.Context) { 174 | // We fully loop over the ops channel in the test to empty it. The 175 | // channel is only closed when the group is awaited or forgotten but 176 | // not when it panics, and just guaranteeing we await it takes the 177 | // least code, so we do that. 178 | defer g.Wait() 179 | 180 | for i := 0; i < limit; i++ { 181 | g.Go(func(context.Context) { 182 | barrier.Done() 183 | blocker.Wait() 184 | }) 185 | } 186 | 187 | // Now we insert a whole buttload of jobs that should never be picked 188 | // up and run by the executor. We will go through and consume these 189 | // from the channel ourselves in the main thread, but if there were 190 | // any workers taking from that channel chances are they would get 191 | // and run at least one of these jobs, failing the test. 192 | for i := 0; i < 10000; i++ { 193 | g.Go(func(context.Context) { 194 | panic("poison pill") 195 | }) 196 | } 197 | 198 | g.Wait() 199 | }) 200 | barrier.Wait() 201 | // All the workers we *expect* to see have shown up now. Throw away all 202 | // the poison pills in the ops queue 203 | for poisonPill := range g.(*limitedGroup).ops { 204 | runtime.Gosched() // Trigger preemption as much as we can 205 | assert.NotNil(t, poisonPill) 206 | runtime.Gosched() // Trigger preemption as much as we can 207 | } 208 | blocker.Done() // unblock the workers 209 | if shouldSucceed { 210 | assert.NotPanics(t, jobInserter.Wait) 211 | } else { 212 | assertPanicsWithValue(t, "poison pill", jobInserter.Wait) 213 | } 214 | }) 215 | } 216 | 217 | func TestLimitedGroupMaxConcurrency(t *testing.T) { 218 | t.Parallel() 219 | testLimitedGroupMaxConcurrency(t, "100", Limited(context.Background(), 100), 100, true) 220 | testLimitedGroupMaxConcurrency(t, "50", Limited(context.Background(), 50), 50, true) 221 | testLimitedGroupMaxConcurrency(t, "5", Limited(context.Background(), 5), 5, true) 222 | testLimitedGroupMaxConcurrency(t, "1", Limited(context.Background(), 1), 1, true) 223 | // Validate the test 224 | testLimitedGroupMaxConcurrency(t, "fail", Limited(context.Background(), 6), 5, false) 225 | } 226 | 227 | func TestConcurrentGroupWaitReallyWaits(t *testing.T) { 228 | testConcurrentGroupWaitReallyWaits(t, "Unlimited", Unlimited(context.Background())) 229 | testConcurrentGroupWaitReallyWaits(t, "Limited", Limited(context.Background(), 2)) 230 | } 231 | 232 | func testConcurrentGroupWaitReallyWaits(t *testing.T, name string, g Executor) { 233 | const parallelWaiters = 100 234 | t.Run(name, func(t *testing.T) { 235 | var blocker sync.WaitGroup 236 | blocker.Add(1) 237 | g.Go(func(context.Context) { 238 | blocker.Wait() 239 | }) 240 | 241 | failureCanary := make(chan struct{}, parallelWaiters) 242 | 243 | // Wait for the group many times concurrently 244 | testingGroup := Unlimited(context.Background()) 245 | for i := 0; i < parallelWaiters; i++ { 246 | testingGroup.Go(func(context.Context) { 247 | g.Wait() 248 | failureCanary <- struct{}{} 249 | }) 250 | } 251 | 252 | // Give the testing group lots and lots of chances to make progress 253 | for i := 0; i < 100000; i++ { 254 | select { 255 | case <-failureCanary: 256 | t.Fatal("a Wait() call made progress when it shouldn't!") 257 | default: 258 | } 259 | runtime.Gosched() 260 | } 261 | // Clean up 262 | blocker.Done() 263 | for i := 0; i < parallelWaiters; i++ { 264 | <-failureCanary 265 | } 266 | testingGroup.Wait() 267 | }) 268 | } 269 | 270 | func TestCanGoexit(t *testing.T) { 271 | g := Unlimited(context.Background()) 272 | g.Go(func(context.Context) { 273 | // Ideally we would test t.Fatal() here to show that parallel now plays 274 | // nicely with the testing lib, but there doesn't seem to be any good 275 | // way to xfail a golang test. As it happens t.Fatal() just sets a fail 276 | // flag and then calls Goexit() anyway; if we treat nil recover() values 277 | // as Goexit() (guaranteed since 1.21 with the advent of PanicNilError) 278 | // we can handle this very simply, without needing a "double defer 279 | // sandwich". 280 | // 281 | // Either way, we expect Goexit() to work normally in tests now and not 282 | // fail or re-panic. 283 | runtime.Goexit() 284 | }) 285 | g.Wait() 286 | } 287 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | package parallel 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "runtime" 8 | "runtime/debug" 9 | "strings" 10 | "sync" 11 | "sync/atomic" 12 | ) 13 | 14 | // This constant can be anything, and only allows for optimization. None of our 15 | // channels are guaranteed not to block. This can be reduced to 0 and the tests 16 | // will still pass with (usually) full coverage. 17 | const bufferSize = 8 18 | 19 | const misuseMessage = "parallel executor misuse: don't reuse executors" 20 | 21 | var ( 22 | errPanicked = errors.New("panicked") 23 | // errGroupDone is a sentinel error value used to cancel an execution 24 | // context when it has completed without error. 25 | errGroupDone = errors.New("executor done") 26 | errGroupAbandoned = errors.New("executor abandoned") 27 | 28 | // Contexts are canceled with this error when executors are awaited. 29 | GroupDoneError = errGroupDone //nolint:errname 30 | ) 31 | 32 | // WorkerPanic represents a panic value propagated from a task within a parallel 33 | // executor, and is the main type of panic that you might expect to receive. 34 | type WorkerPanic struct { //nolint:errname 35 | // Panic contains the originally panic()ed value. 36 | Panic any 37 | // Stacktraces contains the stacktraces of the panics. The stack trace of 38 | // the line that threw the original Panic value appears first, and any other 39 | // stack traces from other parallel groups that received this panic and re- 40 | // threw it appear in order afterwards. 41 | Stacktraces []string 42 | } 43 | 44 | // We pretty-print our wrapped panic type including the captured stack traces. 45 | func (wp WorkerPanic) Error() string { 46 | var sb strings.Builder 47 | for _, s := range wp.Stacktraces { 48 | sb.WriteString(s) 49 | sb.WriteByte('\n') 50 | } 51 | return fmt.Sprintf( 52 | "%#v\n\nPrior %d executor stack trace(s), innermost first:\n%s", 53 | wp.Panic, 54 | len(wp.Stacktraces), 55 | sb.String(), 56 | ) 57 | } 58 | 59 | // NOTE: If you want to really get crazy with it, it IS permissible and safe to 60 | // call Go(...) from multiple threads without additional synchronization, on 61 | // every kind of executor. HOWEVER: the caller always assumes full 62 | // responsibility for making sure that Wait() definitely was not called on that 63 | // executor yet, and any goroutine calling an executor's functions may receive a 64 | // panic if any of the group's goroutines panicked. (Go() may hoist the panic 65 | // opportunistically, and Wait() will reliably always hoist a panic if one 66 | // occurred.) 67 | 68 | // Executor that runs the given functions and can wait for all of them to 69 | // finish. 70 | type Executor interface { 71 | // Go submits a task to the Executor, to be run at some point in the future. 72 | // 73 | // Panics if Wait() has already been called. 74 | // May panic if any submitted task has already panicked. 75 | Go(func(context.Context)) 76 | // Wait waits until all submitted tasks have completed. 77 | // 78 | // After waiting, panics if any submitted task panicked. 79 | Wait() 80 | 81 | // internal 82 | getContext() (context.Context, context.CancelCauseFunc) 83 | // Waits without canceling the context with errGroupDone. The caller of this 84 | // function promises that they will be responsible for canceling the context 85 | waitWithoutCanceling() 86 | } 87 | 88 | // Creates a basic executor which runs all the functions given in one goroutine 89 | // each. Composes starting the goroutines, safe usage of WaitGroups, and as a 90 | // bonus any panic that happens in one of the provided functions will be ferried 91 | // over and re-panicked in the thread that owns the executor (that is, the code 92 | // calling Wait() and Go()), so the whole process doesn't die. 93 | func Unlimited(ctx context.Context) Executor { 94 | return makeGroup(context.WithCancelCause(ctx)) 95 | } 96 | 97 | // Creates a parallelism-limited executor which starts up to a given number of 98 | // goroutines, which each run the provided functions until done. 99 | // 100 | // These executors are even best-effort safe against misuse: if the owner panics 101 | // or otherwise forgets to call Wait(), the goroutines started by this executor 102 | // should still be cleaned up. 103 | func Limited(ctx context.Context, maxGoroutines int) Executor { 104 | if maxGoroutines < 1 { 105 | // When maxGoroutines is non-positive, we return the trivial executor 106 | // type directly. 107 | gctx, cancel := context.WithCancelCause(ctx) 108 | g := &runner{ctx: gctx, cancel: cancel} 109 | // This executor still needs to make certain that its context always 110 | // gets canceled! 111 | runtime.SetFinalizer(g, func(doomed *runner) { 112 | doomed.cancel(errGroupAbandoned) 113 | }) 114 | return g 115 | } 116 | making := &limitedGroup{ 117 | g: makeGroup(context.WithCancelCause(ctx)), 118 | ops: make(chan func(context.Context), bufferSize), 119 | max: uint64(maxGoroutines), 120 | } 121 | runtime.SetFinalizer(making, func(doomed *limitedGroup) { 122 | close(doomed.ops) 123 | }) 124 | return making 125 | } 126 | 127 | // Base executor with an interface that runs everything serially. This can be 128 | // returned directly from Limited in a special case, and otherwise it is just 129 | // composed as inner struct fields for the base concurrent group struct. 130 | // 131 | // The lifecycle of the context is important: When the executor is set up we 132 | // create a cancelable context, and we need to guarantee that it is eventually 133 | // canceled or it can stay resident indefinitely in the known children of a 134 | // parent context, effectively leaking memory. To do this, we guarantee that the 135 | // context is canceled in one of a couple ways: 136 | // 1. if the executor is abandoned without awaiting, a runtime finalizer that 137 | // is registered immediately after we create the executor will cancel it 138 | // 2. if the executor is awaited and completes normally, after everything else 139 | // has completed the context will be canceled with the errGroupDone sentinel 140 | // 3. if there is a panic or another kind of error that causes the executor to 141 | // terminate early (such as with ErrGroup), the context is canceled with 142 | // error normally in this way. 143 | type runner struct { 144 | ctx context.Context // Execution context 145 | cancel context.CancelCauseFunc // Cancel for the ctx; must always be called 146 | awaited atomic.Bool // Set when Wait() is called 147 | } 148 | 149 | func (n *runner) Go(op func(context.Context)) { 150 | if n.awaited.Load() { 151 | panic(misuseMessage) 152 | } 153 | select { 154 | case <-n.ctx.Done(): 155 | return 156 | default: 157 | } 158 | op(n.ctx) 159 | } 160 | 161 | func (n *runner) Wait() { 162 | n.waitWithoutCanceling() 163 | n.cancel(errGroupDone) 164 | } 165 | 166 | func (n *runner) waitWithoutCanceling() { 167 | if !n.awaited.Swap(true) { 168 | runtime.SetFinalizer(n, nil) // unset the finalizer the first time 169 | } 170 | } 171 | 172 | func (n *runner) getContext() (context.Context, context.CancelCauseFunc) { 173 | return n.ctx, n.cancel 174 | } 175 | 176 | func makeGroup(ctx context.Context, cancel context.CancelCauseFunc) *group { 177 | g := &group{runner: runner{ctx: ctx, cancel: cancel}} 178 | runtime.SetFinalizer(g, func(doomed *group) { 179 | doomed.cancel(errGroupAbandoned) 180 | }) 181 | return g 182 | } 183 | 184 | // Base concurrent executor 185 | type group struct { 186 | runner 187 | wg sync.WaitGroup 188 | panicked atomic.Pointer[WorkerPanic] // Stores panic values 189 | } 190 | 191 | func (g *group) Go(op func(context.Context)) { 192 | if g.awaited.Load() { 193 | panic(misuseMessage) 194 | } 195 | g.checkPanic() 196 | select { 197 | case <-g.ctx.Done(): 198 | return 199 | default: 200 | } 201 | g.wg.Add(1) 202 | go func() { 203 | returnedNormally := false 204 | defer func() { 205 | if !returnedNormally { 206 | // When the function call has exited without returning, hoist 207 | // the recover()ed panic value and a stack trace so it can be 208 | // re-panicked. 209 | p := recover() 210 | if p == nil { 211 | // This is a runtime.Goexit(), such as from a process 212 | // termination or a test failure; let that propagate instead 213 | g.cancel(context.Canceled) 214 | } else { 215 | // If we are propagating a panic that is already a 216 | // WorkerPanic (for example, if we have panics propagating 217 | // through multiple parallel groups), just add our 218 | // stacktrace onto the end of the slice; otherwise make a 219 | // new WorkerPanic value. 220 | var wp WorkerPanic 221 | switch tp := p.(type) { 222 | case WorkerPanic: 223 | wp = WorkerPanic{ 224 | Panic: tp.Panic, 225 | Stacktraces: append(tp.Stacktraces, string(debug.Stack())), 226 | } 227 | default: 228 | wp = WorkerPanic{ 229 | Panic: p, 230 | Stacktraces: []string{string(debug.Stack())}, 231 | } 232 | } 233 | 234 | g.panicked.CompareAndSwap(nil, &wp) 235 | g.cancel(errPanicked) 236 | } 237 | } 238 | g.wg.Done() 239 | }() 240 | op(g.ctx) 241 | returnedNormally = true // op returned, don't store a panic 242 | }() 243 | } 244 | 245 | func (g *group) Wait() { 246 | defer g.cancel(errGroupDone) 247 | g.waitWithoutCanceling() 248 | } 249 | 250 | func (g *group) waitWithoutCanceling() { 251 | if !g.awaited.Swap(true) { 252 | runtime.SetFinalizer(g, nil) // unset the finalizer the first time 253 | } 254 | g.wg.Wait() 255 | g.checkPanic() 256 | } 257 | 258 | func (g *group) checkPanic() { 259 | // Safely propagate any panic a worker encountered into the owning goroutine 260 | if p := g.panicked.Load(); p != nil { 261 | panic(*p) 262 | } 263 | } 264 | 265 | // Executor that starts a limited number of worker goroutines. 266 | // 267 | // Note that here, as well in our collectors, many things are stored 268 | // out-of-line through pointers, interfaces, etc. This is required so that we 269 | // can avoid retaining *any* reference to the managing struct in values captured 270 | // by the goroutines we run, ensuring that the struct will get garbage collected 271 | // if it is forgotten rather than being kept alive forever by its sleeping 272 | // goroutines, which lets us guarantee that it will be shut down with the 273 | // registered runtime finalizer. 274 | type limitedGroup struct { 275 | g *group // The actual executor. 276 | ops chan func(context.Context) // These are the functions the workers will run 277 | max uint64 // Maximum number of worker goroutines we start 278 | started uint64 // Counter of how many goroutines we've started or almost-started so far 279 | awaited atomic.Bool // Set when Wait() is called, so we don't close ops twice 280 | } 281 | 282 | func (lg *limitedGroup) Go(op func(context.Context)) { 283 | if lg.awaited.Load() { 284 | panic(misuseMessage) 285 | } 286 | // The first "max" ops started kick off a new worker goroutine 287 | if atomic.LoadUint64(&lg.started) < lg.max && atomic.AddUint64(&lg.started, 1) <= lg.max { 288 | ops, dying := lg.ops, lg.g.ctx.Done() // Don't capture a pointer to the group 289 | lg.g.Go(func(ctx context.Context) { 290 | // Worker bee function. We take and execute ops from the channel 291 | // until we are done or the group is dead. 292 | for { 293 | // First do a non-blocking check on dying. If work remains but 294 | // the context has ended, we only have a ~50% chance to stop 295 | // each iteration unless we specifically check this one first, 296 | // because go's "select" chooses among available channels 297 | // semi-randomly. 298 | select { 299 | case <-dying: 300 | return 301 | default: 302 | } 303 | // Then wait for either submitted work, end, or death 304 | select { 305 | case <-dying: 306 | return 307 | case thisOp, stillOpen := <-ops: 308 | if !stillOpen { 309 | return 310 | } 311 | thisOp(ctx) 312 | } 313 | } 314 | }) 315 | } 316 | select { 317 | case lg.ops <- op: 318 | return 319 | case <-lg.g.ctx.Done(): 320 | lg.g.checkPanic() 321 | } 322 | } 323 | 324 | func (lg *limitedGroup) Wait() { 325 | if !lg.awaited.Swap(true) { 326 | close(lg.ops) 327 | runtime.SetFinalizer(lg, nil) // Don't try to close this chan again :) 328 | } 329 | lg.g.Wait() 330 | } 331 | 332 | func (lg *limitedGroup) waitWithoutCanceling() { 333 | if !lg.awaited.Swap(true) { 334 | close(lg.ops) 335 | runtime.SetFinalizer(lg, nil) // Don't try to close this chan again :) 336 | } 337 | lg.g.waitWithoutCanceling() 338 | } 339 | 340 | func (lg *limitedGroup) getContext() (context.Context, context.CancelCauseFunc) { 341 | return lg.g.getContext() 342 | } 343 | -------------------------------------------------------------------------------- /collect_test.go: -------------------------------------------------------------------------------- 1 | package parallel 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync/atomic" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestErrGroup(t *testing.T) { 14 | for _, test := range []struct { 15 | name string 16 | makeExec func(context.Context) Executor 17 | }{ 18 | {"Unlimited", Unlimited}, 19 | {"Limited", func(ctx context.Context) Executor { return Limited(ctx, 10) }}, 20 | {"serial", func(ctx context.Context) Executor { return Limited(ctx, 0) }}, 21 | } { 22 | test := test 23 | t.Run(test.name, func(t *testing.T) { 24 | testErrGroup(t, test.makeExec) 25 | }) 26 | } 27 | } 28 | 29 | func testErrGroup(t *testing.T, makeExec func(context.Context) Executor) { 30 | t.Parallel() 31 | t.Run("nothing", func(t *testing.T) { 32 | t.Parallel() 33 | g := ErrGroup(makeExec(context.Background())) 34 | assert.NoError(t, g.Wait()) 35 | }) 36 | t.Run("some", func(t *testing.T) { 37 | t.Parallel() 38 | g := ErrGroup(makeExec(context.Background())) 39 | flag := 0 40 | g.Go(func(context.Context) error { 41 | flag = 1 42 | return nil 43 | }) 44 | assert.NoError(t, g.Wait()) 45 | assert.Equal(t, 1, flag) 46 | 47 | }) 48 | t.Run("failing", func(t *testing.T) { 49 | t.Parallel() 50 | g := ErrGroup(makeExec(context.Background())) 51 | g.Go(func(context.Context) error { 52 | return errors.New("failed") 53 | }) 54 | assert.Errorf(t, g.Wait(), "failed") 55 | }) 56 | t.Run("canceled", func(t *testing.T) { 57 | t.Parallel() 58 | ctx, cancel := context.WithCancel(context.Background()) 59 | g := ErrGroup(makeExec(ctx)) 60 | cancel() 61 | g.Go(func(context.Context) error { 62 | return errors.New("failed") 63 | }) 64 | // context cancelation overrides other errors as long as the group isn't 65 | // stopped by an error first 66 | assert.ErrorIs(t, g.Wait(), context.Canceled) 67 | }) 68 | } 69 | 70 | func TestCollectNothing(t *testing.T) { 71 | t.Parallel() 72 | g := Collect[int](Unlimited(context.Background())) 73 | res, err := g.Wait() 74 | assert.NoError(t, err) 75 | assert.Nil(t, res) 76 | } 77 | 78 | func TestCollectSome(t *testing.T) { 79 | t.Parallel() 80 | g := Collect[int](Unlimited(context.Background())) 81 | g.Go(func(context.Context) (int, error) { return 1, nil }) 82 | g.Go(func(context.Context) (int, error) { return 1, nil }) 83 | g.Go(func(context.Context) (int, error) { return 1, nil }) 84 | res, err := g.Wait() 85 | assert.NoError(t, err) 86 | assert.Equal(t, []int{1, 1, 1}, res) 87 | } 88 | 89 | func TestCollectFailed(t *testing.T) { 90 | t.Parallel() 91 | g := Collect[int](Unlimited(context.Background())) 92 | g.Go(func(context.Context) (int, error) { return 1, nil }) 93 | g.Go(func(context.Context) (int, error) { return 1, nil }) 94 | g.Go(func(context.Context) (int, error) { return 1, errors.New("nvm") }) 95 | res, err := g.Wait() 96 | assert.Errorf(t, err, "nvm") 97 | assert.Nil(t, res) 98 | } 99 | 100 | func TestCollectCanceled(t *testing.T) { 101 | t.Parallel() 102 | ctx, cancel := context.WithCancel(context.Background()) 103 | g := Collect[int](Unlimited(ctx)) 104 | cancel() 105 | g.Go(func(context.Context) (int, error) { return 1, errors.New("nvm") }) 106 | g.Go(func(context.Context) (int, error) { return 1, nil }) 107 | g.Go(func(context.Context) (int, error) { return 1, nil }) 108 | res, err := g.Wait() 109 | // context cancellation overrides other errors as long as the group isn't 110 | // stopped by an error first 111 | assert.ErrorIs(t, err, context.Canceled) 112 | assert.Nil(t, res) 113 | } 114 | 115 | func TestFeedNothing(t *testing.T) { 116 | t.Parallel() 117 | g := Feed[int](Unlimited(context.Background()), func(context.Context, int) error { 118 | t.Fatal("never runs") 119 | return nil 120 | }) 121 | assert.NoError(t, g.Wait()) 122 | } 123 | 124 | func TestFeedSome(t *testing.T) { 125 | t.Parallel() 126 | res := make(map[int]bool) 127 | g := Feed(Unlimited(context.Background()), func(ctx context.Context, val int) error { 128 | res[val] = true 129 | return nil 130 | }) 131 | g.Go(func(context.Context) (int, error) { return 1, nil }) 132 | g.Go(func(context.Context) (int, error) { return 2, nil }) 133 | g.Go(func(context.Context) (int, error) { return 3, nil }) 134 | assert.NoError(t, g.Wait()) 135 | assert.Equal(t, map[int]bool{1: true, 2: true, 3: true}, res) 136 | } 137 | 138 | func TestFeedErroring(t *testing.T) { 139 | t.Parallel() 140 | var res []int 141 | g := Feed(Unlimited(context.Background()), func(ctx context.Context, val int) error { 142 | res = append(res, val) 143 | return nil 144 | }) 145 | g.Go(func(context.Context) (int, error) { return 1, nil }) 146 | g.Go(func(context.Context) (int, error) { return 2, nil }) 147 | g.Go(func(context.Context) (int, error) { return 3, nil }) 148 | g.Go(func(context.Context) (int, error) { return 4, errors.New("oops") }) 149 | assert.Errorf(t, g.Wait(), "oops") 150 | assert.Subset(t, []int{1, 2, 3}, res) 151 | } 152 | 153 | func TestFeedLastReceiverErrs(t *testing.T) { 154 | t.Parallel() 155 | // Even when the very very last item through the pipe group causes an error, 156 | // the group's context shouldn't be canceled yet and it should still be able 157 | // to set the error. 158 | g := Feed(Limited(context.Background(), 0), func(ctx context.Context, val int) error { 159 | if val == 10 { 160 | return errors.New("boom") 161 | } else { 162 | return nil 163 | } 164 | }) 165 | for i := 1; i <= 10; i++ { 166 | g.Go(func(ctx context.Context) (int, error) { 167 | return i, nil 168 | }) 169 | } 170 | require.Error(t, g.Wait()) 171 | } 172 | 173 | func TestFeedErroringInReceiver(t *testing.T) { 174 | t.Parallel() 175 | g := Feed(Unlimited(context.Background()), func(ctx context.Context, val int) error { 176 | if val%2 == 1 { 177 | return errors.New("odd numbers are unacceptable") 178 | } 179 | return nil 180 | }) 181 | for i := 0; i < 100; i++ { 182 | i := i 183 | g.Go(func(context.Context) (int, error) { return i, nil }) 184 | } 185 | assert.Errorf(t, g.Wait(), "odd numbers are unacceptable") 186 | } 187 | 188 | func TestFeedCanceled(t *testing.T) { 189 | t.Parallel() 190 | ctx, cancel := context.WithCancel(context.Background()) 191 | defer cancel() 192 | g := Feed(Unlimited(ctx), func(ctx context.Context, val int) error { 193 | return errors.New("error from receiver") 194 | }) 195 | cancel() 196 | g.Go(func(context.Context) (int, error) { return 1, errors.New("error from work") }) 197 | g.Go(func(context.Context) (int, error) { return 2, nil }) 198 | // context cancelation overrides other errors as long as the group isn't 199 | // stopped by an error first. 200 | assert.ErrorIs(t, g.Wait(), context.Canceled) 201 | } 202 | 203 | func TestGatherErrNothing(t *testing.T) { 204 | t.Parallel() 205 | g := GatherErrs(Unlimited(context.Background())) 206 | assert.NoError(t, g.Wait()) 207 | } 208 | 209 | func TestGatherNoErrs(t *testing.T) { 210 | t.Parallel() 211 | var res int64 212 | g := GatherErrs(Unlimited(context.Background())) 213 | g.Go(func(context.Context) error { 214 | atomic.AddInt64(&res, 1) 215 | return nil 216 | }) 217 | g.Go(func(context.Context) error { 218 | atomic.AddInt64(&res, 1) 219 | return nil 220 | }) 221 | g.Go(func(context.Context) error { 222 | atomic.AddInt64(&res, 1) 223 | return nil 224 | }) 225 | assert.NoError(t, g.Wait()) 226 | assert.Equal(t, int64(3), res) 227 | } 228 | 229 | func TestGatherErrSome(t *testing.T) { 230 | t.Parallel() 231 | // Use a dummy executor so we ensure these run in order 232 | g := GatherErrs(Limited(context.Background(), 0)) 233 | flag := 0 234 | g.Go(func(context.Context) error { 235 | return errors.New("oh no") 236 | }) 237 | g.Go(func(context.Context) error { 238 | flag = 1 239 | return nil 240 | }) 241 | g.Go(func(context.Context) error { 242 | return NewMultiError(errors.New("another one"), errors.New("even more")) 243 | }) 244 | err := g.Wait() 245 | assert.Errorf(t, err, "oh no\nanother one\neven more") 246 | assert.Equal(t, []error{ 247 | errors.New("oh no"), 248 | errors.New("another one"), 249 | errors.New("even more"), 250 | }, err.Unwrap()) 251 | assert.Equal(t, 1, flag) 252 | } 253 | 254 | func TestCollectWithErrsNothing(t *testing.T) { 255 | t.Parallel() 256 | g := CollectWithErrs[int](Unlimited(context.Background())) 257 | res, err := g.Wait() 258 | assert.NoError(t, err) 259 | assert.Nil(t, res) 260 | } 261 | 262 | func TestCollectWithErrsSome(t *testing.T) { 263 | t.Parallel() 264 | // Use a dummy executor so we ensure these run in order 265 | g := CollectWithErrs[int](Limited(context.Background(), 0)) 266 | g.Go(func(context.Context) (int, error) { 267 | return 0, errors.New("oh no") 268 | }) 269 | g.Go(func(context.Context) (int, error) { 270 | return 1, nil 271 | }) 272 | g.Go(func(context.Context) (int, error) { 273 | return 2, nil 274 | }) 275 | g.Go(func(context.Context) (int, error) { 276 | return 3, NewMultiError(errors.New("more"), errors.New("yet more")) 277 | }) 278 | res, err := g.Wait() 279 | assert.Equal(t, []int{1, 2}, res) 280 | assert.Errorf(t, err, "oh no\nmore\nyet more") 281 | // Multierrors get flattened :) 282 | assert.Equal(t, []error{ 283 | errors.New("oh no"), 284 | errors.New("more"), 285 | errors.New("yet more"), 286 | }, err.Unwrap()) 287 | } 288 | 289 | func TestFeedWithErrsNothing(t *testing.T) { 290 | t.Parallel() 291 | g := FeedWithErrs(Unlimited(context.Background()), func(context.Context, int) error { 292 | return nil 293 | }) 294 | assert.NoError(t, g.Wait()) 295 | } 296 | 297 | func TestFeedWithErrsSome(t *testing.T) { 298 | t.Parallel() 299 | res := make(map[int]bool) 300 | // Use a dummy executor so we ensure these run in order 301 | g := FeedWithErrs(Limited(context.Background(), 0), func(ctx context.Context, val int) error { 302 | res[val] = true 303 | return nil 304 | }) 305 | g.Go(func(context.Context) (int, error) { 306 | return 0, errors.New("oh no") 307 | }) 308 | g.Go(func(context.Context) (int, error) { 309 | return 1, nil 310 | }) 311 | g.Go(func(context.Context) (int, error) { 312 | return 2, nil 313 | }) 314 | g.Go(func(context.Context) (int, error) { 315 | return 3, NewMultiError(errors.New("more"), errors.New("yet more")) 316 | }) 317 | err := g.Wait() 318 | assert.Equal(t, map[int]bool{1: true, 2: true}, res) 319 | assert.Errorf(t, err, "oh no\nmore\nyet more") 320 | // Multierrors get flattened :) 321 | assert.Equal(t, []error{ 322 | errors.New("oh no"), 323 | errors.New("more"), 324 | errors.New("yet more"), 325 | }, err.Unwrap()) 326 | } 327 | 328 | func TestFeedWithErrsInReceiver(t *testing.T) { 329 | t.Parallel() 330 | var res []int 331 | // Use a dummy executor so we ensure these run in order 332 | g := FeedWithErrs(Limited(context.Background(), 0), func(ctx context.Context, val int) error { 333 | if val%5 == 0 { 334 | return errors.New("buzz") 335 | } 336 | res = append(res, val) 337 | return nil 338 | }) 339 | for i := 1; i <= 10; i++ { 340 | i := i 341 | g.Go(func(context.Context) (int, error) { 342 | if i%3 == 0 { 343 | return 0, errors.New("fizz") 344 | } 345 | return i, nil 346 | }) 347 | } 348 | err := g.Wait() 349 | assert.Equal(t, []int{1, 2, 4, 7, 8}, res) 350 | assert.Error(t, err) 351 | assert.Equal(t, []error{ 352 | errors.New("fizz"), 353 | errors.New("buzz"), 354 | errors.New("fizz"), 355 | errors.New("fizz"), 356 | errors.New("buzz"), 357 | }, err.Unwrap()) 358 | } 359 | 360 | func TestMultipleUsageOfExecutor(t *testing.T) { 361 | t.Parallel() 362 | for _, testCase := range []struct { 363 | name string 364 | executor Executor 365 | }{ 366 | {"group", Unlimited(context.Background())}, 367 | {"limited", Limited(context.Background(), 10)}, 368 | {"serial", Limited(context.Background(), 0)}, 369 | } { 370 | testCase := testCase 371 | t.Run(testCase.name, func(t *testing.T) { 372 | collector := Collect[int](testCase.executor) 373 | feedResult := make(map[string]bool) 374 | feeder := Feed(testCase.executor, func(ctx context.Context, val string) error { 375 | feedResult[val] = true 376 | return nil 377 | }) 378 | errorer := GatherErrs(testCase.executor) 379 | sender := Unlimited(context.Background()) 380 | sender.Go(func(context.Context) { 381 | collector.Go(func(context.Context) (int, error) { 382 | return 1, nil 383 | }) 384 | collector.Go(func(context.Context) (int, error) { 385 | return 1, nil 386 | }) 387 | collector.Go(func(context.Context) (int, error) { 388 | return 1, nil 389 | }) 390 | }) 391 | sender.Go(func(context.Context) { 392 | feeder.Go(func(context.Context) (string, error) { 393 | return "abc", nil 394 | }) 395 | feeder.Go(func(context.Context) (string, error) { 396 | return "foo", nil 397 | }) 398 | feeder.Go(func(context.Context) (string, error) { 399 | return "bar", nil 400 | }) 401 | }) 402 | sender.Go(func(context.Context) { 403 | errorer.Go(func(context.Context) error { 404 | return nil 405 | }) 406 | errorer.Go(func(context.Context) error { 407 | return errors.New("kaboom") 408 | }) 409 | }) 410 | sender.Wait() 411 | collected, err := collector.Wait() 412 | assert.NoError(t, err) 413 | assert.Equal(t, []int{1, 1, 1}, collected) 414 | assert.NoError(t, feeder.Wait()) 415 | assert.Equal(t, map[string]bool{"abc": true, "foo": true, "bar": true}, feedResult) 416 | assert.Errorf(t, errorer.Wait(), "kaboom") 417 | }) 418 | } 419 | } 420 | 421 | func TestWaitPipeGroupMultipleTimes(t *testing.T) { 422 | t.Parallel() 423 | g := Feed(Unlimited(context.Background()), func(context.Context, int) error { return nil }) 424 | assert.NotPanics(t, func() { assert.NoError(t, g.Wait()) }) 425 | assert.NotPanics(t, func() { assert.NoError(t, g.Wait()) }) 426 | } 427 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Parallel executors library 2 | *The goroutine group library we wish we had* 3 | 4 | ## See also 5 | [Contributing](/CONTRIBUTING.md) 6 | 7 | [Security disclosures](/SECURITY.md) 8 | 9 | [Code of Conduct](/CODE_OF_CONDUCT.md) 10 | 11 | [License (Apache 2.0)](/LICENSE) & [copyright notice](/NOTICE) 12 | 13 | [API quick-start 👇](#api) 14 | 15 | ## Motivation 16 | Existing libraries and APIs for running code in parallel in golang are limited and laden with sharp edges. 17 | * `WaitGroup` has a verbose and mistake-prone API. We must always remember to `.Add()` the correct number of times and must always `defer wg.Done()` in the associated goroutines. 18 | * When performing work in parallel with a `WaitGroup`, there are no amenities for stopping early in the event of an error. 19 | * It's also complicated to put limits on the number of goroutines that may be running in parallel. 20 | 21 | There does exist a slightly friendlier library for such things: `x/sync/errgroup`. The `errgroup` library offers amenities for some of the aforementioned challenges: 22 | * Goroutines are started in an `errgroup` with a simple `Go(func() error)` API, and completion is awaited via `Wait() error` on the same group object. 23 | * Stopping early is available via `WithContext(ctx)` 24 | * Limiting concurrency is available via `SetLimit(n)`, which must be called before any work is submitted 25 | 26 | It's pretty good, but it's not perfect. Managing the child context object dedicated to the group is particularly error prone: `WithContext()` returns both a `Group` and the `ctx` that it uses. Now we have two `Context` objects in scope, and we have to be super disciplined about always using the correct one, not just falling back on using `ctx` by habit. 27 | 28 | Another challenge we have to contend with when using golang's parallelism is panic safety. In an ideologically pure world, panics only happen when something has gone severely sideways and the whole process should shut down. In reality, any sufficiently large codebase will have panics all over the place, especially since there is such a heavy reliance in the language upon pointers and fat-pointer types (interfaces) that we must always meticulously check. What's worse, panics that escape a non-main goroutine's main function in golang are *fundamentally unrecoverable.* We must strongly prefer to hoist panics out of isolated goroutines and into the controlling goroutine, where they are more likely to be handled by a framework or other dedicated recovery mechanism that can avoid crashing a large, long-running server process. 29 | 30 | Even more complicated is the task of collecting or combining results from the work that happens inside the group. Synchronizing with the completion of the output requires a second wait group, because we must make sure that all the writers have completed before we tell the reader(s) to finish up. We can't really get around this: appending to a slice of results isn't thread-safe, and putting all the results into a channel to be read after writing has finished is also hazardous: channels have a fixed maximum size, and when they are full they block forever. A channel's buffer always consumes space, and it cannot reallocate to grow later like a slice will. Golang offers no real pre-built solution for a synchronized collection. It is inherently a little bit tricky, and yet another sort of thing we shouldn't have to trust ourselves to write exactly right every single time. 31 | 32 | What's more, panics are relevant here too: if a panic occurs in (or is hoisted to) the controlling goroutine, open channels that are being used to pipe data for readers & writers can be left dangling open causing the goroutines waiting for them to leak, never continuing or terminating, taking up memory forever. 33 | 34 | `errgroup` has an [effort](https://github.com/golang/go/issues/53757) to handle panics that has not ([at time of writing](https://go-review.googlesource.com/c/sync/+/416555)) borne any fruit. The authors are not aware of any effort to automate cleanups of discarded groups, simplify collection of results, or remove which-context-do-we-use style footguns. 35 | 36 | We hope to solve all these problems here, in a single library. It should be: 37 | 1. an easy and obvious choice for every reasonable use case (several slightly different APIs provided for different use cases), 38 | 2. as hard to get wrong as possible (panic and leak safety, always providing contexts inside work functions; there are many subtle ways for such APIs to be error prone), and 39 | 3. easy to remember the available features for 40 | * even while referencing `errgroup` while writing this library, it was all too easy to forget that it even has features like `SetLimit(int)` 41 | * it's preferable that in places where more options exist the chosen behavior is explicit and configured up front in the first function call :) 42 | 43 | ## API 44 | For simply running work to completion, we have some very simple executors: 45 | * introducing `parallel.Unlimited(ctx)` 46 | ```go 47 | group := parallel.Unlimited(ctx) 48 | group.Go(func(ctx context.Context) { 49 | println("yes") 50 | }) 51 | group.Wait() 52 | // at this point, it's guaranteed that all functions 53 | // that were sent to the group have completed! 54 | // this group can run an unbounded number of goroutines 55 | // simultaneously. 56 | ``` 57 | * introducing `parallel.Limited(ctx, n)` 58 | ```go 59 | // Works just like parallel.Unlimited(), but the functions sent to it 60 | // run with at most N parallelism 61 | group := parallel.Limited(ctx, 10) 62 | for i := 0; i < 100; i++ { 63 | // i := i -- We no longer support versions of golang that do not have 64 | // the loopvar semantic enabled by default; worrying about repeatedly 65 | // capturing the same loop variable should be a thing of the past. 66 | group.Go(func(ctx context.Context) { 67 | <-time.After(time.Second) 68 | println("ok", i) 69 | }) 70 | }) 71 | group.Wait() 72 | // we didn't start 100 goroutines! 73 | ``` 74 | 75 | For the more complex chore of running work that produces results which are aggregated at the end, we have several more wrapper APIs that compose with the above executors: 76 | * `parallel.ErrGroup(executor)`, for `func(context.Context) error` 77 | * This one is the most like a bare executor, but offers additional benefits and, like all the other tools in this library, always provides `ctx` to the functions so the user does not have to fight to remember which context variable to use. 78 | ```go 79 | group := parallel.ErrGroup(parallel.Unlimited(ctx)) 80 | group.Go(func(ctx context.Context) error { 81 | println("this might not run if an error happens first!") 82 | return nil 83 | }) 84 | group.Go(func(ctx context.Context) error { 85 | return errors.New("uh oh") 86 | }) 87 | group.Go(func(ctx context.Context) error { 88 | return errors.New("bad foo") 89 | }) 90 | err := group.Wait() // it's one of the errors returned! 91 | ``` 92 | * `parallel.Collect[T](executor)`, for `func(context.Context) (T, error)` 93 | * Collects returned values into a slice automatically, which is returned at the end 94 | ```go 95 | group := parallel.Collect[int](parallel.Unlimited(ctx)) 96 | group.Go(func(ctx context.Context) (int, error) { 97 | return 1, nil 98 | }) 99 | // we get all the results back in a slice from Wait(), or one error 100 | // if there were any. 101 | result, err := group.Wait() // []int{1}, nil 102 | ``` 103 | * `parallel.Feed(executor, receiver)`, for `func(context.Context) (T, error)` 104 | * Provides returned values to a function that is provided up front, which receives all of the values as they are returned from functions submitted to `Go()` but without any worries about thread safety 105 | ```go 106 | result := make(map[int]bool) 107 | group := parallel.Feed(parallel.Unlimited(ctx), 108 | // This can also be a function that doesn't return an error! 109 | func(ctx context.Context, n int) error { 110 | // values from the functions sent to the group end up here! 111 | result[n] = true // this runs safely in 1 thread! 112 | return nil // an error here also stops execution 113 | }) 114 | group.Go(func(ctx context.Context) (int, error) { 115 | return 1, nil 116 | }) 117 | err := group.Wait() // nil 118 | // at this point, it's guaranteed that the receiver function above 119 | // has seen every return value of the functions sent to the group: 120 | result // map[int]bool{1: true} 121 | ``` 122 | * `parallel.GatherErrs(executor)`, for `func(context.Context) error` 123 | * Kind of like `ErrGroup`, but instead of halting the executor, all non-`nil` errors returned from the submitted functions are combined into a `MultiError` at the end. 124 | ```go 125 | group := parallel.GatherErrs(parallel.Unlimited(ctx)) 126 | group.Go(func(ctx context.Context) error { 127 | println("okay (this definitely runs)") 128 | return nil 129 | }) 130 | group.Go(func(ctx context.Context) error { 131 | return errors.New("uh oh") 132 | }) 133 | group.Go(func(ctx context.Context) error { 134 | return NewMultiError( 135 | errors.New("bad foo"), 136 | errors.New("bad bar"), 137 | ) 138 | }) 139 | err := group.Wait() // it's a MultiError! 140 | // Because it's our own MultiError type, we get tools like: 141 | err.Error() // "uh oh\nbad foo\nbad bar" - normal error behavior 142 | err.One() // "uh oh" - one of the original errors! 143 | err.Unwrap() // []error{ "uh oh", "bad foo", "bad bar" } 144 | // As shown, this even flattens other MultiErrors we return, if we need 145 | // to send multiple (see CombineErrors()) 146 | ``` 147 | * `parallel.CollectWithErrs[T](executor)`, for `func(context.Context) (T, error)` 148 | * `MultiError`-returning version of `Collect[T]` which, like `GatherErrs`, does not halt when an error occurs 149 | ```go 150 | group := parallel.CollectWithErrs[int](parallel.Unlimited(ctx)) 151 | group.Go(func(ctx context.Context) (int, error) { 152 | return 1, nil 153 | }) 154 | group.Go(func(ctx context.Context) (int, error) { 155 | return 2, errors.New("oops") 156 | }) 157 | group.Go(func(ctx context.Context) (int, error) { 158 | return 3, errors.New("bad foo") 159 | }) 160 | // both concepts at once! note that we get back a slice of 161 | // values from successful invocations only, and the combined errors 162 | // from the failed invocations. 163 | result, err := group.Wait() // []int{1}, !MultiError with "oops", "bad foo"! 164 | ``` 165 | * `parallel.FeedWithErrs(executor, receiver)`, for `func(context.Context) (T, error)` 166 | * `MultiError`-returning version of `Feed` 167 | ```go 168 | result := make(map[int]bool) 169 | group := parallel.FeedWithErrs(parallel.Unlimited(ctx), 170 | func(ctx context.Context, n int) error { 171 | // values from the functions sent to the group end up here, 172 | // but only if there was no error! 173 | result[n] = true // this runs safely in 1 thread! 174 | return nil // errors returned here will also be collected 175 | }) 176 | group.Go(func(ctx context.Context) (int, error) { 177 | return 1, nil 178 | }) 179 | group.Go(func(ctx context.Context) (int, error) { 180 | return 2, errors.New("oh no") 181 | }) 182 | group.Go(func(ctx context.Context) (int, error) { 183 | return 3, errors.New("bad bar") 184 | }) 185 | err := group.Wait() // !MultiError with "oh no", "bad bar"! 186 | result // map[int]bool{1: true} 187 | ``` 188 | 189 | * All of the above tools will clean up after themselves even if `Wait()` is never called! 190 | Unfortunately if we write functions that get stuck and block forever, that's still a hard problem and this library doesn't even attempt to solve. Fortunately, we find that in practice that isn't a hugely prevalent issue and is pretty easy to avoid outside of the kind of difficult code written here. 😔 191 | * All of the above tools will propagate panics from their workers and collector functions to their owner (that is, the code that is using the executor/wrapper directly, calling `Go()` and `Wait()`). 192 | * Cancelation of the context that is first provided to the executor leads to the executor stopping early if possible 193 | * Errors also stop the execution and context of the groups that stop on error (the inner context provided to the functions, but not the outer context that was provided to create the executor) 194 | * If stopping on errors is not desirable, use the wrappers which are designed to collect a `MultiErr`: `GatherErrs`, `FeedWithErrs` and `CollectWithErrs` 195 | 196 | ## Additional notes 197 | 198 | ### Conceptual organization 199 | 200 | There are basically two different concepts here: 201 | 1. an underlying `Executor` that runs functions in goroutines, and 202 | 2. various kinds of wrappers for that that take functions with/without return values, with/without automatic halting on error, and either collecting results into a slice automatically or sending them to a function we provide up front. 203 | 204 | ### Context lifetime 205 | 206 | **Possibly important:** ➡️ The *inner* context provided to the functions run by the executors *always* gets canceled, even if the executor completes successfully. If a worker function needs to capture or leak a context that must outlive the executor, it should *explicitly ignore* the `Context` parameter that it receives from the executor and capture a longer-lived one instead. 207 | 208 | This avoids unbounded memory usage buildup inside the outer context. Cancelable child contexts of other cancelable child contexts can never be garbage collected until they are canceled, which is why linters often admonish us to `defer cancel()` after creating one. If the parent context is long lived this can lead to a very, very large amount of uncollectable memory built up. 209 | 210 | If you are seeing context cancelation errors with the `ctx.Cause()` error string `"executor done"`, that means a worker function should probably be capturing a longer-lived context. 211 | 212 | ### Executor reuse 213 | 214 | It's possible to use a single executor for none, one, or several wrappers at the same time; this works fine, but we must be careful: 215 | 216 | ⚠️ Never re-use an executor! Once we have called `Wait()` on an executor *or any wrapper around it*, we cannot send any more work to it. 217 | 218 | This means recreating the group every time we need to await it. Don't worry, this is not expensive to do. 219 | -------------------------------------------------------------------------------- /safety_test.go: -------------------------------------------------------------------------------- 1 | //go:build !race 2 | 3 | package parallel 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "runtime" 9 | "sync" 10 | "sync/atomic" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | // The tests in this file can be detected as racy by the race condition checker 17 | // because we are reaching under the hood to look at the group's channel, so we 18 | // can see when the group's functions have started running. There's no good 19 | // reason make those channels otherwise accessible, since they are completely 20 | // owned by the group and making this work in a "non-racy" way would require 21 | // extra complexity and overhead. 22 | 23 | func TestLimitedGroupCleanup(t *testing.T) { 24 | t.Parallel() 25 | var counter int64 26 | var leak contextLeak 27 | 28 | opsQueue := func() chan func(context.Context) { 29 | g := Limited(context.Background(), 10) 30 | for i := 0; i < 100; i++ { 31 | g.Go(func(ctx context.Context) { 32 | atomic.AddInt64(&counter, 1) 33 | leak.leak(ctx) 34 | }) 35 | } 36 | return g.(*limitedGroup).ops 37 | // leak the un-awaited group 38 | }() 39 | assert.NotNil(t, opsQueue) 40 | runtime.GC() // Trigger cleanups for leaked resources 41 | runtime.GC() // Trigger cleanups for leaked resources 42 | runtime.GC() // Trigger cleanups for leaked resources 43 | 44 | // In the event that we need to drain the ops queue below, we need to have 45 | // a context to leak that satisfies our test predicate. 46 | fakeCtx, cancel := context.WithCancelCause(context.Background()) 47 | // We can cancel the context immediately since we don't really use it 48 | cancel(errGroupAbandoned) 49 | 50 | for op := range opsQueue { 51 | op(fakeCtx) // have mercy and run those ops anyway, just so we get a full count 52 | } 53 | // The channel should get closed! 54 | assert.Equal(t, int64(100), atomic.LoadInt64(&counter)) 55 | leak.assertAllCanceled(t, errGroupAbandoned) 56 | } 57 | 58 | func TestTrivialGroupCleanup(t *testing.T) { 59 | t.Parallel() 60 | var counter int64 61 | var leak contextLeak 62 | 63 | func() { 64 | g := Limited(context.Background(), 0) 65 | for i := 0; i < 100; i++ { 66 | g.Go(func(ctx context.Context) { 67 | counter++ 68 | leak.leak(ctx) 69 | }) 70 | } 71 | }() 72 | runtime.GC() // Trigger cleanups for leaked resources 73 | runtime.GC() // Trigger cleanups for leaked resources 74 | runtime.GC() // Trigger cleanups for leaked resources 75 | assert.Equal(t, int64(100), counter) 76 | // The context should be canceled! 77 | leak.assertAllCanceled(t, errGroupAbandoned) 78 | } 79 | 80 | func TestCollectorCleanup(t *testing.T) { 81 | t.Parallel() 82 | var leak contextLeak 83 | valuePipe := func() chan int { 84 | g := Collect[int](Unlimited(context.Background())) 85 | g.Go(func(ctx context.Context) (int, error) { 86 | leak.leak(ctx) 87 | return 1, nil 88 | }) 89 | return g.(collectingGroup[int]).pipe 90 | // leak the un-awaited group 91 | }() 92 | assert.NotNil(t, valuePipe) 93 | runtime.GC() // Trigger cleanup of the collector 94 | runtime.GC() // Trigger cleanup of the executor it owned 95 | runtime.GC() // One more for good measure 96 | for range valuePipe { 97 | // The channel should get closed! 98 | } 99 | leak.assertAllCanceled(t) // the cancelation error is inconsistent here, 100 | // depending on whether the pipe group or the executor was reaped first 101 | } 102 | 103 | func TestFeederCleanup(t *testing.T) { 104 | t.Parallel() 105 | var leak contextLeak 106 | valuePipe := func() chan int { 107 | g := Feed[int](Unlimited(context.Background()), func(context.Context, int) error { return nil }) 108 | g.Go(func(ctx context.Context) (int, error) { 109 | leak.leak(ctx) 110 | return 1, nil 111 | }) 112 | return g.(feedingGroup[int]).pipe 113 | // leak the un-awaited group 114 | }() 115 | assert.NotNil(t, valuePipe) 116 | runtime.GC() // Trigger cleanup of the feeder 117 | runtime.GC() // Trigger cleanup of the executor it owned 118 | runtime.GC() // One more for good measure 119 | for range valuePipe { 120 | // The channel should get closed! 121 | } 122 | leak.assertAllCanceled(t) // the cancelation error is inconsistent here, 123 | // depending on whether the pipe group or the executor was reaped first 124 | } 125 | 126 | func TestGatherErrCleanup(t *testing.T) { 127 | t.Parallel() 128 | var leak contextLeak 129 | valuePipe := func() chan error { 130 | g := GatherErrs(Unlimited(context.Background())) 131 | g.Go(func(ctx context.Context) error { 132 | leak.leak(ctx) 133 | return nil 134 | }) 135 | return g.(multiErrGroup).pipe 136 | // leak the un-awaited group 137 | }() 138 | assert.NotNil(t, valuePipe) 139 | runtime.GC() // Trigger cleanup of the gatherer 140 | runtime.GC() // Trigger cleanup of the executor it owned 141 | runtime.GC() // One more for good measure 142 | for range valuePipe { 143 | // The channel should get closed! 144 | } 145 | leak.assertAllCanceled(t) // the cancelation error is inconsistent here, 146 | // depending on whether the pipe group or the executor was reaped first 147 | } 148 | 149 | func TestCollectWithErrsCleanup(t *testing.T) { 150 | t.Parallel() 151 | var leak contextLeak 152 | valuePipe := func() chan withErr[int] { 153 | g := CollectWithErrs[int](Unlimited(context.Background())) 154 | g.Go(func(ctx context.Context) (int, error) { 155 | leak.leak(ctx) 156 | return 1, nil 157 | }) 158 | return g.(collectingMultiErrGroup[int]).pipe 159 | // leak the un-awaited group 160 | }() 161 | assert.NotNil(t, valuePipe) 162 | runtime.GC() // Trigger cleanup of the collector 163 | runtime.GC() // Trigger cleanup of the executor it owned 164 | runtime.GC() // One more for good measure 165 | for range valuePipe { 166 | // The channel should get closed! 167 | } 168 | leak.assertAllCanceled(t) // the cancelation error is inconsistent here, 169 | // depending on whether the pipe group or the executor was reaped first 170 | } 171 | 172 | func TestFeedWithErrsCleanup(t *testing.T) { 173 | t.Parallel() 174 | var leak contextLeak 175 | valuePipe := func() chan withErr[int] { 176 | g := FeedWithErrs(Unlimited(context.Background()), 177 | func(context.Context, int) error { return nil }) 178 | g.Go(func(ctx context.Context) (int, error) { 179 | leak.leak(ctx) 180 | return 1, nil 181 | }) 182 | return g.(feedingMultiErrGroup[int]).pipe 183 | // leak the un-awaited group 184 | }() 185 | assert.NotNil(t, valuePipe) 186 | runtime.GC() // Trigger cleanup of the collector 187 | runtime.GC() // Trigger cleanup of the executor it owned 188 | runtime.GC() // One more for good measure 189 | for range valuePipe { 190 | // The channel should get closed! 191 | } 192 | leak.assertAllCanceled(t) // the cancelation error is inconsistent here, 193 | // depending on whether the pipe group or the executor was reaped first 194 | } 195 | 196 | func TestPanicGroup(t *testing.T) { 197 | t.Parallel() 198 | var leak contextLeak 199 | g := Unlimited(context.Background()) 200 | var blocker sync.WaitGroup 201 | blocker.Add(1) 202 | g.Go(func(ctx context.Context) { 203 | leak.leak(ctx) 204 | blocker.Wait() 205 | panic("wow") 206 | }) 207 | g.Go(func(context.Context) { 208 | blocker.Done() 209 | }) 210 | // Wait for the group to "die" when the panic hits 211 | ctx, _ := g.getContext() 212 | <-ctx.Done() 213 | assertPanicsWithValue(t, "wow", func() { 214 | g.Wait() 215 | }) 216 | leak.assertAllCanceled(t, errPanicked) 217 | } 218 | 219 | func TestPanicGroupSecondPath(t *testing.T) { 220 | t.Parallel() 221 | var leak contextLeak 222 | g := Unlimited(context.Background()) 223 | var blocker sync.WaitGroup 224 | blocker.Add(1) 225 | g.Go(func(ctx context.Context) { 226 | leak.leak(ctx) 227 | blocker.Wait() 228 | panic("wow") 229 | }) 230 | g.Go(func(context.Context) { 231 | blocker.Done() 232 | }) 233 | // Wait for the group to "die" when the panic hits 234 | ctx, _ := g.getContext() 235 | <-ctx.Done() 236 | assertPanicsWithValue(t, "wow", func() { 237 | g.Go(func(context.Context) { 238 | t.Fatal("this op should never run") 239 | }) 240 | }) 241 | leak.assertAllCanceled(t, errPanicked) 242 | } 243 | 244 | func TestPanicLimitedGroup(t *testing.T) { 245 | t.Parallel() 246 | var leak contextLeak 247 | var waitForNonPanic, unblockInnocent, block sync.WaitGroup 248 | waitForNonPanic.Add(1) 249 | unblockInnocent.Add(1) 250 | block.Add(1) 251 | g := Limited(context.Background(), 10) 252 | g.Go(func(ctx context.Context) { // Innocent function 253 | leak.leak(ctx) 254 | waitForNonPanic.Done() 255 | unblockInnocent.Wait() 256 | }) 257 | g.Go(func(context.Context) { // Panicking function 258 | block.Wait() 259 | unblockInnocent.Done() 260 | panic("lol") 261 | }) 262 | waitForNonPanic.Wait() 263 | block.Done() 264 | assertPanicsWithValue(t, "lol", func() { 265 | g.Wait() 266 | }) 267 | leak.assertAllCanceled(t, errPanicked) 268 | } 269 | 270 | func TestPanicLimitedGroupSecondPath(t *testing.T) { 271 | t.Parallel() 272 | var leak contextLeak 273 | var waitForNonPanic, unblockInnocent, block sync.WaitGroup 274 | waitForNonPanic.Add(1) 275 | unblockInnocent.Add(1) 276 | block.Add(1) 277 | g := Limited(context.Background(), 10) 278 | g.Go(func(ctx context.Context) { // Innocent function 279 | leak.leak(ctx) 280 | waitForNonPanic.Done() 281 | unblockInnocent.Wait() 282 | }) 283 | g.Go(func(context.Context) { // Panicking function 284 | block.Wait() 285 | unblockInnocent.Done() 286 | panic("lol") 287 | }) 288 | waitForNonPanic.Wait() 289 | block.Done() 290 | assertPanicsWithValue(t, "lol", func() { 291 | // Eventually :) 292 | for { 293 | g.Go(func(context.Context) {}) 294 | } 295 | }) 296 | leak.assertAllCanceled(t, errPanicked) 297 | } 298 | 299 | func TestPanicFeedFunction(t *testing.T) { 300 | t.Parallel() 301 | var leak contextLeak 302 | g := Feed(Unlimited(context.Background()), func(ctx context.Context, _ int) error { 303 | leak.leak(ctx) 304 | panic("oh no!") 305 | }) 306 | g.Go(func(context.Context) (int, error) { 307 | return 1, nil 308 | }) 309 | assertPanicsWithValue(t, "oh no!", func() { _ = g.Wait() }) 310 | leak.assertAllCanceled(t, errPanicked) 311 | } 312 | 313 | func TestPanicFeedWork(t *testing.T) { 314 | t.Parallel() 315 | var leak contextLeak 316 | g := Feed(Unlimited(context.Background()), func(context.Context, int) error { 317 | t.Fatal("should not get called") 318 | return nil 319 | }) 320 | g.Go(func(ctx context.Context) (int, error) { 321 | leak.leak(ctx) 322 | panic("oh no!") 323 | }) 324 | assertPanicsWithValue(t, "oh no!", func() { _ = g.Wait() }) 325 | leak.assertAllCanceled(t, errPanicked) 326 | } 327 | 328 | func TestPanicFeedWorkSecondPath(t *testing.T) { 329 | t.Parallel() 330 | var leak contextLeak 331 | g := Feed(Unlimited(context.Background()), func(context.Context, int) error { 332 | t.Fatal("should not get a value") 333 | return nil 334 | }) 335 | g.Go(func(ctx context.Context) (int, error) { 336 | leak.leak(ctx) 337 | panic("oh no!") 338 | }) 339 | ctx, _ := g.(feedingGroup[int]).g.getContext() 340 | <-ctx.Done() 341 | assertPanicsWithValue(t, "oh no!", func() { 342 | g.Go(func(context.Context) (int, error) { return 2, nil }) 343 | }) 344 | leak.assertAllCanceled(t, errPanicked) 345 | } 346 | 347 | func TestPanicFeedFunctionNotCalled(t *testing.T) { 348 | t.Parallel() 349 | var leak contextLeak 350 | g := Feed(Unlimited(context.Background()), func(context.Context, int) error { 351 | t.Fatal("should not get a value") 352 | return nil 353 | }) 354 | fooError := errors.New("foo") 355 | g.Go(func(ctx context.Context) (int, error) { 356 | leak.leak(ctx) 357 | return 0, fooError 358 | }) 359 | assert.NotPanics(t, func() { 360 | assert.ErrorIs(t, g.Wait(), fooError) 361 | }) 362 | leak.assertAllCanceled(t, fooError) 363 | } 364 | 365 | func TestPanicFeedErrFunction(t *testing.T) { 366 | t.Parallel() 367 | var leak contextLeak 368 | g := FeedWithErrs(Unlimited(context.Background()), func(context.Context, int) error { 369 | panic("oh no!") 370 | }) 371 | g.Go(func(ctx context.Context) (int, error) { 372 | leak.leak(ctx) 373 | return 1, nil 374 | }) 375 | assertPanicsWithValue(t, "oh no!", func() { _ = g.Wait() }) 376 | leak.assertAllCanceled(t, errPanicked) 377 | } 378 | 379 | func TestPanicFeedErrWork(t *testing.T) { 380 | t.Parallel() 381 | var leak contextLeak 382 | g := FeedWithErrs(Unlimited(context.Background()), func(context.Context, int) error { 383 | t.Fatal("should not get a value") 384 | return nil 385 | }) 386 | g.Go(func(ctx context.Context) (int, error) { 387 | leak.leak(ctx) 388 | panic("oh no!") 389 | }) 390 | assertPanicsWithValue(t, "oh no!", func() { _ = g.Wait() }) 391 | leak.assertAllCanceled(t, errPanicked) 392 | } 393 | 394 | func TestPanicFeedErrWorkSecondPath(t *testing.T) { 395 | t.Parallel() 396 | var leak contextLeak 397 | g := FeedWithErrs(Unlimited(context.Background()), func(context.Context, int) error { 398 | t.Fatal("should not get a value") 399 | return nil 400 | }) 401 | g.Go(func(ctx context.Context) (int, error) { 402 | leak.leak(ctx) 403 | panic("oh no!") 404 | }) 405 | ctx, _ := g.(feedingMultiErrGroup[int]).g.getContext() 406 | <-ctx.Done() 407 | assertPanicsWithValue(t, "oh no!", func() { 408 | g.Go(func(context.Context) (int, error) { return 2, nil }) 409 | }) 410 | leak.assertAllCanceled(t, errPanicked) 411 | } 412 | 413 | func TestPanicFeedErrFunctionNoValues(t *testing.T) { 414 | t.Parallel() 415 | var leak contextLeak 416 | g := FeedWithErrs(Unlimited(context.Background()), func(context.Context, int) error { 417 | t.Fatal("should not get a value") 418 | return nil 419 | }) 420 | g.Go(func(ctx context.Context) (int, error) { 421 | leak.leak(ctx) 422 | return 0, errors.New("regular error") 423 | }) 424 | assert.Errorf(t, g.Wait(), "regular error") 425 | leak.assertAllCanceled(t, errGroupDone) 426 | } 427 | 428 | func TestMisuseReuse(t *testing.T) { 429 | t.Parallel() 430 | limitedWithAllWorkers := Limited(context.Background(), 10) 431 | for i := 0; i < 10; i++ { 432 | limitedWithAllWorkers.Go(func(context.Context) {}) 433 | } 434 | for _, testCase := range []struct { 435 | name string 436 | g Executor 437 | }{ 438 | {"Unlimited", Unlimited(context.Background())}, 439 | {"Limited", Limited(context.Background(), 10)}, 440 | {"Serial", Limited(context.Background(), 0)}, 441 | {"Limited with all workers", limitedWithAllWorkers}, 442 | } { 443 | testCase := testCase 444 | t.Run(testCase.name, func(t *testing.T) { 445 | t.Parallel() 446 | testCase.g.Wait() 447 | assert.PanicsWithValue( 448 | t, 449 | "parallel executor misuse: don't reuse executors", 450 | func() { 451 | testCase.g.Go(func(context.Context) { 452 | t.Fatal("this should never run") 453 | }) 454 | }, 455 | ) 456 | }) 457 | } 458 | } 459 | 460 | func TestMisuseReuseCollector(t *testing.T) { 461 | t.Parallel() 462 | g := Collect[int](Unlimited(context.Background())) 463 | res, err := g.Wait() 464 | assert.NoError(t, err) 465 | assert.Equal(t, []int(nil), res) 466 | assert.PanicsWithValue( 467 | t, 468 | "parallel executor misuse: don't reuse executors", 469 | func() { 470 | g.Go(func(context.Context) (int, error) { 471 | t.Fatal("this should never run") 472 | return 1, nil 473 | }) 474 | }, 475 | ) 476 | } 477 | 478 | func TestGroupsPanicAgain(t *testing.T) { 479 | t.Parallel() 480 | for _, test := range []struct { 481 | name string 482 | g func() Executor 483 | }{ 484 | {"Unlimited", func() Executor { return Unlimited(context.Background()) }}, 485 | {"Limited", func() Executor { return Limited(context.Background(), 10) }}, 486 | } { 487 | test := test 488 | t.Run(test.name, func(t *testing.T) { 489 | t.Parallel() 490 | innerGroup := test.g() 491 | outerGroup := test.g() 492 | outerGroup.Go(func(context.Context) { 493 | innerGroup.Go(func(context.Context) { panic("at the disco") }) 494 | innerGroup.Wait() 495 | }) 496 | assertPanicsWithValue(t, "at the disco", outerGroup.Wait) 497 | assertPanicsWithValue(t, "at the disco", innerGroup.Wait) 498 | assertPanicsWithValue(t, "at the disco", outerGroup.Wait) 499 | assertPanicsWithValue(t, "at the disco", innerGroup.Wait) 500 | }) 501 | } 502 | } 503 | 504 | func TestPipeGroupPanicsAgain(t *testing.T) { 505 | t.Parallel() 506 | g := Feed(Unlimited(context.Background()), func(context.Context, int) error { return nil }) 507 | g.Go(func(context.Context) (int, error) { panic("at the disco") }) 508 | assertPanicsWithValue(t, "at the disco", func() { _ = g.Wait() }) 509 | assertPanicsWithValue(t, "at the disco", func() { _ = g.Wait() }) 510 | } 511 | 512 | func TestForgottenPipeLegiblePanic(t *testing.T) { 513 | t.Parallel() 514 | exec := Unlimited(context.Background()) 515 | var blocker sync.WaitGroup 516 | blocker.Add(1) 517 | valuePipe := func() chan int { 518 | g := Collect[int](exec) 519 | g.Go(func(context.Context) (int, error) { 520 | blocker.Wait() 521 | return 1, nil 522 | }) 523 | return g.(collectingGroup[int]).pipe 524 | // leak the un-awaited group 525 | }() 526 | assert.NotNil(t, valuePipe) 527 | runtime.GC() // Trigger cleanups for leaked resources 528 | runtime.GC() // Trigger cleanups for leaked resources 529 | runtime.GC() // Trigger cleanups for leaked resources 530 | for range valuePipe { 531 | } 532 | // The collector's pipe is now closed. Unblock the task we submitted to the 533 | // collector now, so its value will be sent to the closed pipe. When this 534 | // happens the panic will be stored in the executor, so we re-panic that 535 | // specific error with a more diagnostic message. 536 | blocker.Done() 537 | assertPanicsWithValue(t, "parallel executor pipe error: a "+ 538 | "collector using this same executor was probably not awaited", exec.Wait) 539 | } 540 | -------------------------------------------------------------------------------- /collect.go: -------------------------------------------------------------------------------- 1 | package parallel 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | "sync/atomic" 7 | ) 8 | 9 | // Executor that runs until the first error encountered 10 | type ErrGroupExecutor interface { 11 | // Go submits a task to the Executor, to be run at some point in the future. 12 | // 13 | // Panics if Wait() has already been called. 14 | // May panic if any submitted task has already panicked. 15 | Go(func(context.Context) error) 16 | // Wait waits until all submitted tasks have completed, then returns one 17 | // error if any errors were returned by submitted tasks (or nil). 18 | // 19 | // After waiting, panics if any submitted task panicked. 20 | Wait() error 21 | } 22 | 23 | // Executor that collects all the return values of the operations, then returns 24 | // the resulting slice or an error if any occurred. 25 | type CollectingExecutor[T any] interface { 26 | // Go submits a task to the Executor, to be run at some point in the future. 27 | // 28 | // Panics if Wait() has already been called. 29 | // May panic if any submitted task has already panicked. 30 | Go(func(context.Context) (T, error)) 31 | // Wait waits until all submitted tasks have completed, then returns a slice 32 | // of the returned values from non-erring tasks or an error if any occurred. 33 | // 34 | // After waiting, panics if any submitted task panicked. 35 | Wait() ([]T, error) 36 | } 37 | 38 | // Executor that feeds all the return values of the operations to a user 39 | // function. 40 | type FeedingExecutor[T any] interface { 41 | // Go submits a task to the Executor, to be run at some point in the future. 42 | // 43 | // Panics if Wait() has already been called. 44 | // May panic if any submitted task has already panicked. 45 | Go(func(context.Context) (T, error)) 46 | // Wait waits until all running tasks have completed and all returned values 47 | // from non-erring tasks have been processed by the receiver function, then 48 | // returns an error if any occurred. 49 | // 50 | // After waiting, panics if any submitted task panicked. 51 | Wait() error 52 | } 53 | 54 | // Executor that collects every error from the operations. 55 | type AllErrsExecutor interface { 56 | // Go submits a task to the Executor, to be run at some point in the future. 57 | // 58 | // Panics if Wait() has already been called. 59 | // May panic if any submitted task has already panicked. 60 | Go(func(context.Context) error) 61 | // Wait waits until all submitted tasks have completed, then returns a 62 | // MultiError of any errors that were returned by submitted tasks (or nil). 63 | // 64 | // After waiting, panics if any submitted task panicked. 65 | Wait() MultiError 66 | } 67 | 68 | // Executor that collects the returned values and errors of the operations. 69 | type CollectingAllErrsExecutor[T any] interface { 70 | // Go submits a task to the Executor, to be run at some point in the future. 71 | // 72 | // Panics if Wait() has already been called. 73 | // May panic if any submitted task has already panicked. 74 | Go(func(context.Context) (T, error)) 75 | // Wait waits until all submitted tasks have completed, then returns a slice 76 | // of the returned values from non-erring tasks and a MultiError of any 77 | // errors returned (or nil). 78 | // 79 | // After waiting, panics if any submitted task panicked. 80 | Wait() ([]T, MultiError) 81 | } 82 | 83 | // Executor that feeds all the return values of the operations to a user 84 | // function and collects returned errors. 85 | type FeedingAllErrsExecutor[T any] interface { 86 | // Go submits a task to the Executor, to be run at some point in the future. 87 | // 88 | // Panics if Wait() has already been called. 89 | // May panic if any submitted task has already panicked. 90 | Go(func(context.Context) (T, error)) 91 | // Wait waits until all submitted tasks have completed and all returned 92 | // values from non-erring tasks have been processed by the receiver 93 | // function, then returns a MultiError of any errors that were returned by 94 | // the tasks (or nil). 95 | // 96 | // After waiting, panics if any submitted task panicked. 97 | Wait() MultiError 98 | } 99 | 100 | // Returns an executor that halts if any submitted task returns an error, and 101 | // returns one error from Wait() if any occurred. 102 | func ErrGroup(executor Executor) ErrGroupExecutor { 103 | return &errGroup{executor} 104 | } 105 | 106 | // Returns an executor that collects all the return values from the functions 107 | // provided, returning them (in no guaranteed order!) in a slice at the end. 108 | // 109 | // These executors are even best-effort safe against misuse: if the owner panics 110 | // or otherwise forgets to call Wait(), the goroutines started by this executor 111 | // should still be cleaned up. 112 | func Collect[T any](executor Executor) CollectingExecutor[T] { 113 | making := collectingGroup[T]{makePipeGroup[T, *[]T](executor)} 114 | var outOfLineResults []T 115 | making.res = &outOfLineResults 116 | pipe := making.pipe // Don't capture a pointer to the executor 117 | making.pipeWorkers.Go(func(context.Context) { 118 | for item := range pipe { 119 | outOfLineResults = append(outOfLineResults, item) 120 | } 121 | }) 122 | return making 123 | } 124 | 125 | // Returns an executor that collects all the return values from the functions 126 | // provided, passing them all (in no guaranteed order!) to the provided 127 | // receiver, which runs in a single goroutine by itself. In the event of an 128 | // error from either the work functions or the receiver function, execution 129 | // halts and the first error is returned. 130 | // 131 | // These executors are even best-effort safe against misuse: if the owner panics 132 | // or otherwise forgets to call Wait(), the goroutines started by this executor 133 | // should still be cleaned up. 134 | func Feed[T any](executor Executor, receiver func(context.Context, T) error) FeedingExecutor[T] { 135 | making := feedingGroup[T]{makePipeGroup[T, struct{}](executor)} 136 | pipe := making.pipe // Don't capture a pointer to the executor 137 | _, cancel := making.g.getContext() 138 | making.pipeWorkers.Go(func(ctx context.Context) { 139 | for val := range pipe { 140 | if err := receiver(ctx, val); err != nil { 141 | cancel(err) 142 | for range pipe { 143 | // Discard all future values 144 | } 145 | return 146 | } 147 | } 148 | }) 149 | return making 150 | } 151 | 152 | // Returns an executor similar to parallel.ErrGroup, except instead of only 153 | // returning the first error encountered it returns a MultiError of any & all 154 | // errors encountered (or nil if none). 155 | // 156 | // These executors are even best-effort safe against misuse: if the owner panics 157 | // or otherwise forgets to call Wait(), the goroutines started by this executor 158 | // should still be cleaned up. 159 | func GatherErrs(executor Executor) AllErrsExecutor { 160 | making := multiErrGroup{makePipeGroup[error, *[]error](executor)} 161 | var outOfLineErrs []error 162 | making.res = &outOfLineErrs 163 | pipe := making.pipe // Don't capture a pointer to the executor 164 | making.pipeWorkers.Go(func(context.Context) { 165 | for err := range pipe { 166 | outOfLineErrs = append(outOfLineErrs, err) 167 | } 168 | }) 169 | return making 170 | } 171 | 172 | // Returns an executor that collects both values and a MultiError of any & all 173 | // errors (or nil if none). Return values are not included in the results if 174 | // that invocation returned an error. Execution does not stop if errors are 175 | // encountered, only if there is a panic. 176 | // 177 | // These executors are even best-effort safe against misuse: if the owner panics 178 | // or otherwise forgets to call Wait(), the goroutines started by this executor 179 | // should still be cleaned up. 180 | func CollectWithErrs[T any](executor Executor) CollectingAllErrsExecutor[T] { 181 | making := collectingMultiErrGroup[T]{ 182 | makePipeGroup[withErr[T], *collectedResultWithErrs[T]](executor), 183 | } 184 | var outOfLineResults collectedResultWithErrs[T] 185 | making.res = &outOfLineResults 186 | pipe := making.pipe // Don't capture a pointer to the executor 187 | making.pipeWorkers.Go(func(context.Context) { 188 | for item := range pipe { 189 | if item.err != nil { 190 | outOfLineResults.errs = append(outOfLineResults.errs, item.err) 191 | } else { 192 | outOfLineResults.values = append(outOfLineResults.values, item.value) 193 | } 194 | } 195 | }) 196 | return making 197 | } 198 | 199 | // Returns an executor that collects all the return values from the functions 200 | // provided, passing them all (in no guaranteed order!) to the provided 201 | // receiver, which runs in a single goroutine by itself. Execution does not stop 202 | // if errors are encountered from either the work functions or the receiver 203 | // function; those errors are all combined into the MultiError returned by 204 | // Wait(). 205 | // 206 | // These executors are even best-effort safe against misuse: if the owner panics 207 | // or otherwise forgets to call Wait(), the goroutines started by this executor 208 | // should still be cleaned up. 209 | func FeedWithErrs[T any](executor Executor, receiver func(context.Context, T) error) FeedingAllErrsExecutor[T] { 210 | making := feedingMultiErrGroup[T]{makePipeGroup[withErr[T], *[]error](executor)} 211 | var outOfLineResults []error 212 | making.res = &outOfLineResults 213 | pipe := making.pipe // Don't capture a pointer to the executor 214 | making.pipeWorkers.Go(func(ctx context.Context) { 215 | for pair := range pipe { 216 | if pair.err != nil { 217 | outOfLineResults = append(outOfLineResults, pair.err) 218 | } else if processErr := receiver(ctx, pair.value); processErr != nil { 219 | outOfLineResults = append(outOfLineResults, processErr) 220 | } 221 | } 222 | }) 223 | return making 224 | } 225 | 226 | // groupError returns the error associated with a group's context; if the error 227 | // was errGroupDone, that doesn't count as an error and nil is returned instead. 228 | func groupError(ctx context.Context) error { 229 | err := context.Cause(ctx) 230 | // We are explicitly using == here to check for the exact value of our 231 | // sentinel error, not using errors.Is(), because we don't actually want to 232 | // find it if it's in wrapped errors. We *only* want to know whether the 233 | // cancelation error is *exactly* errGroupDone. 234 | if err == errGroupDone { 235 | return nil 236 | } 237 | return err 238 | } 239 | 240 | var _ ErrGroupExecutor = &errGroup{} 241 | 242 | type errGroup struct { 243 | g Executor 244 | } 245 | 246 | func (eg *errGroup) Go(op func(context.Context) error) { 247 | _, cancel := eg.g.getContext() // Don't capture a pointer to the group 248 | eg.g.Go(func(ctx context.Context) { 249 | err := op(ctx) 250 | if err != nil { 251 | cancel(err) 252 | } 253 | }) 254 | } 255 | 256 | func (eg *errGroup) Wait() error { 257 | eg.g.Wait() 258 | ctx, _ := eg.g.getContext() 259 | return groupError(ctx) 260 | } 261 | 262 | func makePipeGroup[T any, R any](executor Executor) *pipeGroup[T, R] { 263 | making := &pipeGroup[T, R]{ 264 | g: executor, 265 | pipeWorkers: makeGroup(executor.getContext()), // use the same context for the pipe group 266 | pipe: make(chan T, bufferSize), 267 | } 268 | runtime.SetFinalizer(making, func(doomed *pipeGroup[T, R]) { 269 | close(doomed.pipe) 270 | }) 271 | return making 272 | } 273 | 274 | // Underlying implementation for executors that handle results. 275 | // 276 | // T is the type that goes through the pipe, and R is the return value field we 277 | // are collecting into 278 | type pipeGroup[T any, R any] struct { 279 | // All the constituent parts of this struct are out-of-line so that none of 280 | // the goroutines doing work for it need to hold a reference to any of this 281 | // memory. Thus, if the user forgets to call Wait(), we can hook the GC 282 | // finalizer and ensure that the channels are closed and the goroutines we 283 | // were running get cleaned up. 284 | g Executor 285 | pipeWorkers *group 286 | pipe chan T 287 | awaited atomic.Bool 288 | res R 289 | } 290 | 291 | func sendToPipe[T any](pipe chan T, val T) { 292 | defer func() { 293 | if recover() != nil { 294 | panic("parallel executor pipe error: a collector using this " + 295 | "same executor was probably not awaited") 296 | } 297 | }() 298 | pipe <- val 299 | } 300 | 301 | func (pg *pipeGroup[T, R]) doWait() { 302 | // This function sucks to look at because go has no concept of scoped 303 | // lifetime other than function-scope. You can only ensure something happens 304 | // even in case of a panic by deferring it, and that always only happens at 305 | // the end of the function... so, we just put an inner function here to make 306 | // it happen "early." 307 | 308 | // Runs last: We must make completely certain that we cancel the context 309 | // owned by the pipeGroup. This context is shared between the executor and 310 | // the pipeWorkers; we take charge of making sure this cancelation happens 311 | // as soon as possible here, and we want it to happen at the very end after 312 | // everything else in case something else wanted to set the cancel cause of 313 | // the context to an actual error instead of our "no error" sentinel value. 314 | defer pg.pipeWorkers.cancel(errGroupDone) 315 | func() { 316 | // Runs second: Close the results chan and unblock the pipe worker. 317 | // Because we're deferring this, it will happen even if there is a panic 318 | defer func() { 319 | if !pg.awaited.Swap(true) { 320 | close(pg.pipe) 321 | // Don't try to close this chan again :) 322 | runtime.SetFinalizer(pg, nil) 323 | } 324 | }() 325 | // Runs first: Wait for inputs. Wait "quietly", not canceling the 326 | // context yet so if there is an error later we can still see it 327 | pg.g.waitWithoutCanceling() 328 | }() 329 | // Runs third: Wait for outputs to be done 330 | pg.pipeWorkers.waitWithoutCanceling() 331 | } 332 | 333 | var _ CollectingExecutor[int] = collectingGroup[int]{} 334 | 335 | type collectingGroup[T any] struct { 336 | *pipeGroup[T, *[]T] 337 | } 338 | 339 | func (cg collectingGroup[T]) Go(op func(context.Context) (T, error)) { 340 | pipe := cg.pipe // Don't capture a pointer to the group 341 | _, cancel := cg.g.getContext() 342 | cg.g.Go(func(ctx context.Context) { 343 | val, err := op(ctx) 344 | if err != nil { 345 | cancel(err) 346 | return 347 | } 348 | sendToPipe(pipe, val) 349 | }) 350 | } 351 | 352 | func (cg collectingGroup[T]) Wait() ([]T, error) { 353 | cg.doWait() 354 | ctx, _ := cg.g.getContext() 355 | if err := groupError(ctx); err != nil { 356 | // We have an error; return it 357 | return nil, err 358 | } 359 | return *cg.res, nil 360 | } 361 | 362 | var _ FeedingExecutor[int] = feedingGroup[int]{} 363 | 364 | type feedingGroup[T any] struct { 365 | *pipeGroup[T, struct{}] 366 | } 367 | 368 | func (fg feedingGroup[T]) Go(op func(context.Context) (T, error)) { 369 | pipe := fg.pipe // Don't capture a pointer to the group 370 | _, cancel := fg.g.getContext() 371 | fg.g.Go(func(ctx context.Context) { 372 | val, err := op(ctx) 373 | if err != nil { 374 | cancel(err) 375 | return 376 | } 377 | sendToPipe(pipe, val) 378 | }) 379 | } 380 | 381 | func (fg feedingGroup[T]) Wait() error { 382 | fg.doWait() 383 | ctx, _ := fg.g.getContext() 384 | return groupError(ctx) 385 | } 386 | 387 | var _ AllErrsExecutor = multiErrGroup{} 388 | 389 | type multiErrGroup struct { 390 | *pipeGroup[error, *[]error] 391 | } 392 | 393 | func (meg multiErrGroup) Go(op func(context.Context) error) { 394 | pipe := meg.pipe // Don't capture a pointer to the group 395 | meg.g.Go(func(ctx context.Context) { 396 | // Only send non-nil errors to the results pipe 397 | if err := op(ctx); err != nil { 398 | sendToPipe(pipe, err) 399 | } 400 | }) 401 | } 402 | 403 | func (meg multiErrGroup) Wait() MultiError { 404 | meg.doWait() 405 | err := CombineErrors(*meg.res...) 406 | ctx, _ := meg.g.getContext() 407 | if cause := groupError(ctx); cause != nil { 408 | return CombineErrors(cause, err) 409 | } 410 | return err 411 | } 412 | 413 | var _ CollectingAllErrsExecutor[int] = collectingMultiErrGroup[int]{} 414 | 415 | type withErr[T any] struct { 416 | value T 417 | err error 418 | } 419 | 420 | type collectedResultWithErrs[T any] struct { 421 | values []T 422 | errs []error 423 | } 424 | 425 | type collectingMultiErrGroup[T any] struct { 426 | *pipeGroup[withErr[T], *collectedResultWithErrs[T]] 427 | } 428 | 429 | func (ceg collectingMultiErrGroup[T]) Go(op func(context.Context) (T, error)) { 430 | pipe := ceg.pipe // Don't capture a pointer to the group 431 | ceg.g.Go(func(ctx context.Context) { 432 | value, err := op(ctx) 433 | sendToPipe(pipe, withErr[T]{value, err}) 434 | }) 435 | } 436 | 437 | func (ceg collectingMultiErrGroup[T]) Wait() ([]T, MultiError) { 438 | ceg.doWait() 439 | res, err := ceg.res.values, CombineErrors(ceg.res.errs...) 440 | ctx, _ := ceg.g.getContext() 441 | if cause := groupError(ctx); cause != nil { 442 | return res, CombineErrors(cause, err) 443 | } 444 | return res, err 445 | } 446 | 447 | var _ FeedingAllErrsExecutor[int] = feedingMultiErrGroup[int]{} 448 | 449 | type feedingMultiErrGroup[T any] struct { 450 | *pipeGroup[withErr[T], *[]error] 451 | } 452 | 453 | func (feg feedingMultiErrGroup[T]) Go(op func(context.Context) (T, error)) { 454 | pipe := feg.pipe // Don't capture a pointer to the group 455 | feg.g.Go(func(ctx context.Context) { 456 | value, err := op(ctx) 457 | sendToPipe(pipe, withErr[T]{value, err}) 458 | }) 459 | } 460 | 461 | func (feg feedingMultiErrGroup[T]) Wait() MultiError { 462 | feg.doWait() 463 | err := CombineErrors(*feg.res...) 464 | ctx, _ := feg.g.getContext() 465 | if cause := groupError(ctx); cause != nil { 466 | return CombineErrors(cause, err) 467 | } 468 | return err 469 | } 470 | --------------------------------------------------------------------------------