├── .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 [](https://pkg.go.dev/github.com/carlmjohnson/flowmatic) [](https://coveralls.io/github/carlmjohnson/flowmatic) [](https://goreportcard.com/report/github.com/carlmjohnson/flowmatic) [](https://github.com/avelino/awesome-go)
2 |
3 | 
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 | flowmatic |
32 | stdlib |
33 |
34 |
35 |
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 | |
51 |
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 | |
91 |
92 |
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 | flowmatic |
128 | stdlib |
129 |
130 |
131 |
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 | |
144 |
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 | |
179 |
180 |
181 |
182 | Use `flowmatic.Map` to map an input slice to an output slice.
183 |
184 |
185 |
186 |
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 | |
202 |
203 | flowmatic |
204 | x/sync/errgroup |
205 |
206 |
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 | |
220 |
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 | |
246 |
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 |
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 | |
328 |
329 | flowmatic |
330 | x/sync/errgroup |
331 |
332 |
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 | |
397 |
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 | |
472 |
473 |
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 |
--------------------------------------------------------------------------------