Skip to content

Commit b4d22b8

Browse files
authored
Discard mode (#4)
* discard mode, wip * add Discard option and update docs * lint: unusual return of private struct * minor refactoring
1 parent 2305c11 commit b4d22b8

File tree

7 files changed

+116
-33
lines changed

7 files changed

+116
-33
lines changed

README.md

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![Build Status](https://github.com/go-pkgz/syncs/workflows/build/badge.svg)](https://github.com/go-pkgz/syncs/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/syncs)](https://goreportcard.com/report/github.com/go-pkgz/syncs) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/syncs/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/syncs?branch=master)
44

5-
Package syncs provides additional synchronization primitives.
5+
The `syncs` package offers extra synchronization primitives, such as `Semaphore`, `SizedGroup`, and `ErrSizedGroup`, to help manage concurrency in Go programs. With `syncs` package, you can efficiently manage concurrency in your Go programs using additional synchronization primitives. Use them according to your specific use-case requirements to control and limit concurrent goroutines while handling errors and early termination effectively.
66

77
## Install and update
88

@@ -12,7 +12,8 @@ Package syncs provides additional synchronization primitives.
1212

1313
### Semaphore
1414

15-
Implements `sync.Locker` interface but for given capacity, thread safe. Lock increases count and Unlock - decreases. Unlock on 0 count will be blocked.
15+
`Semaphore` implements the `sync.Locker` interface with an additional `TryLock` function and a specified capacity.
16+
It is thread-safe. The `Lock` function increases the count, while Unlock decreases it. When the count is 0, `Unlock` will block, and `Lock` will block until the count is greater than 0. The `TryLock` function will return false if locking failed (i.e. semaphore is locked) and true otherwise.
1617

1718
```go
1819
sema := syncs.NewSemaphore(10) // make semaphore with 10 initial capacity
@@ -23,14 +24,17 @@ Implements `sync.Locker` interface but for given capacity, thread safe. Lock inc
2324

2425
// in some other place/goroutine
2526
sema.Unlock() // decrease semaphore counter
27+
ok := sema.TryLock() // try to lock, will return false if semaphore is locked
2628
```
2729

2830
### SizedGroup
2931

30-
Mix semaphore and WaitGroup to provide sized waiting group. The result is a wait group allowing limited number of goroutine to run in parallel.
32+
`SizedGroup` combines `Semaphore` and `WaitGroup` to provide a wait group that allows a limited number of goroutines to run in parallel.
33+
34+
By default, locking happens inside the goroutine. This means every call will be non-blocking, but some goroutines may wait if the semaphore is locked. Technically, it doesn't limit the number of goroutines but rather the number of running (active) goroutines.
35+
36+
To block goroutines from starting, use the `Preemptive` option. Important: With `Preemptive`, the `Go` call can block. If the maximum size is reached, the call will wait until the number of running goroutines drops below the maximum. This not only limits the number of running goroutines but also the number of waiting goroutines.
3137

32-
By default, the locking happens inside of goroutine, i.e. **every call will be non-blocked**, but some goroutines may wait if semaphore locked. It means - technically it doesn't limit number of goroutines, but rather number of running (active) goroutines.
33-
In order to block goroutines from even starting use `Preemptive` option (see below).
3438

3539
```go
3640
swg := syncs.NewSizedGroup(5) // wait group with max size=5
@@ -42,17 +46,27 @@ In order to block goroutines from even starting use `Preemptive` option (see bel
4246
swg.Wait()
4347
```
4448

49+
Another option is `Discard`, which will skip (won't start) goroutines if the semaphore is locked. In other words, if a defined number of goroutines are already running, the call will be discarded. `Discard` is useful when you don't care about the results of extra goroutines; i.e., you just want to run some tasks in parallel but can allow some number of them to be ignored. This flag sets `Preemptive` as well, because otherwise, it doesn't make sense.
50+
51+
52+
```go
53+
swg := syncs.NewSizedGroup(5, Discard) // wait group with max size=5 and discarding extra goroutines
54+
for i :=0; i<10; i++ {
55+
swg.Go(func(ctx context.Context){
56+
doThings(ctx) // only 5 of these will run in parallel and 5 other can be discarded
57+
})
58+
}
59+
swg.Wait()
60+
```
61+
62+
4563
### ErrSizedGroup
4664

47-
Sized error group is a SizedGroup with error control.
48-
Works the same as errgrp.Group, i.e. returns first error.
49-
Can work as regular errgrp.Group or with early termination.
50-
Thread safe.
65+
`ErrSizedGroup` is a `SizedGroup` with error control. It works the same as `errgrp.Group`, i.e., it returns the first error.
66+
It can work as a regular errgrp.Group or with early termination. It is thread-safe.
5167

52-
Supports both in-goroutine-wait via `NewErrSizedGroup` as well as outside of goroutine wait with `Preemptive` option. Another options are `TermOnErr` which will skip (won't start) all other goroutines if any error returned, and `Context` for early termination/timeouts.
5368

54-
Important! With `Preemptive` Go call **can block**. In case if maximum size reached the call will wait till number of running goroutines
55-
dropped under max. This way we not only limiting number of running goroutines but also number of waiting goroutines.
69+
`ErrSizedGroup` supports both in-goroutine-wait as well as outside of goroutine wait with `Preemptive` and `Discard` options (see above). Other options include `TermOnErr`, which skips (won't start) all other goroutines if any error is returned, and `Context` for early termination/timeouts.
5670

5771

5872
```go
@@ -64,4 +78,5 @@ dropped under max. This way we not only limiting number of running goroutines bu
6478
})
6579
}
6680
err := ewg.Wait()
67-
```
81+
```
82+

errsizedgroup.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
type ErrSizedGroup struct {
1313
options
1414
wg sync.WaitGroup
15-
sema sync.Locker
15+
sema Locker
1616

1717
err *multierror
1818
errLock sync.RWMutex
@@ -23,7 +23,6 @@ type ErrSizedGroup struct {
2323
// By default all goroutines will be started but will wait inside. For limited number of goroutines use Preemptive() options.
2424
// TermOnErr will skip (won't start) all other goroutines if any error returned.
2525
func NewErrSizedGroup(size int, options ...GroupOption) *ErrSizedGroup {
26-
2726
res := ErrSizedGroup{
2827
sema: NewSemaphore(size),
2928
err: new(multierror),
@@ -40,11 +39,18 @@ func NewErrSizedGroup(size int, options ...GroupOption) *ErrSizedGroup {
4039
// The first call to return a non-nil error cancels the group if termOnError; its error will be
4140
// returned by Wait. If no termOnError all errors will be collected in multierror.
4241
func (g *ErrSizedGroup) Go(f func() error) {
43-
4442
g.wg.Add(1)
4543

4644
if g.preLock {
47-
g.sema.Lock()
45+
lockOk := g.sema.TryLock()
46+
if !lockOk && g.discardIfFull {
47+
// lock failed and discardIfFull is set, discard this goroutine
48+
g.wg.Done()
49+
return
50+
}
51+
if !lockOk && !g.discardIfFull {
52+
g.sema.Lock() // make sure we have block until lock is acquired
53+
}
4854
}
4955

5056
go func() {
@@ -115,7 +121,7 @@ func (m *multierror) errorOrNil() error {
115121
return m
116122
}
117123

118-
// Error returns multierror string
124+
// Error returns multi-error string
119125
func (m *multierror) Error() string {
120126
m.lock.Lock()
121127
defer m.lock.Unlock()

errsizedgroup_test.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@ func TestErrorSizedGroup_Preemptive(t *testing.T) {
4444
ewg := NewErrSizedGroup(10, Preemptive)
4545
var c uint32
4646

47-
for i := 0; i < 1000; i++ {
47+
for i := 0; i < 100; i++ {
4848
i := i
4949
ewg.Go(func() error {
5050
assert.True(t, runtime.NumGoroutine() < 20, "goroutines %d", runtime.NumGoroutine())
5151
atomic.AddUint32(&c, 1)
52-
if i == 100 {
52+
if i == 10 {
5353
return errors.New("err1")
5454
}
55-
if i == 200 {
55+
if i == 20 {
5656
return errors.New("err2")
5757
}
5858
time.Sleep(time.Millisecond)
@@ -64,7 +64,26 @@ func TestErrorSizedGroup_Preemptive(t *testing.T) {
6464
err := ewg.Wait()
6565
require.NotNil(t, err)
6666
assert.True(t, strings.HasPrefix(err.Error(), "2 error(s) occurred:"))
67-
assert.Equal(t, uint32(1000), c, fmt.Sprintf("%d, not all routines have been executed.", c))
67+
assert.Equal(t, uint32(100), c, fmt.Sprintf("%d, not all routines have been executed.", c))
68+
}
69+
70+
func TestErrorSizedGroup_Discard(t *testing.T) {
71+
ewg := NewErrSizedGroup(10, Discard)
72+
var c uint32
73+
74+
for i := 0; i < 1000; i++ {
75+
ewg.Go(func() error {
76+
assert.True(t, runtime.NumGoroutine() < 20, "goroutines %d", runtime.NumGoroutine())
77+
atomic.AddUint32(&c, 1)
78+
time.Sleep(10 * time.Millisecond)
79+
return nil
80+
})
81+
}
82+
83+
assert.True(t, runtime.NumGoroutine() <= 20, "goroutines %d", runtime.NumGoroutine())
84+
err := ewg.Wait()
85+
assert.NoError(t, err)
86+
assert.Equal(t, uint32(10), c)
6887
}
6988

7089
func TestErrorSizedGroup_NoError(t *testing.T) {

group_options.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ package syncs
33
import "context"
44

55
type options struct {
6-
ctx context.Context
7-
cancel context.CancelFunc
8-
preLock bool
9-
termOnError bool
6+
ctx context.Context
7+
cancel context.CancelFunc
8+
preLock bool
9+
termOnError bool
10+
discardIfFull bool
1011
}
1112

1213
// GroupOption functional option type
@@ -28,3 +29,9 @@ func Preemptive(o *options) {
2829
func TermOnErr(o *options) {
2930
o.termOnError = true
3031
}
32+
33+
// Discard will discard new goroutines if semaphore is full, i.e. no more goroutines allowed
34+
func Discard(o *options) {
35+
o.discardIfFull = true
36+
o.preLock = true // discard implies preemptive
37+
}

semaphore.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@ package syncs
22

33
import "sync"
44

5+
// Locker is a superset of sync.Locker interface with TryLock method.
6+
type Locker interface {
7+
sync.Locker
8+
TryLock() bool
9+
}
10+
511
// Semaphore implementation, counted lock only. Implements sync.Locker interface, thread safe.
612
type semaphore struct {
7-
sync.Locker
13+
Locker
814
ch chan struct{}
915
}
1016

1117
// NewSemaphore makes Semaphore with given capacity
12-
func NewSemaphore(capacity int) sync.Locker {
18+
func NewSemaphore(capacity int) Locker {
1319
if capacity <= 0 {
1420
capacity = 1
1521
}
@@ -25,3 +31,13 @@ func (s *semaphore) Lock() {
2531
func (s *semaphore) Unlock() {
2632
<-s.ch
2733
}
34+
35+
// TryLock acquires semaphore if possible, returns true if acquired, false otherwise.
36+
func (s *semaphore) TryLock() bool {
37+
select {
38+
case s.ch <- struct{}{}:
39+
return true
40+
default:
41+
return false
42+
}
43+
}

sizedgroup.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
type SizedGroup struct {
1212
options
1313
wg sync.WaitGroup
14-
sema sync.Locker
14+
sema Locker
1515
}
1616

1717
// NewSizedGroup makes wait group with limited size alive goroutines
@@ -27,7 +27,6 @@ func NewSizedGroup(size int, opts ...GroupOption) *SizedGroup {
2727
// Go calls the given function in a new goroutine.
2828
// Every call will be unblocked, but some goroutines may wait if semaphore locked.
2929
func (g *SizedGroup) Go(fn func(ctx context.Context)) {
30-
3130
canceled := func() bool {
3231
select {
3332
case <-g.ctx.Done():
@@ -41,12 +40,18 @@ func (g *SizedGroup) Go(fn func(ctx context.Context)) {
4140
return
4241
}
4342

44-
g.wg.Add(1)
45-
4643
if g.preLock {
47-
g.sema.Lock()
44+
lockOk := g.sema.TryLock()
45+
if !lockOk && g.discardIfFull {
46+
// lock failed and discardIfFull is set, discard this goroutine
47+
return
48+
}
49+
if !lockOk && !g.discardIfFull {
50+
g.sema.Lock() // make sure we have block until lock is acquired
51+
}
4852
}
4953

54+
g.wg.Add(1)
5055
go func() {
5156
defer g.wg.Done()
5257

sizedgroup_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ func TestSizedGroup(t *testing.T) {
2727
assert.Equal(t, uint32(1000), c, fmt.Sprintf("%d, not all routines have been executed", c))
2828
}
2929

30+
func TestSizedGroup_Discard(t *testing.T) {
31+
swg := NewSizedGroup(10, Preemptive, Discard)
32+
var c uint32
33+
34+
for i := 0; i < 100; i++ {
35+
swg.Go(func(ctx context.Context) {
36+
time.Sleep(5 * time.Millisecond)
37+
atomic.AddUint32(&c, 1)
38+
})
39+
}
40+
assert.True(t, runtime.NumGoroutine() < 15, "goroutines %d", runtime.NumGoroutine())
41+
swg.Wait()
42+
assert.Equal(t, uint32(10), c, fmt.Sprintf("%d, not all routines have been executed", c))
43+
}
44+
3045
func TestSizedGroup_Preemptive(t *testing.T) {
3146
swg := NewSizedGroup(10, Preemptive)
3247
var c uint32

0 commit comments

Comments
 (0)