github.com/haraldrudell/parl@v0.4.176/moderator-core.go (about)

     1  /*
     2  © 2022–present Harald Rudell <harald.rudell@gmail.com> (https://haraldrudell.github.io/haraldrudell/)
     3  ISC License
     4  */
     5  
     6  package parl
     7  
     8  import (
     9  	"fmt"
    10  	"math"
    11  	"sync"
    12  	"sync/atomic"
    13  )
    14  
    15  const (
    16  	// default is to allow 20 threads at a time
    17  	defaultParallelism = 20
    18  )
    19  
    20  // ModeratorCore invokes functions at a limited level of parallelism
    21  //   - ModeratorCore is a ticketing system
    22  //   - ModeratorCore does not have a cancel feature
    23  //   - during low contention atomic performance
    24  //   - during high-contention lock performance
    25  //
    26  // Usage:
    27  //
    28  //	m := NewModeratorCore(20, ctx)
    29  //	defer m.Ticket()() // waiting here for a ticket
    30  //	// got a ticket!
    31  //	…
    32  //	return or panic // ticket automatically returned
    33  //	m.String() → waiting: 2(20)
    34  type ModeratorCore struct {
    35  	// parallelism is the maximum number of outstanding tickets
    36  	parallelism uint64
    37  	// number of issued tickets
    38  	//	- if less than parallelism:
    39  	//	- — moderator is in atomic mode, ie.
    40  	//	- —	tickets obtained by atomic access only
    41  	//	- if equal to parallelism:
    42  	//	- — moderator is in lock mode. ie.
    43  	//	- — tickets are transfered orderly using queue,
    44  	//		waiting and transferBehindLock
    45  	active atomic.Uint64
    46  	// lock used when moderator in lock mode
    47  	//	- treads use the cond with waiting and transferBehindLock
    48  	//	- orderly first-come-first-served
    49  	queue sync.Cond
    50  	// number of threads waiting for a ticket
    51  	//	- behind lock
    52  	//	- atomic so Status can read
    53  	waiting atomic.Uint64
    54  	// transferBehindLock facilitates locked ticket transfer
    55  	//	- behind lock
    56  	//	- atomic so it can be inspected
    57  	transferBehindLock atomic.Uint64
    58  }
    59  
    60  // moderatorCore is a parl-private version of ModeratorCore
    61  type moderatorCore struct {
    62  	*ModeratorCore
    63  }
    64  
    65  // NewModerator creates a new Moderator used to limit parallelism
    66  func NewModeratorCore(parallelism uint64) (m *ModeratorCore) {
    67  	if parallelism < 1 {
    68  		parallelism = defaultParallelism
    69  	}
    70  	return &ModeratorCore{
    71  		parallelism: parallelism,
    72  		queue:       *sync.NewCond(&sync.Mutex{}),
    73  	}
    74  }
    75  
    76  // Ticket returns a ticket possibly blocking until one is available
    77  //   - Ticket returns the function for returning the ticket
    78  //
    79  // Usage:
    80  //
    81  //	defer moderator.Ticket()()
    82  func (m *ModeratorCore) Ticket() (returnTicket func()) {
    83  	returnTicket = m.returnTicket
    84  
    85  	// try available ticket at atomic performance
    86  	for {
    87  		if tickets := m.active.Load(); tickets == m.parallelism {
    88  			break // it’s lock mode
    89  		} else if m.active.CompareAndSwap(tickets, tickets+1) {
    90  			return // got atomic ticket return
    91  		}
    92  	}
    93  
    94  	// enter lock mode
    95  	m.queue.L.Lock()
    96  	defer m.queue.L.Unlock()
    97  	defer m.lastWaitCheck()
    98  
    99  	// critial section: ticket loop
   100  	var isWaiting bool
   101  	for {
   102  
   103  		// attempt atomic ticket
   104  		for {
   105  			if tickets := m.active.Load(); tickets == m.parallelism {
   106  				break // still lock mode
   107  			} else if m.active.CompareAndSwap(tickets, tickets+1) {
   108  				return // got atomic ticket return
   109  			}
   110  		}
   111  
   112  		// attempt transfer-behind-lock ticket
   113  		if m.transferBehindLock.Load() > 0 {
   114  			m.transferBehindLock.Add(math.MaxUint64)
   115  			return // ticket transfer successful return
   116  		}
   117  
   118  		// wait for ticket to become available
   119  		if !isWaiting {
   120  			isWaiting = true
   121  			m.waiting.Add(1)
   122  			defer m.waiting.Add(math.MaxUint64)
   123  		}
   124  		// blocks here
   125  		m.queue.Wait()
   126  	}
   127  }
   128  
   129  // lastWaitCheck prevents tickets from getting stuck as transfers
   130  //   - invoked while holding lock
   131  //   - this can happen if 1 thread is waiting and multiple threads transfer tickets
   132  func (m *ModeratorCore) lastWaitCheck() {
   133  	if m.waiting.Load() > 0 {
   134  		return // more threads are waiting
   135  	}
   136  	var transfers = m.transferBehindLock.Load()
   137  	if transfers == 0 {
   138  		return // no extra transfers available return
   139  	}
   140  
   141  	// put extra transfers in atomic tickets
   142  	m.transferBehindLock.Store(0)
   143  	m.active.Add(math.MaxUint64 - transfers + 1)
   144  }
   145  
   146  // returnTicket returns a ticket obtained by Ticket
   147  func (m *ModeratorCore) returnTicket() {
   148  
   149  	// attempt ticket-return atomically
   150  	for {
   151  		if tickets := m.active.Load(); tickets == m.parallelism {
   152  			break // lock mode: use transfer-ticket
   153  		} else if m.active.CompareAndSwap(tickets, tickets-1) {
   154  			return // ticket returned atomically return
   155  		}
   156  	}
   157  
   158  	// return ticket using transfer behind lock
   159  	m.queue.L.Lock()
   160  	defer m.queue.L.Unlock()
   161  
   162  	// if no thread waiting, return atomically
   163  	if m.waiting.Load() == 0 {
   164  		m.active.Add(math.MaxUint64)
   165  		return // atomic transfer complete return
   166  	}
   167  
   168  	// if thread waiting, do ticket transfer
   169  	m.transferBehindLock.Add(1)
   170  	m.queue.Signal() // signal while holding lock
   171  }
   172  
   173  // Status: values may lack integrity
   174  func (m *ModeratorCore) Status() (parallelism, active, waiting uint64) {
   175  	parallelism = m.parallelism
   176  	active = m.active.Load()
   177  	waiting = m.waiting.Load()
   178  	return
   179  }
   180  
   181  // when tickets available: “available: 2(10)”
   182  //   - 10 - 2 = 8 threads operating
   183  //   - when threads waiting “waiting 1(10)”
   184  //   - 10 threads operating, 1 thread waiting
   185  func (m *ModeratorCore) String() (s string) {
   186  	var parallelism, active, waiting = m.Status()
   187  	if active < parallelism {
   188  		s = fmt.Sprintf("available: %d(%d)", parallelism-active, parallelism)
   189  	} else {
   190  		s = fmt.Sprintf("waiting: %d(%d)", waiting, parallelism)
   191  	}
   192  	return
   193  }