dat-worker-pool 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Gemfile +1 -1
- data/bench/report.rb +130 -0
- data/bench/report.txt +7 -0
- data/dat-worker-pool.gemspec +1 -1
- data/lib/dat-worker-pool.rb +38 -187
- data/lib/dat-worker-pool/default_queue.rb +60 -0
- data/lib/dat-worker-pool/locked_object.rb +60 -0
- data/lib/dat-worker-pool/queue.rb +71 -48
- data/lib/dat-worker-pool/runner.rb +196 -0
- data/lib/dat-worker-pool/version.rb +1 -1
- data/lib/dat-worker-pool/worker.rb +251 -72
- data/lib/dat-worker-pool/worker_pool_spy.rb +39 -53
- data/test/helper.rb +13 -0
- data/test/support/factory.rb +15 -0
- data/test/support/thread_spies.rb +83 -0
- data/test/system/dat-worker-pool_tests.rb +399 -0
- data/test/unit/dat-worker-pool_tests.rb +132 -255
- data/test/unit/default_queue_tests.rb +217 -0
- data/test/unit/locked_object_tests.rb +260 -0
- data/test/unit/queue_tests.rb +95 -72
- data/test/unit/runner_tests.rb +365 -0
- data/test/unit/worker_pool_spy_tests.rb +95 -102
- data/test/unit/worker_tests.rb +819 -153
- metadata +27 -12
- data/test/system/use_worker_pool_tests.rb +0 -34
data/Gemfile
CHANGED
data/bench/report.rb
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
require 'benchmark'
|
2
|
+
require 'thread'
|
3
|
+
|
4
|
+
require 'dat-worker-pool'
|
5
|
+
require 'dat-worker-pool/default_queue'
|
6
|
+
|
7
|
+
class BenchRunner
|
8
|
+
|
9
|
+
NUM_WORKERS = 4
|
10
|
+
TIME_MODIFIER = 10 ** 4 # 4 decimal places
|
11
|
+
|
12
|
+
LOGGER = if ENV['DEBUG']
|
13
|
+
Logger.new(File.expand_path("../../log/bench.log", __FILE__)).tap do |l|
|
14
|
+
l.datetime_format = '' # don't show datetime in the logs
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
output_file_path = if ENV['OUTPUT_FILE']
|
20
|
+
File.expand_path(ENV['OUTPUT_FILE'])
|
21
|
+
else
|
22
|
+
File.expand_path('../report.txt', __FILE__)
|
23
|
+
end
|
24
|
+
@output = Output.new(File.open(output_file_path, 'w'))
|
25
|
+
|
26
|
+
@number_of_work_items = ENV['NUM_WORK_ITEMS'] || 100_000
|
27
|
+
|
28
|
+
@mutex = Mutex.new
|
29
|
+
@cond_var = ConditionVariable.new
|
30
|
+
@finished = LockedInteger.new(0)
|
31
|
+
|
32
|
+
@result = nil
|
33
|
+
end
|
34
|
+
|
35
|
+
def run
|
36
|
+
output "Running benchmark report..."
|
37
|
+
output("\n", false)
|
38
|
+
|
39
|
+
benchmark_processing_work_items
|
40
|
+
|
41
|
+
output "\n", false
|
42
|
+
output "Processing #{@number_of_work_items} Work Items: #{@result}ms"
|
43
|
+
|
44
|
+
output "\n"
|
45
|
+
output "Done running benchmark report"
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def benchmark_processing_work_items
|
51
|
+
queue = DatWorkerPool::DefaultQueue.new.tap(&:dwp_start)
|
52
|
+
@number_of_work_items.times.each{ |n| queue.dwp_push(n + 1) }
|
53
|
+
|
54
|
+
worker_pool = DatWorkerPool.new(BenchWorker, {
|
55
|
+
:num_workers => NUM_WORKERS,
|
56
|
+
:logger => LOGGER,
|
57
|
+
:queue => queue,
|
58
|
+
:worker_params => {
|
59
|
+
:mutex => @mutex,
|
60
|
+
:cond_var => @cond_var,
|
61
|
+
:finished => @finished,
|
62
|
+
:output => @output
|
63
|
+
}
|
64
|
+
})
|
65
|
+
benchmark = Benchmark.measure do
|
66
|
+
worker_pool.start
|
67
|
+
while @finished.value != @number_of_work_items
|
68
|
+
@mutex.synchronize{ @cond_var.wait(@mutex) }
|
69
|
+
end
|
70
|
+
worker_pool.shutdown
|
71
|
+
end
|
72
|
+
|
73
|
+
@result = round_and_display(benchmark.real * 1000.to_f)
|
74
|
+
output "\n", false
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def output(message, puts = true)
|
80
|
+
@output.log(message, puts)
|
81
|
+
end
|
82
|
+
|
83
|
+
def round_and_display(time_in_ms)
|
84
|
+
display_time(round_time(time_in_ms))
|
85
|
+
end
|
86
|
+
|
87
|
+
def round_time(time_in_ms)
|
88
|
+
(time_in_ms * TIME_MODIFIER).to_i / TIME_MODIFIER.to_f
|
89
|
+
end
|
90
|
+
|
91
|
+
def display_time(time)
|
92
|
+
integer, fractional = time.to_s.split('.')
|
93
|
+
[integer, fractional.ljust(4, '0')].join('.')
|
94
|
+
end
|
95
|
+
|
96
|
+
class BenchWorker
|
97
|
+
include DatWorkerPool::Worker
|
98
|
+
|
99
|
+
on_available{ signal_main_thread }
|
100
|
+
|
101
|
+
on_error{ params[:output].log('F', false) }
|
102
|
+
|
103
|
+
def work!(n)
|
104
|
+
params[:finished].increment
|
105
|
+
params[:output].log('.', false) if ((n - 1) % 100 == 0)
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def signal_main_thread
|
111
|
+
params[:mutex].synchronize{ params[:cond_var].signal }
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
class Output < Struct.new(:file)
|
116
|
+
def log(message, puts = true)
|
117
|
+
method = puts ? :puts : :print
|
118
|
+
self.send(method, message)
|
119
|
+
self.file.send(method, message)
|
120
|
+
STDOUT.flush if method == :print
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
class LockedInteger < DatWorkerPool::LockedObject
|
125
|
+
def increment; @mutex.synchronize{ @object = @object + 1 }; end
|
126
|
+
end
|
127
|
+
|
128
|
+
end
|
129
|
+
|
130
|
+
BenchRunner.new.run
|
data/bench/report.txt
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
Running benchmark report...
|
2
|
+
|
3
|
+
........................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
|
4
|
+
|
5
|
+
Processing 100000 Work Items: 3388.7569ms
|
6
|
+
|
7
|
+
Done running benchmark report
|
data/dat-worker-pool.gemspec
CHANGED
data/lib/dat-worker-pool.rb
CHANGED
@@ -1,220 +1,71 @@
|
|
1
1
|
require 'logger'
|
2
|
-
require 'system_timer'
|
3
|
-
require 'thread'
|
4
2
|
|
5
3
|
require 'dat-worker-pool/version'
|
6
4
|
require 'dat-worker-pool/queue'
|
5
|
+
require 'dat-worker-pool/runner'
|
7
6
|
require 'dat-worker-pool/worker'
|
8
7
|
|
9
8
|
class DatWorkerPool
|
10
9
|
|
11
|
-
|
10
|
+
DEFAULT_NUM_WORKERS = 1
|
11
|
+
MIN_WORKERS = 1
|
12
|
+
|
12
13
|
attr_reader :queue
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
@queue
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
@
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
@before_work_callbacks = []
|
38
|
-
@after_work_callbacks = []
|
39
|
-
|
40
|
-
@started = false
|
14
|
+
|
15
|
+
def initialize(worker_class, options = nil)
|
16
|
+
if !worker_class.kind_of?(Class) || !worker_class.include?(DatWorkerPool::Worker)
|
17
|
+
raise ArgumentError, "worker class must include `#{DatWorkerPool::Worker}`"
|
18
|
+
end
|
19
|
+
|
20
|
+
options ||= {}
|
21
|
+
num_workers = (options[:num_workers] || DEFAULT_NUM_WORKERS).to_i
|
22
|
+
if num_workers < MIN_WORKERS
|
23
|
+
raise ArgumentError, "number of workers must be at least #{MIN_WORKERS}"
|
24
|
+
end
|
25
|
+
|
26
|
+
@queue = options[:queue] || begin
|
27
|
+
require 'dat-worker-pool/default_queue'
|
28
|
+
DatWorkerPool::DefaultQueue.new
|
29
|
+
end
|
30
|
+
|
31
|
+
@runner = DatWorkerPool::Runner.new({
|
32
|
+
:num_workers => num_workers,
|
33
|
+
:logger => options[:logger],
|
34
|
+
:queue => @queue,
|
35
|
+
:worker_class => worker_class,
|
36
|
+
:worker_params => options[:worker_params]
|
37
|
+
})
|
41
38
|
end
|
42
39
|
|
43
40
|
def start
|
44
|
-
@
|
45
|
-
@queue.start
|
46
|
-
@min_workers.times{ spawn_worker }
|
41
|
+
@runner.start
|
47
42
|
end
|
48
43
|
|
49
|
-
# * All work on the queue is left on the queue. It's up to the controlling
|
50
|
-
# system to decide how it should handle this.
|
51
44
|
def shutdown(timeout = nil)
|
52
|
-
@
|
53
|
-
begin
|
54
|
-
OptionalTimeout.new(timeout){ graceful_shutdown }
|
55
|
-
rescue TimeoutError
|
56
|
-
force_shutdown(timeout, caller)
|
57
|
-
end
|
45
|
+
@runner.shutdown(timeout, caller)
|
58
46
|
end
|
59
47
|
|
60
|
-
# * Always check if all workers are busy before pushing the work because
|
61
|
-
# `@queue.push` can wakeup a worker. If you check after, you can see all
|
62
|
-
# workers are busy because one just wokeup to handle what was just pushed.
|
63
|
-
# This would cause it to spawn a worker when one isn't needed.
|
64
48
|
def add_work(work_item)
|
65
49
|
return if work_item.nil?
|
66
|
-
|
67
|
-
@queue.push work_item
|
68
|
-
spawn_worker if @started && new_worker_needed && !reached_max_workers?
|
50
|
+
@queue.dwp_push work_item
|
69
51
|
end
|
52
|
+
alias :push :add_work
|
70
53
|
|
71
54
|
def work_items
|
72
55
|
@queue.work_items
|
73
56
|
end
|
74
57
|
|
75
|
-
def
|
76
|
-
@
|
77
|
-
end
|
78
|
-
|
79
|
-
def waiting
|
80
|
-
@workers_waiting.count
|
58
|
+
def available_worker_count
|
59
|
+
@runner.available_worker_count
|
81
60
|
end
|
82
61
|
|
83
62
|
def worker_available?
|
84
|
-
|
85
|
-
end
|
86
|
-
|
87
|
-
def all_spawned_workers_are_busy?
|
88
|
-
@workers_waiting.count <= 0
|
89
|
-
end
|
90
|
-
|
91
|
-
def reached_max_workers?
|
92
|
-
@mutex.synchronize{ @spawned >= @max_workers }
|
93
|
-
end
|
94
|
-
|
95
|
-
def on_queue_pop_callbacks; @queue.on_pop_callbacks; end
|
96
|
-
def on_queue_push_callbacks; @queue.on_push_callbacks; end
|
97
|
-
|
98
|
-
def on_queue_pop(&block); @queue.on_pop_callbacks << block; end
|
99
|
-
def on_queue_push(&block); @queue.on_push_callbacks << block; end
|
100
|
-
|
101
|
-
def on_worker_error(&block); @on_worker_error_callbacks << block; end
|
102
|
-
def on_worker_start(&block); @on_worker_start_callbacks << block; end
|
103
|
-
def on_worker_shutdown(&block); @on_worker_shutdown_callbacks << block; end
|
104
|
-
def on_worker_sleep(&block); @on_worker_sleep_callbacks << block; end
|
105
|
-
def on_worker_wakeup(&block); @on_worker_wakeup_callbacks << block; end
|
106
|
-
|
107
|
-
def before_work(&block); @before_work_callbacks << block; end
|
108
|
-
def after_work(&block); @after_work_callbacks << block; end
|
109
|
-
|
110
|
-
private
|
111
|
-
|
112
|
-
def do_work(work_item)
|
113
|
-
@do_work_proc.call(work_item)
|
114
|
-
end
|
115
|
-
|
116
|
-
# * Always shutdown workers before the queue. `@queue.shutdown` wakes up the
|
117
|
-
# workers. If you haven't told them to shutdown before they wakeup then they
|
118
|
-
# won't start their shutdown when they are woken up.
|
119
|
-
# * Use `@workers.first.join until @workers.empty?` instead of `each` to join
|
120
|
-
# all the workers. While we are joining a worker a separate worker can
|
121
|
-
# shutdown and remove itself from the `@workers` array.
|
122
|
-
def graceful_shutdown
|
123
|
-
@workers.each(&:shutdown)
|
124
|
-
@queue.shutdown
|
125
|
-
@workers.first.join until @workers.empty?
|
126
|
-
end
|
127
|
-
|
128
|
-
# * Use `@workers.first until @workers.empty?` pattern instead of `each` to
|
129
|
-
# raise and join all the workers. While we are raising and joining a worker
|
130
|
-
# a separate worker can shutdown and remove itself from the `@workers`
|
131
|
-
# array.
|
132
|
-
# * `rescue false` when joining the workers. Ruby will raise any exceptions
|
133
|
-
# that aren't handled by a thread when its joined. This ensures if the hard
|
134
|
-
# shutdown is raised and not rescued (for example, in the workers ensure),
|
135
|
-
# then it won't cause the forced shutdown to end prematurely.
|
136
|
-
def force_shutdown(timeout, backtrace)
|
137
|
-
error = ShutdownError.new "Timed out shutting down the worker pool " \
|
138
|
-
"(#{timeout} seconds)."
|
139
|
-
error.set_backtrace(backtrace)
|
140
|
-
until @workers.empty?
|
141
|
-
worker = @workers.first
|
142
|
-
worker.raise(error)
|
143
|
-
worker.join rescue false
|
144
|
-
end
|
145
|
-
raise error if @debug
|
63
|
+
@runner.worker_available?
|
146
64
|
end
|
147
65
|
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
def spawn_worker!
|
153
|
-
Worker.new(@queue).tap do |w|
|
154
|
-
w.on_work = proc{ |worker, work_item| do_work(work_item) }
|
155
|
-
|
156
|
-
w.on_error_callbacks = @on_worker_error_callbacks
|
157
|
-
w.on_start_callbacks = @on_worker_start_callbacks
|
158
|
-
w.on_shutdown_callbacks = @on_worker_shutdown_callbacks
|
159
|
-
w.on_sleep_callbacks = @on_worker_sleep_callbacks
|
160
|
-
w.on_wakeup_callbacks = @on_worker_wakeup_callbacks
|
161
|
-
w.before_work_callbacks = @before_work_callbacks
|
162
|
-
w.after_work_callbacks = @after_work_callbacks
|
163
|
-
|
164
|
-
@workers << w
|
165
|
-
@spawned += 1
|
166
|
-
|
167
|
-
w.start
|
168
|
-
end
|
169
|
-
end
|
170
|
-
|
171
|
-
def despawn_worker(worker)
|
172
|
-
@mutex.synchronize{ despawn_worker!(worker) }
|
173
|
-
end
|
174
|
-
|
175
|
-
def despawn_worker!(worker)
|
176
|
-
@spawned -= 1
|
177
|
-
@workers.delete worker
|
178
|
-
end
|
179
|
-
|
180
|
-
class WorkersWaiting
|
181
|
-
attr_reader :count
|
182
|
-
|
183
|
-
def initialize
|
184
|
-
@mutex = Mutex.new
|
185
|
-
@count = 0
|
186
|
-
end
|
187
|
-
|
188
|
-
def increment
|
189
|
-
@mutex.synchronize{ @count += 1 }
|
190
|
-
end
|
191
|
-
|
192
|
-
def decrement
|
193
|
-
@mutex.synchronize{ @count -= 1 }
|
194
|
-
end
|
195
|
-
end
|
196
|
-
|
197
|
-
module OptionalTimeout
|
198
|
-
def self.new(seconds, &block)
|
199
|
-
if seconds
|
200
|
-
SystemTimer.timeout(seconds, TimeoutError, &block)
|
201
|
-
else
|
202
|
-
block.call
|
203
|
-
end
|
204
|
-
end
|
205
|
-
end
|
206
|
-
|
207
|
-
module Logger
|
208
|
-
def self.new(debug)
|
209
|
-
debug ? ::Logger.new(STDOUT) : ::Logger.new(File.open('/dev/null', 'w'))
|
210
|
-
end
|
211
|
-
end
|
212
|
-
|
213
|
-
TimeoutError = Class.new(RuntimeError)
|
214
|
-
|
215
|
-
# * This error should never be "swallowed". If it is caught be sure to
|
216
|
-
# re-raise it so the workers shutdown. Otherwise workers will get killed
|
217
|
-
# (`Thread#kill`) by ruby which causes lots of issues.
|
66
|
+
# this error should never be "swallowed", if it is caught be sure to re-raise
|
67
|
+
# it so the workers shutdown; otherwise workers will get killed
|
68
|
+
# (`Thread#kill`) by ruby which can cause a problems
|
218
69
|
ShutdownError = Class.new(Interrupt)
|
219
70
|
|
220
71
|
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'dat-worker-pool/locked_object'
|
3
|
+
require 'dat-worker-pool/queue'
|
4
|
+
|
5
|
+
class DatWorkerPool
|
6
|
+
|
7
|
+
class DefaultQueue
|
8
|
+
include DatWorkerPool::Queue
|
9
|
+
|
10
|
+
attr_reader :on_push_callbacks, :on_pop_callbacks
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@work_items = LockedArray.new
|
14
|
+
@cond_var = ConditionVariable.new
|
15
|
+
|
16
|
+
@on_push_callbacks = []
|
17
|
+
@on_pop_callbacks = []
|
18
|
+
end
|
19
|
+
|
20
|
+
def work_items; @work_items.values; end
|
21
|
+
def empty?; @work_items.empty?; end
|
22
|
+
|
23
|
+
def on_push(&block); @on_push_callbacks << block; end
|
24
|
+
def on_pop(&block); @on_pop_callbacks << block; end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# wake up workers (`@cond_var.broadcast`) who are sleeping because of `pop`
|
29
|
+
def shutdown!
|
30
|
+
@work_items.with_lock{ @cond_var.broadcast }
|
31
|
+
end
|
32
|
+
|
33
|
+
# add the work item and wakeup (`@cond_var.signal`) the first sleeping
|
34
|
+
# worker (from calling `pop`)
|
35
|
+
def push!(work_item)
|
36
|
+
@work_items.with_lock do |mutex, work_items|
|
37
|
+
work_items << work_item
|
38
|
+
@cond_var.signal
|
39
|
+
end
|
40
|
+
@on_push_callbacks.each{ |p| p.call(self, work_item) }
|
41
|
+
end
|
42
|
+
|
43
|
+
# check if the queue is empty, if so sleep (`@cond_var.wait(@mutex)`) until
|
44
|
+
# signaled (via `push!` or `shutdown!`); once a work item is available pop
|
45
|
+
# it from the front and return it; if shutdown, return `nil` which the
|
46
|
+
# workers will ignore
|
47
|
+
def pop!
|
48
|
+
work_item = @work_items.with_lock do |mutex, work_items|
|
49
|
+
while !self.shutdown? && work_items.empty?
|
50
|
+
@cond_var.wait(mutex)
|
51
|
+
end
|
52
|
+
work_items.shift unless self.shutdown?
|
53
|
+
end
|
54
|
+
@on_pop_callbacks.each{ |p| p.call(self, work_item) } if !work_item.nil?
|
55
|
+
work_item
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|