├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── tests.yml ├── .gitignore ├── .gitpod.yml ├── LICENSE ├── README.md ├── go.mod ├── heap.go ├── main.go └── main_test.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ '**' ] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.15 20 | 21 | - name: Test 22 | run: go test -race -coverprofile=coverage.txt -covermode=atomic -v ./... 23 | 24 | - name : Codecov 25 | uses: codecov/codecov-action@v1 26 | with: 27 | token: ${{ secrets.CODECOV_TOKEN }} 28 | files: ./coverage.txt 29 | flags: unittests 30 | fail_ci_if_error: true 31 | verbose: true 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | coverage.txt -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - init: go get && go build ./... && go test ./... 3 | command: go run 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Tejus Pratap 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Tests 2 | [![codecov](https://codecov.io/gh/tejzpr/ordered-concurrently/branch/master/graph/badge.svg?token=6WIXWRO3EW)](https://codecov.io/gh/tejzpr/ordered-concurrently) 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/tejzpr/ordered-concurrently.svg)](https://pkg.go.dev/github.com/tejzpr/ordered-concurrently) 4 | [![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/tejzpr/ordered-concurrently) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/tejzpr/ordered-concurrently)](https://goreportcard.com/report/github.com/tejzpr/ordered-concurrently) 6 | 7 | # Ordered Concurrently 8 | A library for parallel processing with ordered output in Go. This module processes work concurrently / in parallel and returns output in a channel in the order of input. It is useful in concurrently / parallelly processing items in a queue, and get output in the order provided by the queue. 9 | 10 | # Usage 11 | ## Get Module 12 | ```go 13 | go get github.com/tejzpr/ordered-concurrently/v3 14 | ``` 15 | ## Import Module in your source code 16 | ```go 17 | import concurrently "github.com/tejzpr/ordered-concurrently/v3" 18 | ``` 19 | ## Create a work function by implementing WorkFunction interface 20 | ```go 21 | // Create a type based on your input to the work function 22 | type loadWorker int 23 | 24 | // The work that needs to be performed 25 | // The input type should implement the WorkFunction interface 26 | func (w loadWorker) Run(ctx context.Context) interface{} { 27 | time.Sleep(time.Millisecond * time.Duration(rand.Intn(100))) 28 | return w * 2 29 | } 30 | ``` 31 | ## Demo 32 | [Go Playground](https://go.dev/play/p/60b_x0YHzYu) 33 | 34 | ## Run 35 | ### Example - 1 36 | ```go 37 | func main() { 38 | max := 10 39 | inputChan := make(chan concurrently.WorkFunction) 40 | ctx := context.Background() 41 | output := concurrently.Process(ctx, inputChan, &concurrently.Options{PoolSize: 10, OutChannelBuffer: 10}) 42 | go func() { 43 | for work := 0; work < max; work++ { 44 | inputChan <- loadWorker(work) 45 | } 46 | close(inputChan) 47 | }() 48 | for out := range output { 49 | log.Println(out.Value) 50 | } 51 | } 52 | ``` 53 | ### Example - 2 - Process unknown number of inputs 54 | ```go 55 | func main() { 56 | inputChan := make(chan concurrently.WorkFunction, 10) 57 | ctx := context.Background() 58 | output := concurrently.Process(ctx, inputChan, &concurrently.Options{PoolSize: 10, OutChannelBuffer: 10}) 59 | 60 | ticker := time.NewTicker(100 * time.Millisecond) 61 | done := make(chan bool) 62 | wg := &sync.WaitGroup{} 63 | go func() { 64 | input := 0 65 | for { 66 | select { 67 | case <-done: 68 | return 69 | case <-ticker.C: 70 | inputChan <- loadWorker(input) 71 | wg.Add(1) 72 | input++ 73 | default: 74 | } 75 | } 76 | }() 77 | 78 | var res []loadWorker 79 | go func() { 80 | for out := range output { 81 | res = append(res, out.Value.(loadWorker)) 82 | wg.Done() 83 | } 84 | }() 85 | 86 | time.Sleep(1600 * time.Millisecond) 87 | ticker.Stop() 88 | done <- true 89 | close(inputChan) 90 | wg.Wait() 91 | 92 | // Check if output is sorted 93 | isSorted := sort.SliceIsSorted(res, func(i, j int) bool { 94 | return res[i] < res[j] 95 | }) 96 | if !isSorted { 97 | log.Println("output is not sorted") 98 | } 99 | } 100 | ``` 101 | # Credits 102 | 1. [u/justinisrael](https://www.reddit.com/user/justinisrael/) for inputs on improving resource usage. 103 | 2. [mh-cbon](https://github.com/mh-cbon) for identifying potential [deadlocks](https://github.com/tejzpr/ordered-concurrently/issues/2). 104 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tejzpr/ordered-concurrently/v3 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /heap.go: -------------------------------------------------------------------------------- 1 | package orderedconcurrently 2 | 3 | type processInput struct { 4 | workFn WorkFunction 5 | order uint64 6 | value interface{} 7 | } 8 | 9 | type processInputHeap []*processInput 10 | 11 | func (h processInputHeap) Len() int { 12 | return len(h) 13 | } 14 | 15 | func (h processInputHeap) Less(i, j int) bool { 16 | return h[i].order < h[j].order 17 | } 18 | 19 | func (h processInputHeap) Swap(i, j int) { 20 | h[i], h[j] = h[j], h[i] 21 | } 22 | 23 | func (h *processInputHeap) Push(x interface{}) { 24 | *h = append(*h, x.(*processInput)) 25 | } 26 | 27 | func (s processInputHeap) Peek() (*processInput, bool) { 28 | if len(s) > 0 { 29 | return s[0], true 30 | } 31 | return nil, false 32 | } 33 | 34 | func (h *processInputHeap) Pop() interface{} { 35 | old := *h 36 | n := len(old) 37 | x := old[n-1] 38 | *h = old[0 : n-1] 39 | return x 40 | } 41 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package orderedconcurrently 2 | 3 | import ( 4 | "container/heap" 5 | "context" 6 | "sync" 7 | ) 8 | 9 | // Options options for Process 10 | type Options struct { 11 | PoolSize int 12 | OutChannelBuffer int 13 | } 14 | 15 | // OrderedOutput is the output channel type from Process 16 | type OrderedOutput struct { 17 | Value interface{} 18 | Remaining func() int 19 | } 20 | 21 | // WorkFunction interface 22 | type WorkFunction interface { 23 | Run(ctx context.Context) interface{} 24 | } 25 | 26 | // Process processes work function based on input. 27 | // It Accepts an WorkFunction read channel, work function and concurrent go routine pool size. 28 | // It Returns an interface{} channel. 29 | func Process(ctx context.Context, inputChan <-chan WorkFunction, options *Options) <-chan OrderedOutput { 30 | outputChan := make(chan OrderedOutput, options.OutChannelBuffer) 31 | 32 | go func() { 33 | if options.PoolSize < 1 { 34 | // Set a minimum number of processors 35 | options.PoolSize = 1 36 | } 37 | processChan := make(chan *processInput, options.PoolSize) 38 | aggregatorChan := make(chan *processInput, options.PoolSize) 39 | 40 | // Go routine to print data in order 41 | go func() { 42 | var current uint64 43 | outputHeap := &processInputHeap{} 44 | defer func() { 45 | close(outputChan) 46 | }() 47 | remaining := func() int { 48 | return outputHeap.Len() 49 | } 50 | for item := range aggregatorChan { 51 | heap.Push(outputHeap, item) 52 | for { 53 | if top, ok := outputHeap.Peek(); !ok || top.order != current { 54 | break 55 | } 56 | outputChan <- OrderedOutput{Value: heap.Pop(outputHeap).(*processInput).value, Remaining: remaining} 57 | current++ 58 | } 59 | } 60 | 61 | for outputHeap.Len() > 0 { 62 | outputChan <- OrderedOutput{Value: heap.Pop(outputHeap).(*processInput).value, Remaining: remaining} 63 | } 64 | }() 65 | 66 | poolWg := sync.WaitGroup{} 67 | poolWg.Add(options.PoolSize) 68 | // Create a goroutine pool 69 | for i := 0; i < options.PoolSize; i++ { 70 | go func(worker int) { 71 | defer func() { 72 | poolWg.Done() 73 | }() 74 | for input := range processChan { 75 | input.value = input.workFn.Run(ctx) 76 | input.workFn = nil 77 | aggregatorChan <- input 78 | } 79 | }(i) 80 | } 81 | 82 | go func() { 83 | poolWg.Wait() 84 | close(aggregatorChan) 85 | }() 86 | 87 | go func() { 88 | defer func() { 89 | close(processChan) 90 | }() 91 | var order uint64 92 | for input := range inputChan { 93 | processChan <- &processInput{workFn: input, order: order} 94 | order++ 95 | } 96 | }() 97 | }() 98 | return outputChan 99 | } 100 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package orderedconcurrently 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "sort" 7 | "sync" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | type zeroLoadWorker int 13 | 14 | func (w zeroLoadWorker) Run(ctx context.Context) interface{} { 15 | return w * 2 16 | } 17 | 18 | type loadWorker int 19 | 20 | func (w loadWorker) Run(ctx context.Context) interface{} { 21 | time.Sleep(time.Millisecond * time.Duration(rand.Intn(100))) 22 | return w * 2 23 | } 24 | 25 | func Test1(t *testing.T) { 26 | t.Run("Test with Preset Pool Size", func(t *testing.T) { 27 | ctx := context.Background() 28 | max := 10 29 | inputChan := make(chan WorkFunction) 30 | wg := &sync.WaitGroup{} 31 | 32 | outChan := Process(ctx, inputChan, &Options{PoolSize: 10}) 33 | counter := 0 34 | go func(t *testing.T) { 35 | for out := range outChan { 36 | if _, ok := out.Value.(loadWorker); !ok { 37 | t.Error("Invalid output") 38 | } else { 39 | counter++ 40 | } 41 | wg.Done() 42 | } 43 | }(t) 44 | 45 | // Create work and the associated order 46 | for work := 0; work < max; work++ { 47 | wg.Add(1) 48 | inputChan <- loadWorker(work) 49 | } 50 | close(inputChan) 51 | wg.Wait() 52 | if counter != max { 53 | t.Error("Input count does not match output count") 54 | } 55 | t.Log("Test with Preset Pool Size Completed") 56 | }) 57 | } 58 | 59 | func Test2(t *testing.T) { 60 | t.Run("Test with default Pool Size", func(t *testing.T) { 61 | ctx := context.Background() 62 | 63 | max := 10 64 | inputChan := make(chan WorkFunction) 65 | wg := &sync.WaitGroup{} 66 | 67 | outChan := Process(ctx, inputChan, &Options{OutChannelBuffer: 2}) 68 | counter := 0 69 | go func(t *testing.T) { 70 | for out := range outChan { 71 | if _, ok := out.Value.(loadWorker); !ok { 72 | t.Error("Invalid output") 73 | } else { 74 | counter++ 75 | } 76 | wg.Done() 77 | } 78 | }(t) 79 | 80 | // Create work and the associated order 81 | for work := 0; work < max; work++ { 82 | wg.Add(1) 83 | inputChan <- loadWorker(work) 84 | } 85 | close(inputChan) 86 | wg.Wait() 87 | if counter != max { 88 | t.Error("Input count does not match output count") 89 | } 90 | t.Log("Test with Default Pool Size Completed") 91 | }) 92 | } 93 | 94 | func Test3(t *testing.T) { 95 | t.Run("Test Zero Load", func(t *testing.T) { 96 | ctx := context.Background() 97 | 98 | max := 10 99 | inputChan := make(chan WorkFunction) 100 | wg := &sync.WaitGroup{} 101 | 102 | outChan := Process(ctx, inputChan, &Options{OutChannelBuffer: 2}) 103 | counter := 0 104 | go func(t *testing.T) { 105 | for out := range outChan { 106 | if _, ok := out.Value.(zeroLoadWorker); !ok { 107 | t.Error("Invalid output") 108 | } else { 109 | counter++ 110 | } 111 | wg.Done() 112 | } 113 | }(t) 114 | 115 | // Create work and the associated order 116 | for work := 0; work < max; work++ { 117 | wg.Add(1) 118 | inputChan <- zeroLoadWorker(work) 119 | } 120 | close(inputChan) 121 | wg.Wait() 122 | if counter != max { 123 | t.Error("Input count does not match output count") 124 | } 125 | t.Log("Test with Default Pool Size and Zero Load Completed") 126 | }) 127 | 128 | } 129 | 130 | func Test4(t *testing.T) { 131 | t.Run("Test without workgroup", func(t *testing.T) { 132 | ctx := context.Background() 133 | 134 | max := 10 135 | inputChan := make(chan WorkFunction) 136 | output := Process(ctx, inputChan, &Options{PoolSize: 10, OutChannelBuffer: 10}) 137 | go func() { 138 | for work := 0; work < max; work++ { 139 | inputChan <- zeroLoadWorker(work) 140 | } 141 | close(inputChan) 142 | }() 143 | counter := 0 144 | for out := range output { 145 | if _, ok := out.Value.(zeroLoadWorker); !ok { 146 | t.Error("Invalid output") 147 | } else { 148 | counter++ 149 | } 150 | } 151 | if counter != max { 152 | t.Error("Input count does not match output count") 153 | } 154 | t.Log("Test without workgroup Completed") 155 | }) 156 | } 157 | 158 | func TestSortedData(t *testing.T) { 159 | t.Run("Test if response is sorted", func(t *testing.T) { 160 | ctx := context.Background() 161 | 162 | max := 10 163 | inputChan := make(chan WorkFunction) 164 | output := Process(ctx, inputChan, &Options{PoolSize: 10, OutChannelBuffer: 10}) 165 | go func() { 166 | for work := 0; work < max; work++ { 167 | inputChan <- loadWorker(work) 168 | } 169 | close(inputChan) 170 | }() 171 | var res []loadWorker 172 | for out := range output { 173 | res = append(res, out.Value.(loadWorker)) 174 | } 175 | isSorted := sort.SliceIsSorted(res, func(i, j int) bool { 176 | return res[i] < res[j] 177 | }) 178 | if !isSorted { 179 | t.Error("output is not sorted") 180 | } 181 | t.Log("Test if response is sorted") 182 | }) 183 | } 184 | 185 | func TestSortedDataMultiple(t *testing.T) { 186 | for i := 0; i < 50; i++ { 187 | t.Run("Test if response is sorted", func(t *testing.T) { 188 | ctx := context.Background() 189 | 190 | max := 10 191 | inputChan := make(chan WorkFunction) 192 | output := Process(ctx, inputChan, &Options{PoolSize: 10, OutChannelBuffer: 10}) 193 | go func() { 194 | for work := 0; work < max; work++ { 195 | inputChan <- loadWorker(work) 196 | } 197 | close(inputChan) 198 | }() 199 | var res []loadWorker 200 | for out := range output { 201 | res = append(res, out.Value.(loadWorker)) 202 | } 203 | isSorted := sort.SliceIsSorted(res, func(i, j int) bool { 204 | return res[i] < res[j] 205 | }) 206 | if !isSorted { 207 | t.Error("output is not sorted") 208 | } 209 | t.Log("Test if response is sorted") 210 | }) 211 | } 212 | } 213 | 214 | func TestStreamingInput(t *testing.T) { 215 | t.Run("Test streaming input", func(t *testing.T) { 216 | ctx := context.Background() 217 | inputChan := make(chan WorkFunction, 10) 218 | output := Process(ctx, inputChan, &Options{PoolSize: 10, OutChannelBuffer: 10}) 219 | 220 | ticker := time.NewTicker(100 * time.Millisecond) 221 | done := make(chan bool) 222 | wg := &sync.WaitGroup{} 223 | go func() { 224 | input := 0 225 | for { 226 | select { 227 | case <-done: 228 | return 229 | case <-ticker.C: 230 | inputChan <- zeroLoadWorker(input) 231 | wg.Add(1) 232 | input++ 233 | default: 234 | } 235 | } 236 | }() 237 | 238 | var res []zeroLoadWorker 239 | 240 | go func() { 241 | for out := range output { 242 | res = append(res, out.Value.(zeroLoadWorker)) 243 | wg.Done() 244 | } 245 | }() 246 | 247 | time.Sleep(1600 * time.Millisecond) 248 | ticker.Stop() 249 | done <- true 250 | close(inputChan) 251 | wg.Wait() 252 | isSorted := sort.SliceIsSorted(res, func(i, j int) bool { 253 | return res[i] < res[j] 254 | }) 255 | if !isSorted { 256 | t.Error("output is not sorted") 257 | } 258 | t.Log("Test streaming input") 259 | }) 260 | } 261 | 262 | func BenchmarkOC(b *testing.B) { 263 | max := 100000 264 | inputChan := make(chan WorkFunction) 265 | output := Process(context.Background(), inputChan, &Options{PoolSize: 10, OutChannelBuffer: 10}) 266 | go func() { 267 | for work := 0; work < max; work++ { 268 | inputChan <- zeroLoadWorker(work) 269 | } 270 | close(inputChan) 271 | }() 272 | for out := range output { 273 | _ = out 274 | } 275 | } 276 | 277 | func BenchmarkOCLoad(b *testing.B) { 278 | max := 10 279 | inputChan := make(chan WorkFunction) 280 | output := Process(context.Background(), inputChan, &Options{PoolSize: 10, OutChannelBuffer: 10}) 281 | go func() { 282 | for work := 0; work < max; work++ { 283 | inputChan <- loadWorker(work) 284 | } 285 | close(inputChan) 286 | }() 287 | for out := range output { 288 | _ = out 289 | } 290 | } 291 | 292 | func BenchmarkOC2(b *testing.B) { 293 | for i := 0; i < 100; i++ { 294 | max := 1000 295 | inputChan := make(chan WorkFunction) 296 | output := Process(context.Background(), inputChan, &Options{PoolSize: 10, OutChannelBuffer: 10}) 297 | go func() { 298 | for work := 0; work < max; work++ { 299 | inputChan <- zeroLoadWorker(work) 300 | } 301 | close(inputChan) 302 | }() 303 | for out := range output { 304 | _ = out 305 | } 306 | } 307 | } 308 | --------------------------------------------------------------------------------