simple-future 1.0.0.pre

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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/simple-future.rb +270 -0
  3. metadata +92 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 633585003f881b71370f40c29614937966544b01
4
+ data.tar.gz: 8e0826483b73039609f8fd8fba1c4a24fa85ff5c
5
+ SHA512:
6
+ metadata.gz: b0bbd230530b9c0eed697bd881a2482449e111852ed91e108801e79473e4880768e0db7c69e49b979dc03adb77ec52b7ff210a4b5afcb68a36595731bd58f469
7
+ data.tar.gz: c102982aa6f2d765d9ef645f01b3e999fbc2ce865f3361f4224d269955aad0f377962206aef0a3be4742a051c401154a970791a2d2f970e7d4debe54cfef8d6a
@@ -0,0 +1,270 @@
1
+ # SimpleFuture: support for easy process-based concurrency.
2
+ #
3
+ # Copyright (C) 2018 Chris Reuter
4
+ # Released under the MIT license
5
+ # USE AT OWN RISK!
6
+
7
+ # Sanity check; fail if the platform doesn't support Process.fork.
8
+ raise "This Ruby does not implement Process.fork" unless
9
+ Process.respond_to? :fork
10
+
11
+ require 'etc'
12
+ require 'io/wait'
13
+
14
+ # A container holding the (eventual) result of a forked child process
15
+ # once that process finishes. The child process executes the code
16
+ # block that must be passed to the constructor:
17
+ #
18
+ # sf = SimpleFuture.new { do_slow_thing }
19
+ # ... do stuff ...
20
+ # use(sf.value)
21
+ #
22
+ # The code block **must** return a value that can be encoded by
23
+ # `Marshal` and **must not** exit prematurely.
24
+ #
25
+ # Exceptions thrown inside the block will trigger a
26
+ # `SimpleFuture::ChildError` in the parent process but that exception
27
+ # will contain the original in its `cause` field.
28
+ #
29
+ class SimpleFuture
30
+
31
+ # Exception class for errors related to SimpleFuture. All
32
+ # exceptions thrown by SimpleFuture are either `Error` or a
33
+ # subclass.
34
+ class Error < RuntimeError; end
35
+
36
+ # Exception class for the case(s) where the result is of a type that
37
+ # can't be returned (e.g. because it's one of the types
38
+ # `Marshal.dump()` fails on). This can also apply to exceptions; if
39
+ # an exception object holds an unmarshallable value, you'll get one
40
+ # of these instead of a `SimpleFuture::ChildError`.
41
+ class ResultTypeError < Error; end
42
+
43
+ # Exception class for the case where an uncaught exception is thrown
44
+ # in the child process.
45
+ class ChildError < Error
46
+ # If the child process threw an exception, this is it. Otherwise,
47
+ # it's nil.
48
+ attr_reader :cause
49
+
50
+ # @param msg [String] The exception text.
51
+ # @param cause [Exception] If valid, the exception raised in the child
52
+ def initialize(msg, cause = nil)
53
+ super(msg)
54
+ @cause = cause
55
+ end
56
+
57
+ def to_s
58
+ result = super.to_s
59
+ result += " (cause: #{cause.class} '#{@cause.to_s}')" if @cause
60
+ return result
61
+ end
62
+ end
63
+
64
+ # Container for holding a correct result. If an error occurred in
65
+ # the child, it will return the raw Exception instead. Wrapping a
66
+ # value (including an Exception) in a ResultContainer marks it as a
67
+ # correct result.
68
+ class ResultContainer
69
+ attr_reader :value
70
+ def initialize(v)
71
+ @value = v
72
+ end
73
+ end
74
+
75
+ private_constant :ResultContainer # Make this private
76
+
77
+ private # For some reason, YARD shows these if they're not private
78
+
79
+ @@max_tasks = Etc.nprocessors # Max. number of concurrent processes
80
+ @@in_progress = [] # List of active child processes
81
+
82
+ public
83
+
84
+ # In addition to creating a new `SimpleFuture`, the constructor
85
+ # creates a child process and evaluates `action` in it. If the
86
+ # maximum number of child processes would be exceeded, it will block
87
+ # until a process finishes.
88
+ def initialize(&action)
89
+ @readPipe = nil
90
+ @pid = nil
91
+ @complete = false
92
+ @result = nil
93
+
94
+ self.class.all_done? # Reclaim all completed children
95
+ block_until_clear()
96
+ launch(action)
97
+ end
98
+
99
+ # Test if the child process has finished and its result is
100
+ # available.
101
+ #
102
+ # Note that this will only be true after a call to `wait` (i.e. the
103
+ # child process finished **and** its result has been retrieved.) If
104
+ # you want to see if the result is (probably) available, use
105
+ # `check_if_ready`.
106
+ def complete?() return @complete; end
107
+
108
+ # Return the result of the child process, blocking if it is not yet
109
+ # available. Blocking is done by calling `wait`, so the process
110
+ # will be cleaned up.
111
+ def value
112
+ wait
113
+ return @result
114
+ end
115
+
116
+ # Block until the child process finishes, recover its result and
117
+ # clean up the process. `wait` **must** be called for each
118
+ # `SimpleFuture` to prevent zombie processes. In practice, this is
119
+ # rarely a problem since `value` calls `wait` and you usually want
120
+ # to get all of the values. See `wait_for_all`.
121
+ #
122
+ # It is safe to call `wait` multiple times on a `SimpleFuture`.
123
+ #
124
+ # @raise [ChildError] The child process raised an uncaught exception.
125
+ # @raise [ResultTypeError] Marshal cannot encode the result
126
+ # @raise [Error] An error occurred in the IPC system or child process.
127
+ def wait
128
+ # Quit if the child has already exited
129
+ return if complete?
130
+
131
+ # Read the contents; this may block
132
+ data = @readPipe.read
133
+
134
+ # Reap the child process; this shouldn't block for long
135
+ Process.wait(@pid)
136
+
137
+ # And now we're complete, regardless of what happens next. (We
138
+ # set it early so that errors later on won't allow waiting again
139
+ # and associated mystery errors.)
140
+ @complete = true
141
+
142
+ # Close and discard the pipe; we're done with it
143
+ @readPipe.close
144
+ @readPipe = nil
145
+
146
+ # If the child process exited badly, this is an error
147
+ raise Error.new("Error in child process #{@pid}!") unless
148
+ $?.exitstatus == 0 && !data.empty?
149
+
150
+ # Decode the result. If it's an exception object, that's the
151
+ # error that was thrown in the child and that means an error here
152
+ # as well.
153
+ rbox = Marshal.load(data)
154
+ raise rbox if rbox.is_a? ResultTypeError
155
+ raise ChildError.new("Child process failed with an exception.", rbox) if
156
+ rbox.is_a? Exception
157
+
158
+ # Ensure rbox is a ResultContainer. This *probably* can't happen.
159
+ raise Error.new("Invalid result object type: #{rbox.class}") unless
160
+ rbox.is_a? ResultContainer
161
+
162
+ # Aaaaaand, retrieve the value.
163
+ @result = rbox.value
164
+
165
+ return # return nil
166
+ end
167
+
168
+
169
+ # Check if the child process has finished evaluating the block and
170
+ # has a result ready. If `check_if_ready` returns `true`, `wait`
171
+ # will not block when called.
172
+ #
173
+ # Note: `check_if_ready` tests if there's data on the pipe to the
174
+ # child process to see if it has finished. A sufficiently evil
175
+ # child block might be able to cause a true result while still
176
+ # blocking `wait`.
177
+ #
178
+ # Don't do that.
179
+ #
180
+ # @return [Boolean]
181
+ def check_if_ready
182
+ return true if complete?
183
+ return false unless @readPipe.ready?
184
+ wait
185
+ return true
186
+ end
187
+
188
+
189
+ # Return the maximum number of concurrent child processes allowed.
190
+ def self.max_tasks() return @@max_tasks; end
191
+
192
+ # Set the maximum number of concurrent child processes allowed. If
193
+ # set to less than 1, it is interpreted as meaning no limit.
194
+ #
195
+ # It is initially set to the number of available cores as provided
196
+ # by the `Etc` module.
197
+ def self.max_tasks=(value)
198
+ @@max_tasks = value
199
+ end
200
+
201
+ # Test if all instances created so far have run to completion. As a
202
+ # side effect, it will also call `wait` on instances whose child
203
+ # processes are running but have finished (i.e. their
204
+ # `check_if_ready` would return true.) This lets you use it as a
205
+ # non-blocking way to clean up the remaining children.
206
+ def self.all_done?
207
+ @@in_progress.select!{ |sp| !sp.check_if_ready }
208
+ return @@in_progress.size == 0
209
+ end
210
+
211
+ # Wait until all child processes have run to completion and recover
212
+ # their results. Programs should call this before exiting if there
213
+ # is a chance that an instance was created without having `wait`
214
+ # called on it.
215
+ def self.wait_for_all
216
+ @@in_progress.each{|sp| sp.wait}
217
+ @@in_progress = []
218
+ return
219
+ end
220
+
221
+ private
222
+
223
+ # Create a forked child process connected to this one with a pipe,
224
+ # eval `action` and return the marshalled result (or exception, in
225
+ # case of an error) via the pipe. Results are wrapped in a
226
+ # `ResultContainer` so that the parent can distinguish between
227
+ # exceptions and legitimately returned Exception objects.
228
+ def launch(action)
229
+ @readPipe, writePipe = IO.pipe
230
+ @pid = Process.fork do
231
+ @readPipe.close()
232
+
233
+ result = nil
234
+ begin
235
+ result = ResultContainer.new( action.call() )
236
+ rescue Exception => e
237
+ result = e
238
+ end
239
+
240
+ rs = nil
241
+ begin
242
+ rs = Marshal.dump(result)
243
+ rescue TypeError => e
244
+ rv = result
245
+ rv = rv.value if rv.class == ResultContainer
246
+ rs = Marshal.dump(ResultTypeError.new("Type #{rv.class} " +
247
+ "cannot be dumped."))
248
+ end
249
+
250
+ writePipe.write(rs)
251
+ writePipe.close
252
+ exit!(0)
253
+ end
254
+
255
+ writePipe.close
256
+
257
+ @@in_progress.push self
258
+ end
259
+
260
+ # If we're currently at maximum allowed processes, wait until the
261
+ # oldest of them finishes. (TO DO: if possible, make it wait until
262
+ # *any* process exits.)
263
+ def block_until_clear
264
+ return unless @@max_tasks > 0 && @@in_progress.size >= @@max_tasks
265
+
266
+ @@in_progress.shift.wait()
267
+ end
268
+
269
+ end
270
+
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: simple-future
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.pre
5
+ platform: ruby
6
+ authors:
7
+ - Chris Reuter
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-01-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.7'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 3.7.0
23
+ type: :development
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '3.7'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 3.7.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: yard
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.9.12
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 0.9.12
43
+ type: :development
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: 0.9.12
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 0.9.12
53
+ description: |2
54
+ SimpleFuture is class that simplifies coarse-grained concurrency using
55
+ processes instead of threads.
56
+
57
+ Each instance represents the future result of a block that is passed
58
+ to it. The block is evaluated in a forked child process and its result
59
+ is returned to the SimpleFuture object. This only works on Ruby
60
+ implementations that provide Process.fork().
61
+ email: chris@blit.ca
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - lib/simple-future.rb
67
+ homepage: https://github.com/suetanvil/simple-future
68
+ licenses:
69
+ - MIT
70
+ metadata: {}
71
+ post_install_message:
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 2.2.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">"
83
+ - !ruby/object:Gem::Version
84
+ version: 1.3.1
85
+ requirements:
86
+ - A version of Ruby that implements Process.fork
87
+ rubyforge_project:
88
+ rubygems_version: 2.6.14
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: A Future class for simple process-based concurrency.
92
+ test_files: []