thread_storm 0.5.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/thread_storm.rb CHANGED
@@ -1,55 +1,126 @@
1
1
  require "thread"
2
2
  require "timeout"
3
3
  require "thread_storm/active_support"
4
- require "thread_storm/sentinel"
4
+ require "thread_storm/queue"
5
5
  require "thread_storm/execution"
6
6
  require "thread_storm/worker"
7
7
 
8
+ # Simple but powerful thread pool implementation.
8
9
  class ThreadStorm
9
10
 
11
+ class TimeoutError < Exception; end
12
+
13
+ # Version of ThreadStorm that you are using.
14
+ VERSION = File.read(File.dirname(__FILE__)+"/../VERSION").chomp
15
+
16
+ # Default options found in ThreadStorm.options.
17
+ DEFAULTS = { :size => 2,
18
+ :execute_blocks => false,
19
+ :timeout => nil,
20
+ :timeout_method => Timeout.method(:timeout),
21
+ :timeout_exception => Timeout::Error,
22
+ :default_value => nil,
23
+ :reraise => true }.freeze
24
+
25
+ @options = DEFAULTS.dup
26
+ metaclass.class_eval do
27
+ # Global options.
28
+ attr_reader :options
29
+ end
30
+
31
+ # Options specific to a ThreadStorm instance.
32
+ attr_reader :options
33
+
10
34
  # Array of executions in order as they are defined by calls to ThreadStorm#execute.
11
35
  attr_reader :executions
12
36
 
13
- # Valid options are
14
- # :size => How many threads to spawn. Default is 2.
15
- # :timeout => Max time an execution is allowed to run before terminating it. Default is nil (no timeout).
16
- # :timeout_method => An object that implements something like Timeout.timeout via #call. Default is Timeout.method(:timeout).
17
- # :default_value => Value of an execution if it times out or errors. Default is nil.
18
- # :reraise => True if you want exceptions reraised when ThreadStorm#join is called. Default is true.
19
- # :execute_blocks => True if you want #execute to block until there is an available thread. Default is false.
37
+ # call-seq:
38
+ # new(options = {}) -> thread_storm
39
+ # new(options = {}){ |self| ... } -> thread_storm
40
+ #
41
+ # Valid _options_ are...
42
+ # :size => How many threads to spawn.
43
+ # :timeout => Max time an execution is allowed to run before terminating it. Nil means no timeout.
44
+ # :timeout_method => An object that implements something like Timeout.timeout via #call..
45
+ # :default_value => Value of an execution if it times out or errors..
46
+ # :reraise => True if you want exceptions to be reraised when ThreadStorm#join is called.
47
+ # :execute_blocks => True if you want #execute to block until there is an available thread.
48
+ #
49
+ # For defaults, see DEFAULTS.
50
+ #
51
+ # When given a block, ThreadStorm#join and ThreadStorm#shutdown are called for you. In other words...
52
+ # ThreadStorm.new do |storm|
53
+ # storm.execute{ sleep(1) }
54
+ # end
55
+ # ...is the same as...
56
+ # storm = ThreadStorm.new
57
+ # storm.execute{ sleep(1) }
58
+ # storm.join
59
+ # storm.shutdown
20
60
  def initialize(options = {})
21
- @options = options.option_merge :size => 2,
22
- :timeout => nil,
23
- :timeout_method => Timeout.method(:timeout),
24
- :default_value => nil,
25
- :reraise => true,
26
- :execute_blocks => false
27
- @sentinel = Sentinel.new
28
- @queue = []
61
+ @options = options.reverse_merge(self.class.options)
62
+ @queue = Queue.new(@options[:size], @options[:execute_blocks])
29
63
  @executions = []
