simple_thread_pool 1.0.0 → 1.0.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ebc46aeec19e5a7572363d8a16e3cb76a1120bfecd31467cc8c232fb5c2480c
4
- data.tar.gz: 93ca2bddde18af8b14c88c2b6d346966c115d398f575467c1d5989b7025af80e
3
+ metadata.gz: 199a48fe6964d0d8ffc112ee6c77da07f49e9111173d36549b6e9e9886a84584
4
+ data.tar.gz: 4b2d69ac3933cbe8b01ca9a8f4ada69bf29b775d8d465772f7434fa1f2a880c1
5
5
  SHA512:
6
- metadata.gz: b14225a854eccedd484d1230dcc57276ee1e71cee72654d4f3030d42c9882c3a8321bdff8b148980b9e8662f8f497e814013368ec5eb26d5894807af5e7738f5
7
- data.tar.gz: b8f1358b7615e9ab485416222b74fd60c8557f7462fa4088127c3aedc4b7dddc1bbcdea8a4a961ff07de73d4a4a2b8044cd05b95a006c3fc07768713a24c3f08
6
+ metadata.gz: 587d77f7a33035cfd359f7ba35496eddbcaad070c38244e333b2e4c653eddbe6973f67a346fdb100ad657b23e1e32ee94bc92ef1d91f93adfa3a2cd5a069882d
7
+ data.tar.gz: ed5801ac689401bef0caa1c66bf5813d8468cb4fca0d28fa3609312914ae195938a181168fa35cf8a06961bd10432fcb3f4b6bce5ea19943f3923db49f843a7b
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## 1.0.1
9
+
10
+ ### Fixed
11
+
12
+ - Threads finishing their work no longer call `Thread#wakeup` on the thread that scheduled them, which could interrupt unrelated `sleep` calls in application code.
13
+ - `finish` now waits for all threads even if a block raised an unhandled exception. Previously `Thread#join` could re-raise the block's exception, causing `finish` to raise and skip joining the remaining threads.
14
+ - Thread scheduling now waits on a condition variable instead of polling in a busy-wait loop, which also removes unsynchronized reads of internal state.
15
+ - An identifier is no longer permanently marked as processing if the thread for it could not be created.
16
+ - `SimpleThreadPool.new` now raises an `ArgumentError` if `max_threads` is less than 1 instead of hanging forever on `execute`.
17
+ - Fatal errors (non-StandardError) raised in threads will now be propagated to the calling thread either in the next call to `execute` or `finish`, instead of being silently swallowed. This allows the calling thread to handle the error appropriately.
18
+ - Removed unnecessary Redis dependency.
19
+
20
+ ## 1.0.0
21
+
22
+ ### Added
23
+
24
+ - Initial release
data/README.md CHANGED
@@ -1,4 +1,8 @@
1
- # SimpleThreadPool
1
+ # Simple Thread Pool
2
+
3
+ [![Continuous Integration](https://github.com/bdurand/simple_thread_pool/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/simple_thread_pool/actions/workflows/continuous_integration.yml)
4
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
+ [![Gem Version](https://badge.fury.io/rb/simple_thread_pool.svg)](https://badge.fury.io/rb/simple_thread_pool)
2
6
 
3
7
  Simple implementation of the thread pool to manage executing tasks in parallel. The thread pool implemented by this code is designed to allow throttled, parallel execution of tasks.
4
8
 
@@ -26,7 +30,7 @@ Or install it yourself as:
26
30
 
27
31
  ```ruby
28
32
  # Create a pool of 10 threads to work with.
29
- thread_pool = SimpleThreadPoolnew(10)
33
+ thread_pool = SimpleThreadPool.new(10)
30
34
 
31
35
  # Execute a block of code in a thread.
32
36
  # If there are no free threads in the pool, then this will block until one is freed up.
@@ -35,8 +39,9 @@ thread_pool.execute do
35
39
  end
36
40
 
37
41
  # Execute a block of code with an identifier.
38
- # The thread pool will not run code with the same identifier in parallel
39
- # and will execute them in the order they are called
42
+ # The thread pool will not run code with the same identifier in parallel.
43
+ # Blocks scheduled with the same identifier from a single thread will be
44
+ # executed in the order they are called.
40
45
  thread_pool.execute("foo") do
41
46
  # Do some work here
42
47
  end
@@ -48,7 +53,7 @@ thread_pool.finish
48
53
 
49
54
  ### Error handling.
50
55
 
51
- All error handling must be conducted inside the execute block. The main thread will not be notified of any exceptions. You can use the `synchronized` method on the thread pool if you need to work data from the main thread.
56
+ All error handling must be conducted inside the execute block. The main thread will not be notified of any exceptions. You can use the `synchronize` method on the thread pool if you need to work with data from the main thread.
52
57
 
53
58
  Example of how you can track any errors in a shared array.
54
59
 
@@ -59,7 +64,7 @@ thread_pool.execute do
59
64
  begin
60
65
  # Do something
61
66
  rescue Error => e
62
- thread_pool.synchronized { errors << e }
67
+ thread_pool.synchronize { errors << e }
63
68
  raise e
64
69
  end
65
70
  end
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.0.1
@@ -1,85 +1,170 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'thread'
4
- require 'set'
5
-
6
3
  # Simple thread pool for executing blocks in parallel in a controlled manner.
7
4
  # Threads are not re-used by the pool to prevent any thread local variables from
8
5
  # leaking out.
9
6
  class SimpleThreadPool
7
+ # Thread variable used to record the exception that terminated a worker thread.
8
+ WORKER_EXCEPTION = :simple_thread_pool_worker_exception
9
+ private_constant :WORKER_EXCEPTION
10
10
 
11
+ # @param max_threads [Integer] The maximum number of threads to spawn.
11
12
  def initialize(max_threads)
13
+ raise ArgumentError, "max_threads must be at least 1" unless max_threads >= 1
12
14
  @max_threads = max_threads
13
15
  @lock = Mutex.new
16
+ @condition = ConditionVariable.new
14
17
  @threads = []
15
18
  @processing_ids = []
19
+ @fatal_exception = nil
16
20
  end
17
21
 
18
22
  # Call this method to spawn a thread to run the block. If the thread pool
19
23
  # is already full, this method will block until a thread is free. The block
20
- # is responsible for handling any exceptions that could be raised.
24
+ # is responsible for handling any StandardError that could be raised.
25
+ #
26
+ # If a block dies from an exception that is not a StandardError (i.e. an
27
+ # Exception that indicates the process itself is no longer healthy, such as
28
+ # NoMemoryError or SystemStackError), the pool is stopped. Blocks passed to
29
+ # this method after that point are not run; instead this method waits for the
30
+ # in-flight threads to finish and then raises the exception that stopped the
31
+ # pool, so that callers never silently lose work. The exception is cleared
32
+ # when it is raised, so it is delivered to exactly one caller and the pool can
33
+ # be used again afterwards.
21
34
  #
22
35
  # The optional id argument can be used to provide an identifier for a block.
23
36
  # If one is provided, processing will be blocked if the same id is already
24
37
  # being processed. This ensures that each unique id is executed one at a time
25
38
  # sequentially.
39
+ #
40
+ # @param id [String, Symbol] An optional identifier for the block.
41
+ # @yield The block to execute in a thread.
42
+ # @return [void]
26
43
  def execute(id = nil, &block)
27
44
  loop do
28
- # Check if a new thread can be added without blocking.
29
- while !can_add_thread?(id)
30
- sleep(0.001)
31
- end
32
-
33
- @lock.synchronize do
34
- # Check again inside a synchronized block if the thread can still be added.
35
- if can_add_thread?(id)
45
+ stopped = @lock.synchronize do
46
+ until @fatal_exception || can_add_thread?(id)
47
+ @condition.wait(@lock)
48
+ end
49
+
50
+ if @fatal_exception
51
+ true
52
+ else
36
53
  @processing_ids << id unless id.nil?
37
- add_thread(id, block)
38
- return
54
+ thread_added = false
55
+ begin
56
+ add_thread(id, block)
57
+ thread_added = true
58
+ ensure
59
+ unless thread_added
60
+ @processing_ids.delete(id) unless id.nil?
61
+ @condition.broadcast
62
+ end
63
+ end
64
+ false
39
65
  end
40
66
  end
67
+
68
+ break unless stopped
69
+
70
+ # The pool has been stopped. Drain the in-flight threads and raise the
71
+ # exception that stopped it. This has to happen outside of the synchronize
72
+ # block above because #finish acquires the same lock. If another caller
73
+ # claimed the exception first then #finish returns normally and the pool
74
+ # is usable again, so retry rather than silently dropping the block.
75
+ finish
41
76
  end
77
+
78
+ nil
42
79
  end
43
80
 
44
81
  # Call this method to block until all current threads have finished executing.
82
+ #
83
+ # Exceptions raised by the blocks are not propagated; they are the block's
84
+ # responsibility to handle. The exception is that if a block died from an
85
+ # exception that was not a StandardError, that exception is re-raised here
86
+ # after every in-flight thread has finished. In-flight threads are always
87
+ # allowed to run to completion rather than being killed, so that they can run
88
+ # their own ensure blocks. That exception is cleared as it is raised, so it is
89
+ # delivered to exactly one caller and the pool can be used again afterwards.
90
+ #
91
+ # @return [void]
45
92
  def finish
46
- active_threads = @lock.synchronize { @threads.select(&:alive?) }
47
- active_threads.each(&:join)
93
+ active_threads = @lock.synchronize do
94
+ # Exclude the calling thread so that this is safe to call from inside a
95
+ # block running in the pool; Thread#join raises if given the current
96
+ # thread. #execute calls this method, so that is reachable indirectly.
97
+ @threads.select { |thread| thread.alive? && !thread.equal?(Thread.current) }
98
+ end
99
+ active_threads.each do |thread|
100
+ thread.join
101
+ rescue Exception => e # standard:disable Lint/RescueException
102
+ # Joining a thread that died with an unhandled exception re-raises that
103
+ # exception; those are the block's responsibility to handle, so they are
104
+ # not propagated here. Worker threads record the exception that killed
105
+ # them, so anything else caught here was raised on the calling thread
106
+ # (e.g. an Interrupt from a signal) and is always re-raised immediately so
107
+ # that the calling thread stays responsive to signals.
108
+ raise unless e.equal?(thread.thread_variable_get(WORKER_EXCEPTION))
109
+ end
110
+
111
+ # Claim the exception under the lock and clear it before raising so that it
112
+ # is delivered to exactly one caller. The pool is usable again afterwards.
113
+ fatal_exception = @lock.synchronize do
114
+ exception = @fatal_exception
115
+ @fatal_exception = nil
116
+ exception
117
+ end
118
+ raise fatal_exception if fatal_exception
119
+
48
120
  nil
49
121
  end
50
122
 
51
123
  # Synchronize data access across the thread pool. This method will block
52
124
  # waiting on the same internal Mutex the thread pool uses to manage scheduling
53
125
  # threads.
126
+ #
127
+ # @yield The block to execute in a synchronized manner.
128
+ # @return [Object] The return value of the block.
54
129
  def synchronize(&block)
55
130
  @lock.synchronize(&block)
56
131
  end
57
-
132
+
58
133
  private
59
-
134
+
60
135
  def can_add_thread?(id)
61
136
  @threads.size < @max_threads && (id.nil? || !@processing_ids.include?(id))
62
137
  end
63
-
138
+
64
139
  # Spawn a thread in this method to ensure that it doesn't accidentally pick up any local variables.
65
140
  def add_thread(id, block)
66
- main_thread = Thread.current
67
-
68
141
  @threads << Thread.new do
142
+ fatal_exception = nil
69
143
  begin
70
144
  block.call
71
145
  # Return nil to ensure no objects are leaked.
72
146
  nil
147
+ rescue Exception => e # standard:disable Lint/RescueException
148
+ # Record the exception so that #finish can tell an exception re-raised
149
+ # by Thread#join apart from one raised on the calling thread. It is
150
+ # re-raised so the normal Thread.report_on_exception behavior is kept.
151
+ Thread.current.thread_variable_set(WORKER_EXCEPTION, e)
152
+ # Anything that isn't a StandardError means the process is no longer
153
+ # healthy, so it stops the pool instead of being left to the block.
154
+ fatal_exception = e unless e.is_a?(StandardError)
155
+ raise
73
156
  ensure
74
157
  @lock.synchronize do
158
+ # Set under the same lock as the broadcast so that callers waiting in
159
+ # #execute see the stopped pool as soon as they wake up.
160
+ @fatal_exception ||= fatal_exception
75
161
  @processing_ids.delete(id) unless id.nil?
76
162
  @threads.delete(Thread.current)
163
+ @condition.broadcast
77
164
  end
78
- main_thread.wakeup if main_thread.alive?
79
165
  end
80
166
  end
81
167
 
82
168
  nil
83
169
  end
84
-
85
170
  end
@@ -1,23 +1,36 @@
1
1
  Gem::Specification.new do |spec|
2
- spec.name = "simple_thread_pool"
3
- spec.version = File.read(File.expand_path("VERSION", __dir__)).chomp
4
- spec.authors = ["Brian Durand"]
5
- spec.email = ["bbdurand@gmail.com"]
2
+ spec.name = "simple_thread_pool"
3
+ spec.version = File.read(File.expand_path("VERSION", __dir__)).strip
4
+ spec.authors = ["Brian Durand"]
5
+ spec.email = ["bbdurand@gmail.com"]
6
6
 
7
- spec.summary = %q{Simple thread pool implementation to manage running tasks in parallel.}
8
- spec.homepage = "https://github.com/bdurand/simple_thread_pool"
9
- spec.license = "MIT"
7
+ spec.summary = "Simple thread pool implementation to manage running tasks in parallel."
8
+ spec.homepage = "https://github.com/bdurand/simple_thread_pool"
9
+ spec.license = "MIT"
10
+
11
+ spec.metadata = {
12
+ "homepage_uri" => spec.homepage,
13
+ "source_code_uri" => spec.homepage,
14
+ "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md"
15
+ }
10
16
 
11
17
  # Specify which files should be added to the gem when it is released.
12
18
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
13
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
14
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
19
+ ignore_files = %w[
20
+ .
21
+ Appraisals
22
+ Gemfile
23
+ Gemfile.lock
24
+ Rakefile
25
+ bin/
26
+ gemfiles/
27
+ spec/
28
+ ]
29
+ spec.files = Dir.chdir(__dir__) do
30
+ `git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } }
15
31
  end
16
- spec.bindir = "exe"
17
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
32
+
18
33
  spec.require_paths = ["lib"]
19
34
 
20
- spec.add_development_dependency "bundler", "~> 1.16"
21
- spec.add_development_dependency "rake", "~> 10.0"
22
- spec.add_development_dependency "rspec", "~> 3.8"
35
+ spec.required_ruby_version = ">= 2.6"
23
36
  end
metadata CHANGED
@@ -1,78 +1,33 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_thread_pool
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
- autorequire:
9
- bindir: exe
8
+ bindir: bin
10
9
  cert_chain: []
11
- date: 2019-02-08 00:00:00.000000000 Z
12
- dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: bundler
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '1.16'
20
- type: :development
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: '1.16'
27
- - !ruby/object:Gem::Dependency
28
- name: rake
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '10.0'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '10.0'
41
- - !ruby/object:Gem::Dependency
42
- name: rspec
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '3.8'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '3.8'
55
- description:
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
56
12
  email:
57
13
  - bbdurand@gmail.com
58
14
  executables: []
59
15
  extensions: []
60
16
  extra_rdoc_files: []
61
17
  files:
62
- - ".gitignore"
63
- - CHANGE_LOG.md
64
- - Gemfile
65
- - LICENSE.txt
18
+ - CHANGELOG.md
19
+ - MIT_LICENSE.txt
66
20
  - README.md
67
- - Rakefile
68
21
  - VERSION
69
22
  - lib/simple_thread_pool.rb
70
23
  - simple_thread_pool.gemspec
71
24
  homepage: https://github.com/bdurand/simple_thread_pool
72
25
  licenses:
73
26
  - MIT
74
- metadata: {}
75
- post_install_message:
27
+ metadata:
28
+ homepage_uri: https://github.com/bdurand/simple_thread_pool
29
+ source_code_uri: https://github.com/bdurand/simple_thread_pool
30
+ changelog_uri: https://github.com/bdurand/simple_thread_pool/blob/main/CHANGELOG.md
76
31
  rdoc_options: []
77
32
  require_paths:
78
33
  - lib
@@ -80,16 +35,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
80
35
  requirements:
81
36
  - - ">="
82
37
  - !ruby/object:Gem::Version
83
- version: '0'
38
+ version: '2.6'
84
39
  required_rubygems_version: !ruby/object:Gem::Requirement
85
40
  requirements:
86
41
  - - ">="
87
42
  - !ruby/object:Gem::Version
88
43
  version: '0'
89
44
  requirements: []
90
- rubyforge_project:
91
- rubygems_version: 2.7.6
92
- signing_key:
45
+ rubygems_version: 4.0.3
93
46
  specification_version: 4
94
47
  summary: Simple thread pool implementation to manage running tasks in parallel.
95
48
  test_files: []
data/.gitignore DELETED
@@ -1,8 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /_yardoc/
4
- /coverage/
5
- /doc/
6
- /pkg/
7
- /spec/reports/
8
- /tmp/
data/CHANGE_LOG.md DELETED
@@ -1,3 +0,0 @@
1
- # 1.0.0
2
-
3
- * Initial release
data/Gemfile DELETED
@@ -1,6 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
-
5
- # Specify your gem's dependencies in simple_thread_pool.gemspec
6
- gemspec
data/Rakefile DELETED
@@ -1,6 +0,0 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
3
-
4
- RSpec::Core::RakeTask.new(:spec)
5
-
6
- task :default => :spec
File without changes