├── go.mod ├── lineworker_example_test.go ├── README.md ├── LICENSE ├── lineworker_test.go └── lineworker.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/codesoap/lineworker 2 | 3 | go 1.22.1 4 | -------------------------------------------------------------------------------- /lineworker_example_test.go: -------------------------------------------------------------------------------- 1 | package lineworker_test 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "runtime" 7 | "time" 8 | 9 | "github.com/codesoap/lineworker" 10 | ) 11 | 12 | func ExampleWorkerPool() { 13 | slowSprint := func(a int) (string, error) { 14 | delay := rand.Int() 15 | time.Sleep(time.Duration(delay%6) * time.Millisecond) 16 | return fmt.Sprint(a), nil 17 | } 18 | pool := lineworker.NewWorkerPool(runtime.NumCPU(), slowSprint) 19 | go func() { 20 | for i := 0; i < 10; i++ { 21 | workAccepted := pool.Process(i) 22 | if !workAccepted { 23 | // Cannot happen in this example, because pool.Stop is not called 24 | // outside this goroutine, but is handled for demonstration 25 | // purposes. 26 | return 27 | } 28 | } 29 | pool.Stop() 30 | }() 31 | for { 32 | res, err := pool.Next() 33 | if err == lineworker.EOS { 34 | break 35 | } else if err != nil { 36 | panic(err) 37 | } 38 | fmt.Println(res) 39 | } 40 | // Output: 41 | // 0 42 | // 1 43 | // 2 44 | // 3 45 | // 4 46 | // 5 47 | // 6 48 | // 7 49 | // 8 50 | // 9 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | lineworker provides worker pools that perform work in parallel, but 2 | output the work results in the order the work was given. 3 | 4 | Take a look at the documentation for more info: https://godocs.io/github.com/codesoap/lineworker 5 | 6 | # Example 7 | ```go 8 | slowSprint := func(a int) (string, error) { 9 | delay := rand.Int() 10 | time.Sleep(time.Duration(delay%6) * time.Millisecond) 11 | return fmt.Sprint(a), nil 12 | } 13 | 14 | // Start the worker goroutines: 15 | pool := lineworker.NewWorkerPool(runtime.NumCPU(), slowSprint) 16 | 17 | // Put in work: 18 | go func() { 19 | for i := 0; i < 10; i++ { 20 | workAccepted := pool.Process(i) 21 | if !workAccepted { 22 | // Cannot happen in this example, because pool.Stop is not called 23 | // outside this goroutine, but is handled for demonstration 24 | // purposes. 25 | return 26 | } 27 | } 28 | pool.Stop() 29 | }() 30 | 31 | // Retrieve the results: 32 | for { 33 | res, err := pool.Next() 34 | if err == lineworker.EOS { 35 | break 36 | } else if err != nil { 37 | panic(err) 38 | } 39 | fmt.Println(res) 40 | } 41 | ``` 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Richard Ulmer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /lineworker_test.go: -------------------------------------------------------------------------------- 1 | package lineworker_test 2 | 3 | import ( 4 | "sync/atomic" 5 | "testing" 6 | "time" 7 | 8 | "github.com/codesoap/lineworker" 9 | ) 10 | 11 | // TestStop tests if an external stopping of the worker pool works 12 | // without problems. 13 | func TestStop(t *testing.T) { 14 | pool := lineworker.NewWorkerPool(2, func(a any) (any, error) { return 0, nil }) 15 | stoppedProcessing := &atomic.Bool{} 16 | go func() { 17 | for { 18 | if ok := pool.Process(0); !ok { 19 | break 20 | } 21 | } 22 | stoppedProcessing.Store(true) 23 | }() 24 | for i := 0; i < 10; i++ { 25 | _, err := pool.Next() 26 | if err != nil { 27 | t.Error("Next returned error:", err) 28 | break 29 | } 30 | } 31 | pool.Stop() 32 | pool.DiscardWork() 33 | for i := 0; i < 10; i++ { 34 | if stoppedProcessing.Load() { 35 | return 36 | } 37 | time.Sleep(10 * time.Millisecond) 38 | } 39 | t.Error("Processing did not stop after call to Stop.") 40 | } 41 | 42 | // TestStopTwice tests if stopping a worker pool twice works. 43 | func TestStopTwice(t *testing.T) { 44 | pool := lineworker.NewWorkerPool(1, func(a any) (any, error) { return 42, nil }) 45 | if ok := pool.Process(0); !ok { 46 | t.Error("could not process work") 47 | } 48 | pool.Stop() 49 | pool.Stop() 50 | res, err := pool.Next() 51 | if err != nil { 52 | t.Errorf("unexpected error when retrieving result: %v", err) 53 | } 54 | if res != 42 { 55 | t.Errorf("got wrong result (expected 42): %d", res) 56 | } 57 | _, err = pool.Next() 58 | if err != lineworker.EOS { 59 | t.Error("expected EOS, but did not occur") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lineworker.go: -------------------------------------------------------------------------------- 1 | // package lineworker provides a worker pool with a fixed amount of 2 | // workers. It outputs work results in the order the work was given. The 3 | // package is designed for serial data input and output; the functions 4 | // Process and Next must never be called in parallel. 5 | // 6 | // Each worker caches at most one result, so that no new work is 7 | // processed, if as many results are waiting to be consumed as there are 8 | // workers. 9 | package lineworker 10 | 11 | import ( 12 | "fmt" 13 | "sync" 14 | ) 15 | 16 | // EOS is the error returned by Next when no more results are available. 17 | var EOS = fmt.Errorf("no more results available") 18 | 19 | type WorkFunc[IN, OUT any] func(in IN) (OUT, error) 20 | 21 | type WorkerPool[IN, OUT any] struct { 22 | workFunc WorkFunc[IN, OUT] 23 | 24 | processCalls int 25 | work []chan IN 26 | workLock sync.Mutex 27 | 28 | nextCalls int 29 | out []chan workResult[OUT] 30 | 31 | stop chan bool 32 | stopLock sync.Mutex 33 | } 34 | 35 | // NewWorkerPool creates a new worker pool with workerCount workers 36 | // waiting to process data of type IN to results of type OUT via f. 37 | func NewWorkerPool[IN, OUT any](workerCount int, f WorkFunc[IN, OUT]) *WorkerPool[IN, OUT] { 38 | pool := WorkerPool[IN, OUT]{ 39 | workFunc: f, 40 | 41 | work: make([]chan IN, workerCount), 42 | workLock: sync.Mutex{}, 43 | out: make([]chan workResult[OUT], workerCount), 44 | 45 | stop: make(chan bool), 46 | stopLock: sync.Mutex{}, 47 | } 48 | for i := 0; i < workerCount; i++ { 49 | pool.work[i] = make(chan IN) 50 | pool.out[i] = make(chan workResult[OUT]) 51 | go func() { 52 | for { 53 | if w, ok := <-pool.work[i]; ok { 54 | out, err := pool.workFunc(w) 55 | pool.out[i] <- workResult[OUT]{result: out, err: err} 56 | } else { 57 | close(pool.out[i]) 58 | return 59 | } 60 | } 61 | }() 62 | } 63 | return &pool 64 | } 65 | 66 | // Process queues a new input for processing. If all workers are 67 | // currently busy, Process will block. 68 | // 69 | // Process will return true if the input has been accepted. If Stop has 70 | // been called previously, Process will discard the given input and 71 | // return false. 72 | func (w *WorkerPool[IN, OUT]) Process(input IN) bool { 73 | w.workLock.Lock() 74 | defer w.workLock.Unlock() 75 | select { 76 | case <-w.stop: 77 | return false 78 | default: 79 | } 80 | select { 81 | case w.work[w.processCalls%len(w.work)] <- input: 82 | w.processCalls++ 83 | case <-w.stop: 84 | return false 85 | } 86 | return true 87 | } 88 | 89 | // Next will return the next result with its error. If the next result 90 | // is not yet ready, it will block. If no more results are available, 91 | // the EOS error will be returned. 92 | func (w *WorkerPool[IN, OUT]) Next() (OUT, error) { 93 | res, ok := <-w.out[w.nextCalls%len(w.out)] 94 | if !ok { 95 | return *new(OUT), EOS 96 | } 97 | w.nextCalls++ 98 | return res.result, res.err 99 | } 100 | 101 | // Stop should be called after all calls to Process have been made. It 102 | // stops the workers from accepting new work and allows their resources 103 | // to be released after all results have been consumed via Next or 104 | // discarded with DiscardWork. 105 | // 106 | // Further calls to Stop after the first call will do nothing. 107 | func (w *WorkerPool[IN, OUT]) Stop() { 108 | w.stopLock.Lock() 109 | defer w.stopLock.Unlock() 110 | select { 111 | case <-w.stop: 112 | default: 113 | close(w.stop) 114 | w.workLock.Lock() 115 | defer w.workLock.Unlock() 116 | for _, work := range w.work { 117 | close(work) 118 | } 119 | } 120 | } 121 | 122 | // DiscardWork receives and discards all pending work results, so that 123 | // workers can quit after Stop has been called. It will block until all 124 | // workers have quit. 125 | // 126 | // DiscardWork must only be called after Stop has been called. 127 | func (w *WorkerPool[IN, OUT]) DiscardWork() { 128 | for _, out := range w.out { 129 | for { 130 | if _, ok := <-out; !ok { 131 | break 132 | } 133 | } 134 | } 135 | } 136 | 137 | type workResult[OUT any] struct { 138 | result OUT 139 | err error 140 | } 141 | --------------------------------------------------------------------------------