30
- @workers = (1..@options[:size]).collect{ Worker.new(@queue, @sentinel, @options) }
64
+ @workers = (1..@options[:size]).collect{ Worker.new(@queue) }
65
+ run{ yield(self) } if block_given?
66
+ end
67
+
68
+ # This is like Execution.new except the default options are specific this ThreadStorm instance.
69
+ # ThreadStorm.options[:timeout]
70
+ # # => nil
71
+ # storm = ThreadStorm.new :timeout => 1
72
+ # execution = storm.new_execution
73
+ # execution.options[:timeout]
74
+ # # => 1
75
+ # execution = ThreadStorm::Execution.new
76
+ # execution.options[:timeout]
77
+ # # => nil
78
+ def new_execution(*args, &block)
79
+
80
+ # It has to be this way because of how options are merged.
81
+
31
82
  if block_given?
32
- yield(self)
33
- join
34
- shutdown
83
+ Execution.new(options.dup).define(*args, &block)
84
+ elsif args.length == 0
85
+ Execution.new(options.dup)
86
+ elsif args.length == 1 and args.first.kind_of?(Hash)
87
+ Execution.new(options.merge(args.first))
88
+ else
89
+ raise ArgumentError, "illegal call-seq"
35
90
  end
36
91
  end
37
92
 
38
- def size #:nodoc:
39
- @options[:size]
93
+ def run
94
+ yield(self)
95
+ join
96
+ shutdown
40
97
  end
41
98
 
42
- # Creates an execution and schedules it to be run by the thread pool.
43
- # Return value is a ThreadStorm::Execution.
99
+ # call-seq:
100
+ # storm.execute(*args){ |*args| ... } -> execution
101
+ # storm.execute(execution) -> execution
102
+ #
103
+ # Schedules an execution to be run (i.e. moves it to the :queued state).
104
+ # When given a block, it is the same as
105
+ # execution = ThreadStorm::Execution.new(*args){ |*args| ... }
106
+ # storm.execute(execution)
44
107
  def execute(*args, &block)
45
- Execution.new(args, default_value, &block).tap do |execution|
46
- @sentinel.synchronize do |e_cond, p_cond|
47
- e_cond.wait_while{ all_workers_busy? } if execute_blocks?
48
- @queue << execution
49
- @executions << execution
50
- p_cond.signal
51
- end
108
+ if block_given?
109
+ execution = new_execution(*args, &block)
110
+ elsif args.length == 1 and args.first.instance_of?(Execution)
111
+ execution = args.first
112
+ else
113
+ raise ArgumentError, "execution or arguments and block expected"
114
+ end
115
+
116
+ @queue.synchronize do |q|
117
+ q.enqueue(execution)
118
+ execution.queued! # This needs to be in here or we'll get a race condition to set the execution's state.
52
119
  end
120
+
121
+ @executions << execution
122
+
123
+ execution
53
124
  end
54
125
 
55
126
  # Block until all pending executions are finished running.
@@ -57,7 +128,6 @@ class ThreadStorm
57
128
  def join
58
129
  @executions.each do |execution|
59
130
  execution.join
60
- raise execution.exception if execution.exception and reraise?
61
131
  end
62
132
  end
63
133
 
@@ -69,19 +139,11 @@ class ThreadStorm
69
139
  # Signals the worker threads to terminate immediately (ignoring any pending
70
140
  # executions) and blocks until they do.
71
141
  def shutdown
72
- @sentinel.synchronize do |e_cond, p_cond|
73
- @queue.replace([:die] * size)
74
- p_cond.broadcast
75
- end
76
- @workers.each{ |worker| worker.thread.join }
142
+ @queue.shutdown
143
+ threads.each{ |thread| thread.join }
77
144
  true
78
145
  end
79
146
 
80
- # Returns workers that are currently running executions.
81
- def busy_workers
82
- @workers.select{ |worker| worker.busy? }
83
- end
84
-
85
147
  # Returns an array of Ruby threads in the pool.
86
148
  def threads
87
149
  @workers.collect{ |worker| worker.thread }
@@ -108,22 +170,4 @@ class ThreadStorm
108
170
  cleared
109
171
  end
110
172
 
111
- private
112
-
113
- def default_value #:nodoc:
114
- @options[:default_value]
115
- end
116
-
117
- def reraise? #:nodoc:
118
- @options[:reraise]
119
- end
120
-
121
- def execute_blocks? #:nodoc:
122
- @options[:execute_blocks]
123
- end
124
-
125
- def all_workers_busy? #:nodoc:
126
- @workers.all?{ |worker| worker.busy? }
127
- end
128
-
129
173
  end
