initial working release
This commit is contained in:
commit
6482410063
11 changed files with 556 additions and 0 deletions
29
README.md
Normal file
29
README.md
Normal file
|
@ -0,0 +1,29 @@
|
|||
# gtq - the go task queue
|
||||
|
||||
## what?
|
||||
a simple goroutine scheduler for longer-running goroutines, allowing you
|
||||
to put them into priority buckets, and get roughly fair queuing of which
|
||||
tasks will be processed next.
|
||||
|
||||
## why?
|
||||
goroutines are awesome, and typically super fast. but what if you need to
|
||||
queue up a lot of longer running work in the background? and what if you
|
||||
want some control over which chunks of work will get executed next?
|
||||
|
||||
gtq provides simple task queuing and scheduling for longer running goroutines.
|
||||
in this case, "long" means on the order of 1ms or longer, as many simple
|
||||
goroutines can run in a matter of ns.
|
||||
|
||||
## how?
|
||||
We use simple heap based queues for each priority level, and track statistics
|
||||
in a rolling window for task execution times in each of those. Then we
|
||||
have a scheduler which looks at those statistics, in relation to the priority
|
||||
level for the queue, and decides which queue to pull from next and feed to task
|
||||
runners.
|
||||
|
||||
Overhead for this process is typically < 1000ns, so it's not suitable for
|
||||
typical short lived goroutines, but those type of goroutines don't really,
|
||||
need scheduling anyway.
|
||||
|
||||
## usage
|
||||
TODO
|
8
go.mod
Normal file
8
go.mod
Normal file
|
@ -0,0 +1,8 @@
|
|||
module github.com/nergdron/gtq
|
||||
|
||||
go 1.12
|
||||
|
||||
require (
|
||||
gopkg.in/eapache/queue.v1 v1.1.0
|
||||
gopkg.in/yaml.v2 v2.2.2
|
||||
)
|
5
go.sum
Normal file
5
go.sum
Normal file
|
@ -0,0 +1,5 @@
|
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/eapache/queue.v1 v1.1.0 h1:EldqoJEGtXYiVCMRo2C9mePO2UUGnYn2+qLmlQSqPdc=
|
||||
gopkg.in/eapache/queue.v1 v1.1.0/go.mod h1:wNtmx1/O7kZSR9zNT1TTOJ7GLpm3Vn7srzlfylFbQwU=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
71
queue.go
Normal file
71
queue.go
Normal file
|
@ -0,0 +1,71 @@
|
|||
package gtq
|
||||
|
||||
import (
|
||||
"gopkg.in/eapache/queue.v1"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// NewQueue initializes and returns an new Queue.
|
||||
func NewQueue() (q *Queue) {
|
||||
return &Queue{
|
||||
queue: queue.New(),
|
||||
Done: NewStat(0),
|
||||
}
|
||||
}
|
||||
|
||||
// Queue wraps eapache/queue with locking for thread safety. Also bundles in
|
||||
// Stats, used by schedulers to help pick tasks to execute.
|
||||
type Queue struct {
|
||||
queue *queue.Queue
|
||||
mutex sync.Mutex
|
||||
// Stat which tracks task removal rate from the queue.
|
||||
Done *Stat
|
||||
}
|
||||
|
||||
// Add puts an element on the end of the queue.
|
||||
func (q *Queue) Add(task func()) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
q.queue.Add(task)
|
||||
}
|
||||
|
||||
// Get returns the element at index i in the queue.
|
||||
// This method accepts both positive and negative index values.
|
||||
// Index 0 refers to the first element, and index -1 refers to the last.
|
||||
func (q *Queue) Get(i int) (task func()) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
if (q.queue.Length() < 1 || i > q.queue.Length()-1) || (i < -1) {
|
||||
return nil
|
||||
}
|
||||
return q.queue.Get(i).(func())
|
||||
}
|
||||
|
||||
// Length returns the number of elements currently stored in the queue.
|
||||
func (q *Queue) Length() int {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
return q.queue.Length()
|
||||
}
|
||||
|
||||
// Peek returns the element at the head of the queue.
|
||||
func (q *Queue) Peek() (task func()) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
if q.Length() < 1 {
|
||||
return nil
|
||||
}
|
||||
return q.queue.Peek().(func())
|
||||
}
|
||||
|
||||
// Remove removes and returns the element from the front of the queue.
|
||||
func (q *Queue) Remove() (task func()) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
if q.queue.Length() < 1 {
|
||||
return nil
|
||||
}
|
||||
task = q.queue.Remove().(func())
|
||||
q.Done.Add(1)
|
||||
return task
|
||||
}
|
87
queue_test.go
Normal file
87
queue_test.go
Normal file
|
@ -0,0 +1,87 @@
|
|||
package gtq
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var testQ = NewQueue()
|
||||
|
||||
func testAdd(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
testQ.Add(func() {
|
||||
time.Sleep(1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testRemove(wg *sync.WaitGroup, removed chan int) {
|
||||
defer wg.Done()
|
||||
var i int
|
||||
for testQ.Remove() != nil {
|
||||
i++
|
||||
}
|
||||
removed <- i
|
||||
}
|
||||
|
||||
func TestParallelQueueAdd(t *testing.T) {
|
||||
max := runtime.GOMAXPROCS(0)
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < max; n++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
testAdd(1000000)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
t.Log("Queue size is", testQ.Length())
|
||||
targetSize := max * 1000000
|
||||
if testQ.Length() != targetSize {
|
||||
t.Error("Queue size should be", targetSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelQueueRemove(t *testing.T) {
|
||||
target := testQ.Length()
|
||||
var wg sync.WaitGroup
|
||||
max := runtime.GOMAXPROCS(0)
|
||||
removed := make(chan int, max)
|
||||
for n := 0; n < max; n++ {
|
||||
wg.Add(1)
|
||||
go testRemove(&wg, removed)
|
||||
}
|
||||
wg.Wait()
|
||||
close(removed)
|
||||
var total int
|
||||
for count := range removed {
|
||||
total += count
|
||||
}
|
||||
if total != target {
|
||||
t.Error("removed", total, "but expected", target)
|
||||
}
|
||||
t.Log("removed", total)
|
||||
}
|
||||
|
||||
func BenchmarkParallelQueue(b *testing.B) {
|
||||
testQ = NewQueue()
|
||||
b.ResetTimer()
|
||||
max := runtime.GOMAXPROCS(0)
|
||||
var wg1, wg2 sync.WaitGroup
|
||||
removed := make(chan int, max)
|
||||
for i := 0; i < max; i++ {
|
||||
wg1.Add(1)
|
||||
wg2.Add(1)
|
||||
go func() {
|
||||
testAdd(b.N / (max * 2))
|
||||
wg1.Done()
|
||||
}()
|
||||
go func() {
|
||||
testRemove(&wg2, removed)
|
||||
}()
|
||||
}
|
||||
wg1.Wait()
|
||||
wg2.Wait()
|
||||
}
|
39
scheduler.go
Normal file
39
scheduler.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
package gtq
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Scheduler is an internal goroutine which examines the Queues and
|
||||
// choses which tasks to run next. Returns true if tasks were scheduled,
|
||||
// or false if there's nothing left to schedule.
|
||||
type Scheduler func(tq *TaskQueue) bool
|
||||
|
||||
// SimpleScheduler is the simplest possible implementation, which just takes
|
||||
// tasks off the highest priority queue.
|
||||
func SimpleScheduler(tq *TaskQueue) bool {
|
||||
pc := tq.PriorityCounts()
|
||||
// sort priorities
|
||||
prios := make([]uint, 0, len(pc))
|
||||
for k := range pc {
|
||||
prios = append(prios, k)
|
||||
}
|
||||
sort.Sort(UIntSlice(prios))
|
||||
|
||||
var queued uint
|
||||
for _, prio := range prios {
|
||||
q, ok := tq.queues.Load(prio)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for q.(*Queue).Length() > 0 {
|
||||
task := q.(*Queue).Remove()
|
||||
tq.nextTask <- task
|
||||
queued++
|
||||
}
|
||||
}
|
||||
if queued >= (tq.numJobs * 2) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
118
stats.go
Normal file
118
stats.go
Normal file
|
@ -0,0 +1,118 @@
|
|||
package gtq
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v2"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Counter is an incrementing statistical value, with a Start time for rate
|
||||
// calculation.
|
||||
type Counter struct {
|
||||
Start time.Time
|
||||
Value uint64
|
||||
}
|
||||
|
||||
// Stat for a queue priority level, grouped into timeslice buckets.
|
||||
type Stat struct {
|
||||
timeSlices []Counter
|
||||
currentSlice int
|
||||
window time.Duration
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// NewStat initializes and returns a new Stat. window determines
|
||||
// the size of the rolling statistics timeframe that is captured,
|
||||
// which in turn determines how finely grained stats collection and task
|
||||
// scheduling can be. window defaults to 1 second.
|
||||
func NewStat(window time.Duration) (s *Stat) {
|
||||
s = &Stat{window: window}
|
||||
if s.window == 0 {
|
||||
s.window = time.Second
|
||||
}
|
||||
// default to 10 timeslice buckets, maybe make configurable later.
|
||||
s.timeSlices = make([]Counter, 10)
|
||||
return s
|
||||
}
|
||||
|
||||
// Add increments the specified counter by the amount in val. window specifies
|
||||
func (s *Stat) Add(val uint64) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
// roll over to next timeslice if slice duration has been exceeded.
|
||||
sliceLength := s.window / time.Duration(len(s.timeSlices))
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(s.timeSlices[s.currentSlice].Start)
|
||||
if elapsed > sliceLength {
|
||||
s.currentSlice++
|
||||
s.currentSlice = s.currentSlice % len(s.timeSlices)
|
||||
s.timeSlices[s.currentSlice].Start = now
|
||||
s.timeSlices[s.currentSlice].Value = val
|
||||
}
|
||||
s.timeSlices[s.currentSlice].Value += val
|
||||
}
|
||||
|
||||
// Total returns the current rolling total for this Stat.
|
||||
func (s *Stat) Total() (t uint64) {
|
||||
for _, slice := range s.timeSlices {
|
||||
t += slice.Value
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Start returns the start time for this statistics window.
|
||||
func (s *Stat) Start() (start time.Time) {
|
||||
for i := s.currentSlice; i < 10; i++ {
|
||||
if s.timeSlices[i%10].Start.After(time.Time{}) {
|
||||
return s.timeSlices[i%10].Start
|
||||
}
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
// Duration returns the size of the current statistics window in this Stat.
|
||||
// This will fluctuate between 90-100% of the window size set when the state
|
||||
// was created, as internal buckets roll over.
|
||||
func (s *Stat) Duration() time.Duration {
|
||||
return time.Now().Sub(s.Start())
|
||||
}
|
||||
|
||||
// Rate returns the flow rate of this counter, which is the current rolling
|
||||
// value / the current statistics duration.
|
||||
func (s *Stat) Rate() (r float64) {
|
||||
return float64(s.Total()) / (float64(s.Duration()) / float64(time.Second))
|
||||
}
|
||||
|
||||
// QueueStats is a report of formatted Stat data about a specific priority
|
||||
// queue captured at a point in time.
|
||||
type QueueStats struct {
|
||||
Start time.Time
|
||||
time.Duration
|
||||
Count uint64
|
||||
Rate float64
|
||||
}
|
||||
|
||||
func (qs *QueueStats) String() string {
|
||||
b, _ := yaml.Marshal(qs)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Stats returns the current time usage statistics for all active task
|
||||
// priority levels.
|
||||
func (tq *TaskQueue) Stats() (stats map[uint]*QueueStats) {
|
||||
stats = make(map[uint]*QueueStats)
|
||||
tq.mutex.Lock()
|
||||
defer tq.mutex.Unlock()
|
||||
tq.queues.Range(func(prio, q interface{}) bool {
|
||||
stat := q.(*Queue).Done
|
||||
qs := &QueueStats{
|
||||
Start: stat.Start(),
|
||||
Duration: stat.Duration(),
|
||||
Count: stat.Total(),
|
||||
Rate: stat.Rate(),
|
||||
}
|
||||
stats[prio.(uint)] = qs
|
||||
return true
|
||||
})
|
||||
return stats
|
||||
}
|
22
stats_test.go
Normal file
22
stats_test.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package gtq
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var testStat = NewStat(0)
|
||||
|
||||
func TestStat(t *testing.T) {
|
||||
total := int64(10000)
|
||||
perOp := int64(time.Second) / total
|
||||
for i := int64(0); i < total; i++ {
|
||||
testStat.Add(1)
|
||||
// random sleep to introduce some flow rate variation to capture.
|
||||
time.Sleep(time.Duration(rand.Float64() * float64(perOp)))
|
||||
}
|
||||
t.Log("completed", testStat.Total(), "ops in", testStat.Duration(),
|
||||
"for a total rate of", int64(testStat.Rate()), "ops/sec",
|
||||
)
|
||||
}
|
101
taskqueue.go
Normal file
101
taskqueue.go
Normal file
|
@ -0,0 +1,101 @@
|
|||
package gtq
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TaskQueue manages multiple Queues with different priorities.
|
||||
// Task clients request tasks to run from this, and it decides which Task
|
||||
// to give the client, based on a roughly even time division based on the
|
||||
// priority numbers. Items from priority 2 will be given twice as much
|
||||
// processing time as items from priorty 1, for instance.
|
||||
type TaskQueue struct {
|
||||
queues sync.Map
|
||||
// task runners read off this channel, scheduler pushes onto it.
|
||||
nextTask chan func()
|
||||
// we need locking when cleaning up unused priorities.
|
||||
mutex sync.Mutex
|
||||
running bool
|
||||
numJobs uint
|
||||
}
|
||||
|
||||
// NewTaskQueue returns an initialized TaskQueue.
|
||||
// numJobs specifies how many task workers you want running simultaneously, if
|
||||
// 0, defaults to runtime.GOMAXPROCS(0).
|
||||
func NewTaskQueue(numJobs uint) (tq *TaskQueue) {
|
||||
if numJobs == 0 {
|
||||
numJobs = uint(runtime.GOMAXPROCS(0))
|
||||
}
|
||||
tq = &TaskQueue{
|
||||
// make sure we can buffer up at least one extra job per worker.
|
||||
nextTask: make(chan func(), numJobs*2),
|
||||
numJobs: numJobs,
|
||||
}
|
||||
return tq
|
||||
}
|
||||
|
||||
// Add a task to the TaskQueue with a given priority.
|
||||
func (tq *TaskQueue) Add(task func(), priority uint) {
|
||||
q, _ := tq.queues.LoadOrStore(priority, NewQueue())
|
||||
q.(*Queue).Add(task)
|
||||
}
|
||||
|
||||
// Length returns the number of tasks at all priorities.
|
||||
func (tq *TaskQueue) Length() (length uint) {
|
||||
tq.mutex.Lock()
|
||||
defer tq.mutex.Unlock()
|
||||
tq.queues.Range(func(_, q interface{}) bool {
|
||||
length += uint(q.(*Queue).Length())
|
||||
return true
|
||||
})
|
||||
return length
|
||||
}
|
||||
|
||||
// PriorityCounts returns a map where the key is an active priority level,
|
||||
// and the value is the number of tasks left at that priority.
|
||||
func (tq *TaskQueue) PriorityCounts() (pc map[uint]int) {
|
||||
pc = make(map[uint]int)
|
||||
tq.mutex.Lock()
|
||||
defer tq.mutex.Unlock()
|
||||
tq.queues.Range(func(prio, q interface{}) bool {
|
||||
pc[prio.(uint)] = q.(*Queue).Length()
|
||||
return true
|
||||
})
|
||||
return pc
|
||||
}
|
||||
|
||||
// Start begins processing tasks.
|
||||
func (tq *TaskQueue) Start(scheduler Scheduler) {
|
||||
tq.mutex.Lock()
|
||||
tq.running = true
|
||||
tq.mutex.Unlock()
|
||||
// start workers!
|
||||
var j uint
|
||||
for j = 0; j < tq.numJobs; j++ {
|
||||
go func() {
|
||||
for job := range tq.nextTask {
|
||||
job()
|
||||
}
|
||||
}()
|
||||
}
|
||||
if scheduler == nil {
|
||||
scheduler = SimpleScheduler
|
||||
}
|
||||
go func() {
|
||||
for tq.running {
|
||||
workQueued := scheduler(tq)
|
||||
if !workQueued {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop pauses processing of tasks. TaskQueue state is retained.
|
||||
func (tq *TaskQueue) Stop() {
|
||||
tq.mutex.Lock()
|
||||
defer tq.mutex.Unlock()
|
||||
tq.running = false
|
||||
}
|
51
taskqueue_test.go
Normal file
51
taskqueue_test.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
package gtq
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var numTestPrios = 10
|
||||
var tq *TaskQueue
|
||||
|
||||
func testTQAdd(n int) {
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
for i := 0; i < n; i++ {
|
||||
// simplest possible task
|
||||
tq.Add(func() {
|
||||
time.Sleep(0)
|
||||
}, uint(i%numTestPrios))
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestTaskQueueAdd(t *testing.T) {
|
||||
tq = NewTaskQueue(0)
|
||||
testTQAdd(1000000)
|
||||
expected := 1000000 * runtime.GOMAXPROCS(0)
|
||||
added := tq.Length()
|
||||
if uint(expected) != added {
|
||||
t.Error("expected to add", expected, "but added", added)
|
||||
} else {
|
||||
t.Log("added", added)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTaskQueue(b *testing.B) {
|
||||
tq = NewTaskQueue(0)
|
||||
testTQAdd(b.N / (runtime.GOMAXPROCS(0)))
|
||||
tq.Start(nil)
|
||||
for tq.Length() > 0 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
tq.Stop()
|
||||
}
|
25
uintslice.go
Normal file
25
uintslice.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package gtq
|
||||
|
||||
// UIntSlice just subtypes []uint to fulfill sort.Interface, as demonstrated
|
||||
// for other types in the sort package.
|
||||
type UIntSlice []uint
|
||||
|
||||
// Len helps fulfill sort.Interface.
|
||||
func (list UIntSlice) Len() int {
|
||||
return len(list)
|
||||
}
|
||||
|
||||
// Less helps fulfill sort.Interface.
|
||||
func (list UIntSlice) Less(i, j int) bool {
|
||||
if list[i] <= list[j] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Swap helps fulfill sort.Interface
|
||||
func (list UIntSlice) Swap(i, j int) {
|
||||
tmp := list[i]
|
||||
list[i] = list[j]
|
||||
list[j] = tmp
|
||||
}
|
Loading…
Add table
Reference in a new issue