├── .github ├── FUNDING.yml └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── README.md ├── all.go ├── all_example_test.go ├── do.go ├── do_example_test.go ├── do_test.go ├── doc.go ├── each.go ├── each_example_test.go ├── each_test.go ├── go.mod ├── go.sum ├── manage_tasks.go ├── manage_tasks_example_test.go ├── manage_tasks_test.go ├── map.go ├── map_example_test.go ├── map_test.go ├── panic_test.go ├── race.go ├── race_example_test.go ├── race_test.go ├── taskpool.go ├── taskpool_example_test.go └── testdata └── md5all └── hello.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: carlmjohnson 2 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | 7 | build: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-go@v3 13 | with: 14 | go-version: '1.21' 15 | cache: true 16 | - name: Get dependencies 17 | run: go mod download 18 | - name: Test 19 | run: go test -race -v -coverprofile=profile.cov ./... 20 | - name: Upload Coverage 21 | uses: shogo82148/actions-goveralls@v1 22 | with: 23 | path-to-profile: profile.cov 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | img/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Carl Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flowmatic [![GoDoc](https://pkg.go.dev/badge/github.com/carlmjohnson/flowmatic)](https://pkg.go.dev/github.com/carlmjohnson/flowmatic) [![Coverage Status](https://coveralls.io/repos/github/carlmjohnson/flowmatic/badge.svg)](https://coveralls.io/github/carlmjohnson/flowmatic) [![Go Report Card](https://goreportcard.com/badge/github.com/carlmjohnson/flowmatic)](https://goreportcard.com/report/github.com/carlmjohnson/flowmatic) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 2 | 3 | ![Flowmatic logo](https://github.com/carlmjohnson/flowmatic/assets/222245/c14936e9-bb35-405b-926e-4cfeb8003439) 4 | 5 | 6 | Flowmatic is a generic Go library that provides a [structured approach](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/) to concurrent programming. It lets you easily manage concurrent tasks in a manner that is simple, yet effective and flexible. 7 | 8 | Flowmatic has an easy to use API with functions for handling common concurrency patterns. It automatically handles spawning workers, collecting errors, and propagating panics. 9 | 10 | Flowmatic requires Go 1.20+. 11 | 12 | ## Features 13 | 14 | - Has a simple API that improves readability over channels/waitgroups/mutexes 15 | - Handles a variety of concurrency problems such as heterogenous task groups, homogenous execution of a task over a slice, and dynamic work spawning 16 | - Aggregates errors 17 | - Properly propagates panics across goroutine boundaries 18 | - Has helpers for context cancelation 19 | - Few dependencies 20 | - Good test coverage 21 | 22 | ## How to use Flowmatic 23 | 24 | ### Execute heterogenous tasks 25 | One problem that Flowmatic solves is managing the execution of multiple tasks in parallel that are independent of each other. For example, let's say you want to send data to three different downstream APIs. If any of the sends fail, you want to return an error. With traditional Go concurrency, this can quickly become complex and difficult to manage, with Goroutines, channels, and `sync.WaitGroup`s to keep track of. Flowmatic makes it simple. 26 | 27 | To execute heterogenous tasks, just use `flowmatic.Do`: 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 51 | 91 | 92 |
flowmaticstdlib
36 | 37 | ```go 38 | err := flowmatic.Do( 39 | func() error { 40 | return doThingA(), 41 | }, 42 | func() error { 43 | return doThingB(), 44 | }, 45 | func() error { 46 | return doThingC(), 47 | }) 48 | ``` 49 | 50 | 52 | 53 | ```go 54 | var wg sync.WaitGroup 55 | var errs []error 56 | errChan := make(chan error) 57 | 58 | wg.Add(3) 59 | go func() { 60 | defer wg.Done() 61 | if err := doThingA(); err != nil { 62 | errChan <- err 63 | } 64 | }() 65 | go func() { 66 | defer wg.Done() 67 | if err := doThingB(); err != nil { 68 | errChan <- err 69 | } 70 | }() 71 | go func() { 72 | defer wg.Done() 73 | if err := doThingC(); err != nil { 74 | errChan <- err 75 | } 76 | }() 77 | 78 | go func() { 79 | wg.Wait() 80 | close(errChan) 81 | }() 82 | 83 | for err := range errChan { 84 | errs = append(errs, err) 85 | } 86 | 87 | err := errors.Join(errs...) 88 | ``` 89 | 90 |
93 | 94 | To create a context for tasks that is canceled on the first error, 95 | use `flowmatic.All`. 96 | To create a context for tasks that is canceled on the first success, 97 | use `flowmatic.Race`. 98 | 99 | ```go 100 | // Make variables to hold responses 101 | var pageA, pageB, pageC string 102 | // Race the requests to see who can answer first 103 | err := flowmatic.Race(ctx, 104 | func(ctx context.Context) error { 105 | var err error 106 | pageA, err = request(ctx, "A") 107 | return err 108 | }, 109 | func(ctx context.Context) error { 110 | var err error 111 | pageB, err = request(ctx, "B") 112 | return err 113 | }, 114 | func(ctx context.Context) error { 115 | var err error 116 | pageC, err = request(ctx, "C") 117 | return err 118 | }, 119 | ) 120 | ``` 121 | 122 | ### Execute homogenous tasks 123 | `flowmatic.Each` is useful if you need to execute the same task on each item in a slice using a worker pool: 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 144 | 179 | 180 |
flowmaticstdlib
132 | 133 | ```go 134 | things := []someType{thingA, thingB, thingC} 135 | 136 | err := flowmatic.Each(numWorkers, things, 137 | func(thing someType) error { 138 | foo := thing.Frobincate() 139 | return foo.DoSomething() 140 | }) 141 | ``` 142 | 143 | 145 | 146 | ```go 147 | things := []someType{thingA, thingB, thingC} 148 | 149 | work := make(chan someType) 150 | errs := make(chan error) 151 | 152 | for i := 0; i < numWorkers; i++ { 153 | go func() { 154 | for thing := range work { 155 | // Omitted: panic handling! 156 | foo := thing.Frobincate() 157 | errs <- foo.DoSomething() 158 | } 159 | }() 160 | } 161 | 162 | go func() { 163 | for _, thing := range things { 164 | work <- thing 165 | } 166 | 167 | close(tasks) 168 | }() 169 | 170 | var collectedErrs []error 171 | for i := 0; i < len(things); i++ { 172 | collectedErrs = append(collectedErrs, <-errs) 173 | } 174 | 175 | err := errors.Join(collectedErrs...) 176 | ``` 177 | 178 |
181 | 182 | Use `flowmatic.Map` to map an input slice to an output slice. 183 | 184 | 185 | 186 | 202 | 203 | 204 | 205 | 206 | 220 | 246 |
187 | 188 | ```go 189 | func main() { 190 | results, err := Google(context.Background(), "golang") 191 | if err != nil { 192 | fmt.Fprintln(os.Stderr, err) 193 | return 194 | } 195 | for _, result := range results { 196 | fmt.Println(result) 197 | } 198 | } 199 | ``` 200 | 201 |
flowmaticx/sync/errgroup
207 | 208 | ```go 209 | func Google(ctx context.Context, query string) ([]Result, error) { 210 | searches := []Search{Web, Image, Video} 211 | return flowmatic.Map(ctx, flowmatic.MaxProcs, searches, 212 | func(ctx context.Context, search Search) (Result, error) { 213 | return search(ctx, query) 214 | }) 215 | } 216 | ``` 217 | 218 | 219 | 221 | 222 | ```go 223 | func Google(ctx context.Context, query string) ([]Result, error) { 224 | g, ctx := errgroup.WithContext(ctx) 225 | 226 | searches := []Search{Web, Image, Video} 227 | results := make([]Result, len(searches)) 228 | for i, search := range searches { 229 | i, search := i, search // https://golang.org/doc/faq#closures_and_goroutines 230 | g.Go(func() error { 231 | result, err := search(ctx, query) 232 | if err == nil { 233 | results[i] = result 234 | } 235 | return err 236 | }) 237 | } 238 | if err := g.Wait(); err != nil { 239 | return nil, err 240 | } 241 | return results, nil 242 | } 243 | ``` 244 | 245 |
247 | 248 | ### Manage tasks that spawn new tasks 249 | For tasks that may create more work, use `flowmatic.ManageTasks`. 250 | Create a manager that will be serially executed, 251 | and have it save the results 252 | and examine the output of tasks to decide if there is more work to be done. 253 | 254 | ```go 255 | // Task fetches a page and extracts the URLs 256 | task := func(u string) ([]string, error) { 257 | page, err := getURL(ctx, u) 258 | if err != nil { 259 | return nil, err 260 | } 261 | return getLinks(page), nil 262 | } 263 | 264 | // Map from page to links 265 | // Doesn't need a lock because only the manager touches it 266 | results := map[string][]string{} 267 | var managerErr error 268 | 269 | // Manager keeps track of which pages have been visited and the results graph 270 | manager := func(req string, links []string, err error) ([]string, bool) { 271 | // Halt execution after the first error 272 | if err != nil { 273 | managerErr = err 274 | return nil, false 275 | } 276 | // Save final results in map 277 | results[req] = urls 278 | 279 | // Check for new pages to scrape 280 | var newpages []string 281 | for _, link := range links { 282 | if _, ok := results[link]; ok { 283 | // Seen it, try the next link 284 | continue 285 | } 286 | // Add to list of new pages 287 | newpages = append(newpages, link) 288 | // Add placeholder to map to prevent double scraping 289 | results[link] = nil 290 | } 291 | return newpages, true 292 | } 293 | 294 | // Process the tasks with as many workers as GOMAXPROCS 295 | flowmatic.ManageTasks(flowmatic.MaxProcs, task, manager, "http://example.com/") 296 | // Check if anything went wrong 297 | if managerErr != nil { 298 | fmt.Println("error", managerErr) 299 | } 300 | ``` 301 | 302 | Normally, it is very difficult to keep track of concurrent code because any combination of events could occur in any order or simultaneously, and each combination has to be accounted for by the programmer. `flowmatic.ManageTasks` makes it simple to write concurrent code because everything follows a simple rule: **tasks happen concurrently; the manager runs serially**. 303 | 304 | Centralizing control in the manager makes reasoning about the code radically simpler. When writing locking code, if you have M states and N methods, you need to think about all N states in each of the M methods, giving you an M × N code explosion. By centralizing the logic, the N states only need to be considered in one location: the manager. 305 | 306 | ### Advanced patterns with TaskPool 307 | 308 | For very advanced uses, `flowmatic.TaskPool` takes the boilerplate out of managing a pool of workers. Compare Flowmatic to [this example from x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup#example-Group-Pipeline): 309 | 310 | 311 | 312 | 328 | 329 | 330 | 331 | 332 | 397 | 472 | 473 |
313 | 314 | ```go 315 | func main() { 316 | m, err := MD5All(context.Background(), ".") 317 | if err != nil { 318 | log.Fatal(err) 319 | } 320 | 321 | for k, sum := range m { 322 | fmt.Printf("%s:\t%x\n", k, sum) 323 | } 324 | } 325 | ``` 326 | 327 |
flowmaticx/sync/errgroup
333 | 334 | 335 | ```go 336 | // MD5All reads all the files in the file tree rooted at root 337 | // and returns a map from file path to the MD5 sum of the file's contents. 338 | // If the directory walk fails or any read operation fails, 339 | // MD5All returns an error. 340 | func MD5All(ctx context.Context, root string) (map[string][md5.Size]byte, error) { 341 | // Make a pool of 20 digesters 342 | in, out := flowmatic.TaskPool(20, digest) 343 | 344 | m := make(map[string][md5.Size]byte) 345 | // Open two goroutines: 346 | // one for reading file names by walking the filesystem 347 | // one for recording results from the digesters in a map 348 | err := flowmatic.All(ctx, 349 | func(ctx context.Context) error { 350 | return walkFilesystem(ctx, root, in) 351 | }, 352 | func(ctx context.Context) error { 353 | for r := range out { 354 | if r.Err != nil { 355 | return r.Err 356 | } 357 | m[r.In] = *r.Out 358 | } 359 | return nil 360 | }, 361 | ) 362 | 363 | return m, err 364 | } 365 | 366 | func walkFilesystem(ctx context.Context, root string, in chan<- string) error { 367 | defer close(in) 368 | 369 | return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 370 | if err != nil { 371 | return err 372 | } 373 | if !info.Mode().IsRegular() { 374 | return nil 375 | } 376 | select { 377 | case in <- path: 378 | case <-ctx.Done(): 379 | return ctx.Err() 380 | } 381 | 382 | return nil 383 | }) 384 | } 385 | 386 | func digest(path string) (*[md5.Size]byte, error) { 387 | data, err := os.ReadFile(path) 388 | if err != nil { 389 | return nil, err 390 | } 391 | hash := md5.Sum(data) 392 | return &hash, nil 393 | } 394 | ``` 395 | 396 | 398 | 399 | ```go 400 | type result struct { 401 | path string 402 | sum [md5.Size]byte 403 | } 404 | 405 | // MD5All reads all the files in the file tree rooted at root and returns a map 406 | // from file path to the MD5 sum of the file's contents. If the directory walk 407 | // fails or any read operation fails, MD5All returns an error. 408 | func MD5All(ctx context.Context, root string) (map[string][md5.Size]byte, error) { 409 | // ctx is canceled when g.Wait() returns. When this version of MD5All returns 410 | // - even in case of error! - we know that all of the goroutines have finished 411 | // and the memory they were using can be garbage-collected. 412 | g, ctx := errgroup.WithContext(ctx) 413 | paths := make(chan string) 414 | 415 | g.Go(func() error { 416 | defer close(paths) 417 | return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 418 | if err != nil { 419 | return err 420 | } 421 | if !info.Mode().IsRegular() { 422 | return nil 423 | } 424 | select { 425 | case paths <- path: 426 | case <-ctx.Done(): 427 | return ctx.Err() 428 | } 429 | return nil 430 | }) 431 | }) 432 | 433 | // Start a fixed number of goroutines to read and digest files. 434 | c := make(chan result) 435 | const numDigesters = 20 436 | for i := 0; i < numDigesters; i++ { 437 | g.Go(func() error { 438 | for path := range paths { 439 | data, err := ioutil.ReadFile(path) 440 | if err != nil { 441 | return err 442 | } 443 | select { 444 | case c <- result{path, md5.Sum(data)}: 445 | case <-ctx.Done(): 446 | return ctx.Err() 447 | } 448 | } 449 | return nil 450 | }) 451 | } 452 | go func() { 453 | g.Wait() 454 | close(c) 455 | }() 456 | 457 | m := make(map[string][md5.Size]byte) 458 | for r := range c { 459 | m[r.path] = r.sum 460 | } 461 | // Check whether any of the goroutines failed. Since g is accumulating the 462 | // errors, we don't need to send them (or check for them) in the individual 463 | // results sent on the channel. 464 | if err := g.Wait(); err != nil { 465 | return nil, err 466 | } 467 | return m, nil 468 | } 469 | ``` 470 | 471 |
474 | 475 | ## Note on panicking 476 | 477 | In Go, if there is a panic in a goroutine, and that panic is not recovered, then the whole process is shutdown. There are pros and cons to this approach. The pro is that if the panic is the symptom of a programming error in the application, no further damage can be done by the application. The con is that in many cases, this leads to a shutdown in a situation that might be recoverable. 478 | 479 | As a result, although the Go standard HTTP server will catch panics that occur in one of its HTTP handlers and continue serving requests, a standard Go HTTP server cannot catch panics that occur in separate goroutines, and these will cause the whole server to go offline. 480 | 481 | Flowmatic fixes this problem by catching a panic that occurs in one of its worker goroutines and repropagating it in the parent goroutine, so the panic can be caught and logged at the appropriate level. 482 | -------------------------------------------------------------------------------- /all.go: -------------------------------------------------------------------------------- 1 | package flowmatic 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // All runs each task concurrently 8 | // and waits for them all to finish. 9 | // Each task receives a child context 10 | // which is canceled once one task returns an error or panics. 11 | // All returns nil if all tasks succeed. 12 | // Otherwise, 13 | // All returns a multierror containing the errors encountered. 14 | // If a task panics during execution, 15 | // a panic will be caught and rethrown in the parent Goroutine. 16 | func All(ctx context.Context, tasks ...func(context.Context) error) error { 17 | ctx, cancel := context.WithCancel(ctx) 18 | defer cancel() 19 | 20 | return eachN(len(tasks), len(tasks), func(pos int) error { 21 | defer func() { 22 | panicVal := recover() 23 | if panicVal != nil { 24 | cancel() 25 | panic(panicVal) 26 | } 27 | }() 28 | err := tasks[pos](ctx) 29 | if err != nil { 30 | cancel() 31 | return err 32 | } 33 | return nil 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /all_example_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/carlmjohnson/flowmatic" 9 | ) 10 | 11 | func ExampleAll() { 12 | ctx := context.Background() 13 | start := time.Now() 14 | err := flowmatic.All(ctx, 15 | func(ctx context.Context) error { 16 | // This task sleeps then returns an error 17 | d := 1 * time.Millisecond 18 | time.Sleep(d) 19 | fmt.Println("slept for", d) 20 | return fmt.Errorf("abort after %v", d) 21 | }, 22 | func(ctx context.Context) error { 23 | // sleepFor is a cancelable time.Sleep. 24 | // The error of first task 25 | // causes the early cancelation of this one. 26 | if !sleepFor(ctx, 1*time.Minute) { 27 | fmt.Println("canceled") 28 | } 29 | return nil 30 | }, 31 | ) 32 | fmt.Println("err:", err) 33 | fmt.Println("exited early?", time.Since(start) < 10*time.Millisecond) 34 | // Output: 35 | // slept for 1ms 36 | // canceled 37 | // err: abort after 1ms 38 | // exited early? true 39 | } 40 | -------------------------------------------------------------------------------- /do.go: -------------------------------------------------------------------------------- 1 | package flowmatic 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | ) 7 | 8 | // Do runs each task concurrently 9 | // and waits for them all to finish. 10 | // Errors returned by tasks do not cancel execution, 11 | // but are joined into a multierror return value. 12 | // If a task panics during execution, 13 | // a panic will be caught and rethrown in the parent Goroutine. 14 | func Do(tasks ...func() error) error { 15 | type result struct { 16 | err error 17 | panic any 18 | } 19 | 20 | var wg sync.WaitGroup 21 | errch := make(chan result, len(tasks)) 22 | 23 | wg.Add(len(tasks)) 24 | for i := range tasks { 25 | fn := tasks[i] 26 | go func() { 27 | defer wg.Done() 28 | defer func() { 29 | if panicVal := recover(); panicVal != nil { 30 | errch <- result{panic: panicVal} 31 | } 32 | }() 33 | errch <- result{err: fn()} 34 | }() 35 | } 36 | go func() { 37 | wg.Wait() 38 | close(errch) 39 | }() 40 | 41 | var ( 42 | panicVal any 43 | errs []error 44 | ) 45 | for res := range errch { 46 | switch { 47 | case res.err == nil && res.panic == nil: 48 | continue 49 | case res.panic != nil: 50 | panicVal = res.panic 51 | case res.err != nil: 52 | errs = append(errs, res.err) 53 | } 54 | } 55 | if panicVal != nil { 56 | panic(panicVal) 57 | } 58 | return errors.Join(errs...) 59 | } 60 | -------------------------------------------------------------------------------- /do_example_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/carlmjohnson/flowmatic" 8 | ) 9 | 10 | func ExampleDo() { 11 | start := time.Now() 12 | err := flowmatic.Do( 13 | func() error { 14 | time.Sleep(50 * time.Millisecond) 15 | fmt.Println("hello") 16 | return nil 17 | }, func() error { 18 | time.Sleep(100 * time.Millisecond) 19 | fmt.Println("world") 20 | return nil 21 | }, func() error { 22 | time.Sleep(200 * time.Millisecond) 23 | fmt.Println("from flowmatic.Do") 24 | return nil 25 | }) 26 | if err != nil { 27 | fmt.Println("error", err) 28 | } 29 | fmt.Println("executed concurrently?", time.Since(start) < 250*time.Millisecond) 30 | // Output: 31 | // hello 32 | // world 33 | // from flowmatic.Do 34 | // executed concurrently? true 35 | } 36 | -------------------------------------------------------------------------------- /do_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/carlmjohnson/flowmatic" 8 | ) 9 | 10 | func TestDo_err(t *testing.T) { 11 | a := errors.New("a") 12 | b := errors.New("b") 13 | errs := flowmatic.Do( 14 | func() error { return a }, 15 | func() error { return b }, 16 | ) 17 | if !errors.Is(errs, a) { 18 | t.Fatal(errs) 19 | } 20 | if !errors.Is(errs, b) { 21 | t.Fatal(errs) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package flowmatic contains easy-to-use generic helpers for structured concurrency. 2 | // 3 | // Comparison of simple helpers: 4 | // 5 | // Tasks Cancels Context? Collect results? 6 | // Do Different No No 7 | // All Different On error No 8 | // Race Different On success No 9 | // Each Same No No 10 | // Map Same On error Yes 11 | // 12 | // ManageTasks and TaskPool allow for advanced concurrency patterns. 13 | package flowmatic 14 | 15 | // MaxProcs means use GOMAXPROCS workers when doing tasks. 16 | const MaxProcs = -1 17 | -------------------------------------------------------------------------------- /each.go: -------------------------------------------------------------------------------- 1 | package flowmatic 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | // Each starts numWorkers concurrent workers (or GOMAXPROCS workers if numWorkers < 1) 8 | // and processes each item as a task. 9 | // Errors returned by a task do not halt execution, 10 | // but are joined into a multierror return value. 11 | // If a task panics during execution, 12 | // the panic will be caught and rethrown in the parent Goroutine. 13 | func Each[Input any](numWorkers int, items []Input, task func(Input) error) error { 14 | return eachN(numWorkers, len(items), func(pos int) error { 15 | return task(items[pos]) 16 | }) 17 | } 18 | 19 | // eachN starts numWorkers concurrent workers (or GOMAXPROCS workers if numWorkers < 1) 20 | // and starts a task for each number from 0 to numItems. 21 | // Errors returned by a task do not halt execution, 22 | // but are joined into a multierror return value. 23 | // If a task panics during execution, 24 | // the panic will be caught and rethrown in the parent Goroutine. 25 | func eachN(numWorkers, numItems int, task func(int) error) error { 26 | type void struct{} 27 | inch, ouch := TaskPool(numWorkers, func(pos int) (void, error) { 28 | return void{}, task(pos) 29 | }) 30 | var ( 31 | panicVal any 32 | errs []error 33 | ) 34 | _ = Do( 35 | func() error { 36 | for i := 0; i < numItems; i++ { 37 | inch <- i 38 | } 39 | close(inch) 40 | return nil 41 | }, 42 | func() error { 43 | for r := range ouch { 44 | if r.Panic != nil && panicVal == nil { 45 | panicVal = r.Panic 46 | } 47 | if r.Err != nil { 48 | errs = append(errs, r.Err) 49 | } 50 | } 51 | return nil 52 | }) 53 | if panicVal != nil { 54 | panic(panicVal) 55 | } 56 | return errors.Join(errs...) 57 | } 58 | -------------------------------------------------------------------------------- /each_example_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/carlmjohnson/flowmatic" 8 | ) 9 | 10 | func ExampleEach() { 11 | times := []time.Duration{ 12 | 50 * time.Millisecond, 13 | 100 * time.Millisecond, 14 | 200 * time.Millisecond, 15 | } 16 | start := time.Now() 17 | err := flowmatic.Each(3, times, func(d time.Duration) error { 18 | time.Sleep(d) 19 | fmt.Println("slept", d) 20 | return nil 21 | }) 22 | if err != nil { 23 | fmt.Println("error", err) 24 | } 25 | fmt.Println("executed concurrently?", time.Since(start) < 300*time.Millisecond) 26 | // Output: 27 | // slept 50ms 28 | // slept 100ms 29 | // slept 200ms 30 | // executed concurrently? true 31 | } 32 | -------------------------------------------------------------------------------- /each_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/carlmjohnson/flowmatic" 8 | ) 9 | 10 | func TestEach_err(t *testing.T) { 11 | a := errors.New("a") 12 | b := errors.New("b") 13 | errs := flowmatic.Each(1, []int{1, 2, 3}, func(i int) error { 14 | switch i { 15 | case 1: 16 | return a 17 | case 2: 18 | return b 19 | default: 20 | return nil 21 | } 22 | }) 23 | if !errors.Is(errs, a) { 24 | t.Fatal(errs) 25 | } 26 | if !errors.Is(errs, b) { 27 | t.Fatal(errs) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/carlmjohnson/flowmatic 2 | 3 | go 1.21 4 | 5 | require github.com/carlmjohnson/deque v0.23.1 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/carlmjohnson/deque v0.23.1 h1:X2HOJM9xcglY03deMZ0oZ1V2xtbqYV7dJDnZiSZN4Ak= 2 | github.com/carlmjohnson/deque v0.23.1/go.mod h1:LF5NJjICBrEOPx84pxPL4nCimy5n9NQjxKi5cXkh+8U= 3 | -------------------------------------------------------------------------------- /manage_tasks.go: -------------------------------------------------------------------------------- 1 | package flowmatic 2 | 3 | import ( 4 | "github.com/carlmjohnson/deque" 5 | ) 6 | 7 | // Manager is a function that serially examines Task results to see if it produced any new Inputs. 8 | // Returning false will halt the processing of future tasks. 9 | type Manager[Input, Output any] func(Input, Output, error) (tasks []Input, ok bool) 10 | 11 | // Task is a function that can concurrently transform an input into an output. 12 | type Task[Input, Output any] func(in Input) (out Output, err error) 13 | 14 | // ManageTasks manages tasks using numWorkers concurrent workers (or GOMAXPROCS workers if numWorkers < 1) 15 | // which produce output consumed by a serially run manager. 16 | // The manager should return a slice of new task inputs based on prior task results, 17 | // or return false to halt processing. 18 | // If a task panics during execution, 19 | // the panic will be caught and rethrown in the parent Goroutine. 20 | func ManageTasks[Input, Output any](numWorkers int, task Task[Input, Output], manager Manager[Input, Output], initial ...Input) { 21 | in, out := TaskPool(numWorkers, task) 22 | defer func() { 23 | close(in) 24 | // drain any waiting tasks 25 | for range out { 26 | } 27 | }() 28 | queue := deque.Of(initial...) 29 | inflight := 0 30 | for inflight > 0 || queue.Len() > 0 { 31 | inch := in 32 | item, ok := queue.Head() 33 | if !ok { 34 | inch = nil 35 | } 36 | select { 37 | case inch <- item: 38 | inflight++ 39 | queue.RemoveFront() 40 | case r := <-out: 41 | inflight-- 42 | if r.Panic != nil { 43 | panic(r.Panic) 44 | } 45 | items, ok := manager(r.In, r.Out, r.Err) 46 | if !ok { 47 | return 48 | } 49 | queue.PushBackSlice(items) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /manage_tasks_example_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "slices" 9 | "strings" 10 | "testing/fstest" 11 | 12 | "github.com/carlmjohnson/flowmatic" 13 | ) 14 | 15 | func ExampleManageTasks() { 16 | // Example site to crawl with recursive links 17 | srv := httptest.NewServer(http.FileServer(http.FS(fstest.MapFS{ 18 | "index.html": &fstest.MapFile{ 19 | Data: []byte("/a.html"), 20 | }, 21 | "a.html": &fstest.MapFile{ 22 | Data: []byte("/b1.html\n/b2.html"), 23 | }, 24 | "b1.html": &fstest.MapFile{ 25 | Data: []byte("/c.html"), 26 | }, 27 | "b2.html": &fstest.MapFile{ 28 | Data: []byte("/c.html"), 29 | }, 30 | "c.html": &fstest.MapFile{ 31 | Data: []byte("/"), 32 | }, 33 | }))) 34 | defer srv.Close() 35 | cl := srv.Client() 36 | 37 | // Task fetches a page and extracts the URLs 38 | task := func(u string) ([]string, error) { 39 | res, err := cl.Get(srv.URL + u) 40 | if err != nil { 41 | return nil, err 42 | } 43 | defer res.Body.Close() 44 | body, err := io.ReadAll(res.Body) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | return strings.Split(string(body), "\n"), nil 50 | } 51 | 52 | // Manager keeps track of which pages have been visited and the results graph 53 | tried := map[string]int{} 54 | results := map[string][]string{} 55 | manager := func(req string, urls []string, err error) ([]string, bool) { 56 | if err != nil { 57 | // If there's a problem fetching a page, try three times 58 | if tried[req] < 3 { 59 | tried[req]++ 60 | return []string{req}, true 61 | } 62 | return nil, false 63 | } 64 | results[req] = urls 65 | var newurls []string 66 | for _, u := range urls { 67 | if tried[u] == 0 { 68 | newurls = append(newurls, u) 69 | tried[u]++ 70 | } 71 | } 72 | return newurls, true 73 | } 74 | 75 | // Process the tasks with as many workers as GOMAXPROCS 76 | flowmatic.ManageTasks(flowmatic.MaxProcs, task, manager, "/") 77 | 78 | keys := make([]string, 0, len(results)) 79 | for key := range results { 80 | keys = append(keys, key) 81 | } 82 | slices.Sort(keys) 83 | for _, key := range keys { 84 | fmt.Println(key, "links to:") 85 | for _, v := range results[key] { 86 | fmt.Println("- ", v) 87 | } 88 | } 89 | 90 | // Output: 91 | // / links to: 92 | // - /a.html 93 | // /a.html links to: 94 | // - /b1.html 95 | // - /b2.html 96 | // /b1.html links to: 97 | // - /c.html 98 | // /b2.html links to: 99 | // - /c.html 100 | // /c.html links to: 101 | // - / 102 | } 103 | -------------------------------------------------------------------------------- /manage_tasks_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/carlmjohnson/flowmatic" 10 | ) 11 | 12 | func TestManageTasks_drainage(t *testing.T) { 13 | const sleepTime = 10 * time.Millisecond 14 | b := false 15 | task := func(n int) (int, error) { 16 | if n == 1 { 17 | return 0, errors.New("text string") 18 | } 19 | time.Sleep(sleepTime) 20 | b = true 21 | return 0, nil 22 | } 23 | start := time.Now() 24 | m := map[int]struct { 25 | int 26 | error 27 | }{} 28 | manager := func(in, out int, err error) ([]int, bool) { 29 | m[in] = struct { 30 | int 31 | error 32 | }{out, err} 33 | if err != nil { 34 | return nil, false 35 | } 36 | return nil, true 37 | } 38 | flowmatic.ManageTasks(5, task, manager, 0, 1) 39 | if s := fmt.Sprint(m); s != "map[1:text string]" { 40 | t.Fatal(s) 41 | } 42 | if time.Since(start) < sleepTime { 43 | t.Fatal("didn't sleep enough") 44 | } 45 | if !b { 46 | t.Fatal("didn't finish") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /map.go: -------------------------------------------------------------------------------- 1 | package flowmatic 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Map starts numWorkers concurrent workers (or GOMAXPROCS workers if numWorkers < 1) 8 | // and attempts to map the input slice to an output slice. 9 | // Each task receives a child context. 10 | // The first error or panic returned by a task 11 | // cancels the child context 12 | // and halts further task scheduling. 13 | // If a task panics during execution, 14 | // the panic will be caught and rethrown in the parent Goroutine. 15 | func Map[Input, Output any](ctx context.Context, numWorkers int, items []Input, task func(context.Context, Input) (Output, error)) (results []Output, err error) { 16 | ctx, cancel := context.WithCancel(ctx) 17 | defer cancel() 18 | 19 | inch, ouch := TaskPool(numWorkers, func(pos int) (Output, error) { 20 | item := items[pos] 21 | return task(ctx, item) 22 | }) 23 | 24 | var panicVal any 25 | n := 0 26 | closeinch := false 27 | results = make([]Output, len(items)) 28 | 29 | for { 30 | if n >= len(items) { 31 | closeinch = true 32 | } 33 | if closeinch && inch != nil { 34 | close(inch) 35 | inch = nil 36 | } 37 | select { 38 | case inch <- n: 39 | n++ 40 | case r, ok := <-ouch: 41 | if !ok { 42 | if panicVal != nil { 43 | panic(panicVal) 44 | } 45 | if err != nil { 46 | return nil, err 47 | } 48 | return results, nil 49 | } 50 | if r.Err != nil && err == nil { 51 | cancel() 52 | closeinch = true 53 | err = r.Err 54 | } 55 | if r.Panic != nil && panicVal == nil { 56 | cancel() 57 | closeinch = true 58 | panicVal = r.Panic 59 | } 60 | results[r.In] = r.Out 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /map_example_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/carlmjohnson/flowmatic" 10 | ) 11 | 12 | var ( 13 | Web = fakeSearch("web") 14 | Image = fakeSearch("image") 15 | Video = fakeSearch("video") 16 | ) 17 | 18 | type Result string 19 | type Search func(ctx context.Context, query string) (Result, error) 20 | 21 | func fakeSearch(kind string) Search { 22 | return func(_ context.Context, query string) (Result, error) { 23 | return Result(fmt.Sprintf("%s result for %q", kind, query)), nil 24 | } 25 | } 26 | 27 | func Google(ctx context.Context, query string) ([]Result, error) { 28 | searches := []Search{Web, Image, Video} 29 | return flowmatic.Map(ctx, flowmatic.MaxProcs, searches, 30 | func(ctx context.Context, search Search) (Result, error) { 31 | return search(ctx, query) 32 | }) 33 | } 34 | 35 | func ExampleMap() { 36 | // Compare to https://pkg.go.dev/golang.org/x/sync/errgroup#example-Group-Parallel 37 | // and https://pkg.go.dev/sync#example-WaitGroup 38 | results, err := Google(context.Background(), "golang") 39 | if err != nil { 40 | fmt.Fprintln(os.Stderr, err) 41 | return 42 | } 43 | 44 | for _, result := range results { 45 | fmt.Println(result) 46 | } 47 | 48 | // Output: 49 | // web result for "golang" 50 | // image result for "golang" 51 | // video result for "golang" 52 | } 53 | 54 | func ExampleMap_simple() { 55 | ctx := context.Background() 56 | 57 | // Start with some slice of input work 58 | input := []string{"0", "1", "42", "1337"} 59 | // Have a task that takes a context 60 | decodeAndDouble := func(ctx context.Context, s string) (int, error) { 61 | // Do some work 62 | n, err := strconv.Atoi(s) 63 | if err != nil { 64 | return 0, err 65 | } 66 | // Return early if context was canceled 67 | if ctx.Err() != nil { 68 | return 0, ctx.Err() 69 | } 70 | // Do more work 71 | return 2 * n, nil 72 | } 73 | // Concurrently process input into output 74 | output, err := flowmatic.Map(ctx, flowmatic.MaxProcs, input, decodeAndDouble) 75 | if err != nil { 76 | fmt.Println(err) 77 | } 78 | fmt.Println(output) 79 | // Output: 80 | // [0 2 84 2674] 81 | } 82 | -------------------------------------------------------------------------------- /map_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/carlmjohnson/flowmatic" 9 | ) 10 | 11 | func TestMap(t *testing.T) { 12 | ctx := context.Background() 13 | a := errors.New("a") 14 | b := errors.New("b") 15 | o, errs := flowmatic.Map(ctx, 1, []int{1, 2, 3}, func(_ context.Context, i int) (int, error) { 16 | switch i { 17 | case 1: 18 | return 1, a 19 | case 2: 20 | return 2, b 21 | default: 22 | panic("should be canceled by now!") 23 | } 24 | }) 25 | if !errors.Is(errs, a) { 26 | t.Fatal(errs) 27 | } 28 | if errors.Is(errs, b) { 29 | t.Fatal(errs) 30 | } 31 | if o != nil { 32 | t.Fatal(o) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /panic_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync/atomic" 7 | "testing" 8 | 9 | "github.com/carlmjohnson/flowmatic" 10 | ) 11 | 12 | func try(f func()) (r any) { 13 | defer func() { 14 | r = recover() 15 | }() 16 | f() 17 | return 18 | } 19 | 20 | func TestManageTasks_panic(t *testing.T) { 21 | task := func(n int) (int, error) { 22 | if n == 3 { 23 | panic("3!!") 24 | } 25 | return n * 3, nil 26 | } 27 | var triples []int 28 | manager := func(n, triple int, err error) ([]int, bool) { 29 | triples = append(triples, triple) 30 | return nil, true 31 | } 32 | r := try(func() { 33 | flowmatic.ManageTasks(1, task, manager, 1, 2, 3, 4) 34 | }) 35 | if r == nil { 36 | t.Fatal("should have panicked") 37 | } 38 | if r != "3!!" { 39 | t.Fatal(r) 40 | } 41 | if fmt.Sprint(triples) != "[3 6]" { 42 | t.Fatal(triples) 43 | } 44 | } 45 | 46 | func TestEach_panic(t *testing.T) { 47 | var ( 48 | n atomic.Int64 49 | err error 50 | ) 51 | r := try(func() { 52 | err = flowmatic.Each(1, []int64{1, 2, 3}, 53 | func(delta int64) error { 54 | if delta == 2 { 55 | panic("boom") 56 | } 57 | n.Add(delta) 58 | return nil 59 | }) 60 | }) 61 | if err != nil { 62 | t.Fatal("should have panicked") 63 | } 64 | if r == nil { 65 | t.Fatal("should have panicked") 66 | } 67 | if r != "boom" { 68 | t.Fatal(r) 69 | } 70 | if n.Load() != 4 { 71 | t.Fatal(n.Load()) 72 | } 73 | } 74 | 75 | func TestDo_panic(t *testing.T) { 76 | var ( 77 | n atomic.Int64 78 | err error 79 | ) 80 | r := try(func() { 81 | err = flowmatic.Do( 82 | func() error { 83 | n.Add(1) 84 | return nil 85 | }, 86 | func() error { 87 | panic("boom") 88 | }, 89 | func() error { 90 | n.Add(1) 91 | return nil 92 | }) 93 | }) 94 | if err != nil { 95 | t.Fatal("should have panicked") 96 | } 97 | if r == nil { 98 | t.Fatal("should have panicked") 99 | } 100 | if r != "boom" { 101 | t.Fatal(r) 102 | } 103 | if n.Load() != 2 { 104 | t.Fatal(n.Load()) 105 | } 106 | } 107 | 108 | func TestRace_panic(t *testing.T) { 109 | var ( 110 | n atomic.Int64 111 | err error 112 | ) 113 | r := try(func() { 114 | err = flowmatic.Race(context.Background(), 115 | func(context.Context) error { 116 | n.Add(1) 117 | return nil 118 | }, 119 | func(context.Context) error { 120 | panic("boom") 121 | }, 122 | func(context.Context) error { 123 | n.Add(1) 124 | return nil 125 | }) 126 | }) 127 | if err != nil { 128 | t.Fatal("should have panicked") 129 | } 130 | if r == nil { 131 | t.Fatal("should have panicked") 132 | } 133 | if r != "boom" { 134 | t.Fatal(r) 135 | } 136 | if n.Load() != 2 { 137 | t.Fatal(n.Load()) 138 | } 139 | } 140 | 141 | func TestAll_panic(t *testing.T) { 142 | var ( 143 | n atomic.Int64 144 | err error 145 | ) 146 | r := try(func() { 147 | err = flowmatic.All(context.Background(), 148 | func(context.Context) error { 149 | n.Add(1) 150 | return nil 151 | }, 152 | func(context.Context) error { 153 | panic("boom") 154 | }, 155 | func(context.Context) error { 156 | n.Add(1) 157 | return nil 158 | }) 159 | }) 160 | if err != nil { 161 | t.Fatal("should have panicked") 162 | } 163 | if r == nil { 164 | t.Fatal("should have panicked") 165 | } 166 | if r != "boom" { 167 | t.Fatal(r) 168 | } 169 | if n.Load() != 2 { 170 | t.Fatal(n.Load()) 171 | } 172 | } 173 | 174 | func TestMap_panic(t *testing.T) { 175 | var ( 176 | err error 177 | o []int64 178 | ) 179 | ctx := context.Background() 180 | r := try(func() { 181 | o, err = flowmatic.Map(ctx, 1, []int64{1, 2, 3}, 182 | func(_ context.Context, delta int64) (int64, error) { 183 | if delta == 2 { 184 | panic("boom") 185 | } 186 | return 2 * delta, nil 187 | }) 188 | }) 189 | if err != nil { 190 | t.Fatal("should have panicked") 191 | } 192 | if r == nil { 193 | t.Fatal("should have panicked") 194 | } 195 | if r != "boom" { 196 | t.Fatal(r) 197 | } 198 | if o != nil { 199 | t.Fatal(o) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /race.go: -------------------------------------------------------------------------------- 1 | package flowmatic 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync/atomic" 7 | ) 8 | 9 | // Race runs each task concurrently 10 | // and waits for them all to finish. 11 | // Each function receives a child context 12 | // which is canceled once one function has successfully completed or panicked. 13 | // Race returns nil 14 | // if at least one function completes without an error. 15 | // If all functions return an error, 16 | // Race returns a multierror containing all the errors. 17 | // If a function panics during execution, 18 | // a panic will be caught and rethrown in the parent Goroutine. 19 | func Race(ctx context.Context, tasks ...func(context.Context) error) error { 20 | ctx, cancel := context.WithCancel(ctx) 21 | defer cancel() 22 | errs := make([]error, len(tasks)) 23 | var success atomic.Bool 24 | _ = eachN(len(tasks), len(tasks), func(pos int) error { 25 | defer func() { 26 | panicVal := recover() 27 | if panicVal != nil { 28 | cancel() 29 | panic(panicVal) 30 | } 31 | }() 32 | err := tasks[pos](ctx) 33 | if err != nil { 34 | errs[pos] = err 35 | return nil 36 | } 37 | cancel() 38 | success.Store(true) 39 | return nil 40 | }) 41 | if success.Load() { 42 | return nil 43 | } 44 | return errors.Join(errs...) 45 | } 46 | -------------------------------------------------------------------------------- /race_example_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/carlmjohnson/flowmatic" 10 | ) 11 | 12 | func ExampleRace() { 13 | ctx := context.Background() 14 | start := time.Now() 15 | err := flowmatic.Race(ctx, 16 | func(ctx context.Context) error { 17 | // This task sleeps for only 1ms 18 | d := 1 * time.Millisecond 19 | time.Sleep(d) 20 | fmt.Println("slept for", d) 21 | return nil 22 | }, 23 | func(ctx context.Context) error { 24 | // This task wants to sleep for a whole minute. 25 | d := 1 * time.Minute 26 | // But sleepFor is a cancelable time.Sleep. 27 | // So when the other task completes, 28 | // it cancels this one, causing it to return early. 29 | if !sleepFor(ctx, d) { 30 | fmt.Println("canceled") 31 | } 32 | // The error here is ignored 33 | // because the other task succeeded 34 | return errors.New("ignored") 35 | }, 36 | ) 37 | // Err is nil as long as one task succeeds 38 | fmt.Println("err:", err) 39 | fmt.Println("exited early?", time.Since(start) < 10*time.Millisecond) 40 | // Output: 41 | // slept for 1ms 42 | // canceled 43 | // err: 44 | // exited early? true 45 | } 46 | 47 | func ExampleRace_fakeRequest() { 48 | // Setup fake requests 49 | request := func(ctx context.Context, page string) (string, error) { 50 | var sleepLength time.Duration 51 | switch page { 52 | case "A": 53 | sleepLength = 10 * time.Millisecond 54 | case "B": 55 | sleepLength = 100 * time.Millisecond 56 | case "C": 57 | sleepLength = 10 * time.Second 58 | } 59 | if !sleepFor(ctx, sleepLength) { 60 | return "", ctx.Err() 61 | } 62 | return "got " + page, nil 63 | } 64 | ctx := context.Background() 65 | // Make variables to hold responses 66 | var pageA, pageB, pageC string 67 | // Race the requests to see who can answer first 68 | err := flowmatic.Race(ctx, 69 | func(ctx context.Context) error { 70 | var err error 71 | pageA, err = request(ctx, "A") 72 | return err 73 | }, 74 | func(ctx context.Context) error { 75 | var err error 76 | pageB, err = request(ctx, "B") 77 | return err 78 | }, 79 | func(ctx context.Context) error { 80 | var err error 81 | pageC, err = request(ctx, "C") 82 | return err 83 | }, 84 | ) 85 | fmt.Println("err:", err) 86 | fmt.Printf("A: %q B: %q C: %q\n", pageA, pageB, pageC) 87 | // Output: 88 | // err: 89 | // A: "got A" B: "" C: "" 90 | } 91 | -------------------------------------------------------------------------------- /race_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/carlmjohnson/flowmatic" 10 | ) 11 | 12 | func sleepFor(ctx context.Context, d time.Duration) bool { 13 | timer := time.NewTimer(d) 14 | defer timer.Stop() 15 | select { 16 | case <-timer.C: 17 | return true 18 | case <-ctx.Done(): 19 | return false 20 | } 21 | } 22 | 23 | func TestRace_join_errs(t *testing.T) { 24 | var ( 25 | a = errors.New("a") 26 | b = errors.New("b") 27 | ) 28 | 29 | err := flowmatic.Race(context.Background(), 30 | func(ctx context.Context) error { 31 | if !sleepFor(ctx, 10*time.Millisecond) { 32 | return ctx.Err() 33 | } 34 | return a 35 | }, 36 | func(ctx context.Context) error { 37 | if !sleepFor(ctx, 30*time.Millisecond) { 38 | return ctx.Err() 39 | } 40 | return b 41 | }, 42 | ) 43 | if !errors.Is(err, a) || !errors.Is(err, b) { 44 | t.Fatal(err) 45 | } 46 | if errors.Is(err, context.Canceled) { 47 | t.Fatal(err) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /taskpool.go: -------------------------------------------------------------------------------- 1 | package flowmatic 2 | 3 | import ( 4 | "runtime" 5 | "sync" 6 | ) 7 | 8 | // Result is the type returned by the output channel of TaskPool. 9 | type Result[Input, Output any] struct { 10 | In Input 11 | Out Output 12 | Err error 13 | Panic any 14 | } 15 | 16 | // TaskPool starts numWorkers workers (or GOMAXPROCS workers if numWorkers < 1) which consume 17 | // the in channel, execute task, and send the Result on the out channel. 18 | // Callers should close the in channel to stop the workers from waiting for tasks. 19 | // The out channel will be closed once the last result has been sent. 20 | func TaskPool[Input, Output any](numWorkers int, task Task[Input, Output]) (in chan<- Input, out <-chan Result[Input, Output]) { 21 | if numWorkers < 1 { 22 | numWorkers = runtime.GOMAXPROCS(0) 23 | } 24 | inch := make(chan Input) 25 | ouch := make(chan Result[Input, Output], numWorkers) 26 | var wg sync.WaitGroup 27 | wg.Add(numWorkers) 28 | for i := 0; i < numWorkers; i++ { 29 | go func() { 30 | defer wg.Done() 31 | for inval := range inch { 32 | func() { 33 | defer func() { 34 | pval := recover() 35 | if pval == nil { 36 | return 37 | } 38 | ouch <- Result[Input, Output]{ 39 | In: inval, 40 | Panic: pval, 41 | } 42 | }() 43 | 44 | outval, err := task(inval) 45 | ouch <- Result[Input, Output]{inval, outval, err, nil} 46 | }() 47 | } 48 | }() 49 | } 50 | go func() { 51 | wg.Wait() 52 | close(ouch) 53 | }() 54 | return inch, ouch 55 | } 56 | -------------------------------------------------------------------------------- /taskpool_example_test.go: -------------------------------------------------------------------------------- 1 | package flowmatic_test 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/carlmjohnson/flowmatic" 12 | ) 13 | 14 | func ExampleTaskPool() { 15 | // Compare to https://pkg.go.dev/golang.org/x/sync/errgroup#example-Group-Pipeline and https://blog.golang.org/pipelines 16 | 17 | m, err := MD5All(context.Background(), "testdata/md5all") 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | for k, sum := range m { 23 | fmt.Printf("%s:\t%x\n", k, sum) 24 | } 25 | 26 | // Output: 27 | // testdata/md5all/hello.txt: bea8252ff4e80f41719ea13cdf007273 28 | } 29 | 30 | // MD5All reads all the files in the file tree rooted at root 31 | // and returns a map from file path to the MD5 sum of the file's contents. 32 | // If the directory walk fails or any read operation fails, 33 | // MD5All returns an error. 34 | func MD5All(ctx context.Context, root string) (map[string][md5.Size]byte, error) { 35 | // Make a pool of 20 digesters 36 | in, out := flowmatic.TaskPool(20, digest) 37 | 38 | m := make(map[string][md5.Size]byte) 39 | // Open two goroutines: 40 | // one for reading file names by walking the filesystem 41 | // one for recording results from the digesters in a map 42 | err := flowmatic.All(ctx, 43 | func(ctx context.Context) error { 44 | return walkFilesystem(ctx, root, in) 45 | }, 46 | func(ctx context.Context) error { 47 | for r := range out { 48 | if r.Err != nil { 49 | return r.Err 50 | } 51 | m[r.In] = *r.Out 52 | } 53 | return nil 54 | }, 55 | ) 56 | 57 | return m, err 58 | } 59 | 60 | func walkFilesystem(ctx context.Context, root string, in chan<- string) error { 61 | defer close(in) 62 | 63 | return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 64 | if err != nil { 65 | return err 66 | } 67 | if !info.Mode().IsRegular() { 68 | return nil 69 | } 70 | select { 71 | case in <- path: 72 | case <-ctx.Done(): 73 | return ctx.Err() 74 | } 75 | 76 | return nil 77 | }) 78 | } 79 | 80 | func digest(path string) (*[md5.Size]byte, error) { 81 | data, err := os.ReadFile(path) 82 | if err != nil { 83 | return nil, err 84 | } 85 | hash := md5.Sum(data) 86 | return &hash, nil 87 | } 88 | -------------------------------------------------------------------------------- /testdata/md5all/hello.txt: -------------------------------------------------------------------------------- 1 | Hello, World! 2 | --------------------------------------------------------------------------------