data/test/helper.rb CHANGED
@@ -5,6 +5,7 @@ require "set"
5
5
  $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
6
6
  $LOAD_PATH.unshift(File.dirname(__FILE__))
7
7
  require 'thread_storm'
8
+ require 'timecop'
8
9
 
9
10
  class Test::Unit::TestCase
10
11
 
@@ -0,0 +1,49 @@
1
+ require 'helper'
2
+
3
+ class TestCallbacks < Test::Unit::TestCase
4
+
5
+ # The general premise of this test is that we assign a callback to increment
6
+ # this counter when we enter each state.
7
+ def test_state_callbacks
8
+ counter = 0
9
+ callback = Proc.new{ counter += 1 }
10
+
11
+ storm = ThreadStorm.new
12
+ execution = storm.new_execution{ "done" }
13
+ assert_equal 0, counter
14
+ execution.options.merge! :queued_callback => callback,
15
+ :started_callback => callback,
16
+ :finished_callback => callback
17
+
18
+ execution.queued!
19
+ assert_equal 1, counter
20
+
21
+ execution.execute
22
+ assert_equal 3, counter
23
+ end
24
+
25
+ def test_callback_exception
26
+ storm = ThreadStorm.new :size => 1
27
+ storm.options[:queued_callback] = Proc.new{ raise RuntimeError, "oops" }
28
+ e = storm.execute{ "success" }
29
+ storm.join
30
+ assert_equal false, e.exception?
31
+ assert_equal "success", e.value
32
+ assert_equal true, e.callback_exception?
33
+ assert_equal false, e.callback_exception?(:started)
34
+ assert_equal true, e.callback_exception?(:queued)
35
+ assert_equal RuntimeError, e.callback_exception(:queued).class
36
+ assert_equal "oops", e.callback_exception(:queued).message
37
+ assert storm.threads.all?{ |thread| thread.alive? }
38
+ end
39
+
40
+ def test_initialized_callback
41
+ counter = 0
42
+ callback = Proc.new{ counter += 1 }
43
+
44
+ assert_equal 0, counter
45
+ execution = ThreadStorm::Execution.new :initialized_callback => callback
46
+ assert_equal 1, counter
47
+ end
48
+
49
+ end
@@ -0,0 +1,147 @@
1
+ require 'helper'
2
+
3
+ class TestExecution < Test::Unit::TestCase
4
+
5
+ def setup
6
+ @sign = nil
7
+ @lock = Monitor.new
8
+ @cond = @lock.new_cond
9
+ end
10
+
11
+ def new_execution(*args, &block)
12
+ block = Proc.new{ nil } unless block_given?
13
+ ThreadStorm::Execution.new(*args, &block).tap do |execution|
14
+ execution.options.replace :timeout => nil,
15
+ :timeout_method => Proc.new{ |seconds, &block| Timeout.timeout(seconds, ThreadStorm::TimeoutError, &block) },
16
+ :timeout_exception => ThreadStorm::TimeoutError,
17
+ :default_value => nil,
18
+ :reraise => false
19
+ end
20
+ end
21
+
22
+ def wait_until_sign(i)
23
+ @lock.synchronize do
24
+ @cond.wait_until{ @sign == i }
25
+ end
26
+ end
27
+
28
+ def set_sign(i)
29
+ @lock.synchronize do
30
+ @sign = i
31
+ @cond.signal
32
+ end
33
+ end
34
+
35
+ def test_execute
36
+ execution = new_execution{ 1 }
37
+ execution.execute
38
+ assert ! execution.exception?
39
+ assert ! execution.timeout?
40
+ assert_equal 1, execution.value
41
+ end
42
+
43
+ def test_exception
44
+ execution = new_execution{ raise RuntimeError, "blah"; 1 }
45
+ execution.options[:default_value] = 2
46
+ execution.execute
47
+ assert execution.exception?
48
+ assert ! execution.timeout?
49
+ assert_equal 2, execution.value
50
+ end
51
+
52
+ def test_timeout
53
+ execution = new_execution{ sleep(1); 1 }
54
+ execution.options.merge! :default_value => 2,
55
+ :timeout => 0.001
56
+ execution.execute
57
+ assert ! execution.exception?
58
+ assert execution.timeout?
59
+ assert execution.exception.kind_of?(ThreadStorm::TimeoutError)
60
+ assert_equal 2, execution.value
61
+ end
62
+
63
+ def test_timeout_exception
64
+ timeout_exception = Class.new(RuntimeError)
65
+ execution = new_execution{ sleep(1); 1 }
66
+ execution.options.merge! :default_value => 2,
67
+ :timeout => 0.001,
68
+ :timeout_method => Proc.new{ |secs, &block| Timeout.timeout(secs, timeout_exception){ block.call } },
69
+ :timeout_exception => timeout_exception
70
+ execution.execute
71
+ assert ! execution.exception?
72
+ assert execution.timeout?
73
+ assert execution.exception.kind_of?(timeout_exception)
74
+ assert_equal 2, execution.value
75
+ end
76
+
77
+ def test_states
78
+ execution = new_execution do
79
+ set_sign(1)
80
+ wait_until_sign(2)
81
+ end
82
+
83
+ assert_equal :initialized, execution.state(:sym)
84
+
85
+ execution.queued!
86
+ assert_equal :queued, execution.state(:sym)
87
+
88
+ Thread.new{ execution.execute }
89
+ wait_until_sign(1)
90
+ assert_equal :started, execution.state(:sym)
91
+
92
+ set_sign(2)
93
+ execution.join
94
+ assert_equal :finished, execution.state(:sym)
95
+ end
96
+
97
+ def test_duration
98
+ time, execution = Time.now, nil
99
+ Timecop.freeze(time){ execution = ThreadStorm::Execution.new{ "done" } }
100
+ Timecop.freeze(time += 1){ execution.queued! }
101
+ assert_equal 1, execution.duration(:initialized)
102
+
103
+ # The queued state is still going on, so the duration should change each time we call it.
104
+ Timecop.freeze(time += 2){ assert_equal 2, execution.duration(:queued) }
105
+ Timecop.freeze(time += 3){ assert_equal 5, execution.duration(:queued) }
106
+
107
+ # Make sure duration doesn't crash if we call it for a state that hasn't started yet.
108
+ assert_equal nil, execution.duration(:started)
109
+
110
+ # The execution has been in the queued state for 5 seconds already (see above).
111
+ Timecop.freeze(time += 4){ execution.execute }
112
+ assert_equal 9, execution.duration(:queued)
113
+ end
114
+
115
+ def test_duration_with_skipped_states
116
+ time, execution = Time.now, nil
117
+ Timecop.freeze(time){ execution = ThreadStorm::Execution.new{ "done" } }
118
+ Timecop.freeze(time += 5){ execution.execute } # Skip over the queued state.
119
+ assert_equal 5, execution.duration(:initialized)
120
+ assert_equal nil, execution.duration(:queued)
121
+ end
122
+
123
+ def test_reraise
124
+ klass = Class.new(RuntimeError)
125
+
126
+ execution = new_execution{ nil }
127
+ execution.options[:reraise] = true
128
+ execution.execute
129
+ assert_nothing_raised{ execution.join }
130
+
131
+ execution = new_execution{ raise klass }
132
+ execution.options[:reraise] = true
133
+ execution.execute
134
+ assert_raise(klass){ execution.join }
135
+ end
136
+
137
+ def test_new_with_options
138
+ old_options = ThreadStorm.options.dup
139
+ ThreadStorm.options[:timeout] = 10
140
+ execution = ThreadStorm::Execution.new
141
+ assert_equal 10, execution.options[:timeout]
142
+ execution = ThreadStorm::Execution.new :timeout => 5
143
+ assert_equal 5, execution.options[:timeout]
144
+ ThreadStorm.options.replace(old_options) # Be sure to restore to previous state.
145
+ end
146
+
147
+ end