async_futures 0.1.2

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.
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nocov:
4
+ # The Ractor API was different before version 4.x of Ruby.
5
+ if /^3\./ === RUBY_VERSION
6
+ raise LoadError.new("'async_futures/ractor_executor' is not supported in Ruby version '#{RUBY_VERSION}'")
7
+ end
8
+
9
+ # :nocov:
10
+
11
+ require_relative 'executor'
12
+
13
+ require 'etc'
14
+ require 'set' # rubocop:disable Lint/RedundantRequireStatement
15
+
16
+ module AsyncFutures
17
+ # `Executor` implementation based on `Thread` primitives
18
+ # that uses a pool of up to `max_workers` to execute calls concurrently.
19
+ #
20
+ # `RactorExecutor` specific submission considerations:
21
+ #
22
+ # For `RactorExecutor` the tasks are never run immediately upon submission.
23
+ # They are placed into a work queue
24
+ # to be picked up later by worker threads.
25
+ #
26
+ # This does _not_ guarantee
27
+ # that any particular task will be run concurrently
28
+ # with any other particular task;
29
+ # that is dependent on how many worker threads and tasks there are
30
+ # at any given point in time.
31
+ class RactorExecutor # rubocop:disable Metrics/ClassLength
32
+ include Executor
33
+
34
+ # Create a new `RactorExecutor`.
35
+ #
36
+ # Uses a pool of up to `max_workers`
37
+ # to execute tasks concurrently.
38
+ # If no value is given for `max_workers`
39
+ # it will default to `[32, Etc.nprocessors + 4].min`.
40
+ # Workers are spawned lazily as needed
41
+ # when tasks are added to the work queue.
42
+ #
43
+ # The parameter `worker_name_prefix` can be used
44
+ # to optionally add a prefix to generated `Thread` names.
45
+ #
46
+ # If the `move_result` keyword argument is `true`,
47
+ # results from worker ractors will be moved instead of copied.
48
+ # Moving is faster than copying,
49
+ # but less safe
50
+ # if the worker ractor keeps the values around for some reason
51
+ # (in a cache, for example).
52
+ # If you aren't doing something like caching inside workers
53
+ # you are probably safe to set this to `true`.
54
+ #
55
+ # If the `move_args` keyword argument is `true`,
56
+ # `args` and `kwargs` will be moved instead of copied
57
+ # from the submitting ractor to the worker ractors.
58
+ # Moving is faster than copying,
59
+ # but is even less safe than `move_result`
60
+ # because the submitting ractor
61
+ # is more likely to have kept references to the submitted values.
62
+ # You should only set this to `true`
63
+ # if you are absolutely certain that submitted values
64
+ # have no remaining references in the submitting ractor
65
+ # otherwise the submitting ractor will error when accessing them later.
66
+ def initialize( # rubocop:disable Metrics/AbcSize,Metrics/ParameterLists
67
+ max_workers: nil,
68
+ worker_name_prefix: nil,
69
+ move_result: false,
70
+ move_args: false,
71
+ make_args_shareable: false,
72
+ copy_args: false
73
+ )
74
+ if copy_args && !make_args_shareable
75
+ raise ArgumentError.new('`copy_args` cannot be true unless `make_args_shareable` is also true')
76
+ end
77
+
78
+ @max_workers = (max_workers || [32, Etc.nprocessors + 4].min).to_i
79
+ @worker_name_prefix = worker_name_prefix
80
+
81
+ # This value is passed into worker Ractors.
82
+ # If the caller passed something not shareable,
83
+ # it would error when we spawn workers later.
84
+ # Boolean values are always safely shareable
85
+ # and since we only care about the truthiness of this value
86
+ # double negation makes sense here.
87
+ @move_result = !!move_result # rubocop:disable Style/DoubleNegation
88
+
89
+ @move_args = move_args
90
+ @make_args_shareable = make_args_shareable
91
+ @copy_args = copy_args
92
+ @mutex = Thread::Mutex.new
93
+ @condition = Thread::ConditionVariable.new
94
+ @tasks = Thread::Queue.new
95
+ @worker_tasks_ports = Thread::Queue.new
96
+
97
+ # All private variables after this point
98
+ # require synchronization to safely interact with.
99
+ @results_ports = {}
100
+ @worker_to_task_port_map = ObjectSpace::WeakKeyMap.new
101
+ @futures = {}
102
+
103
+ @pool = Set.new
104
+ @worker_count = 0
105
+
106
+ @task_feeder = nil
107
+ @result_feeder = nil
108
+
109
+ # The inter-thread communication between these is necessary for shutdown,
110
+ # so even if nothing is submitted, we still need these to exist for now.
111
+ maybe_spawn_task_feeder
112
+ maybe_spawn_result_feeder
113
+
114
+ at_exit { shutdown(wait: false) }
115
+ end
116
+
117
+ # Asynchronously submit a task for execution.
118
+ #
119
+ # See `AsyncFutures::Executor.submit` method for full documentation.
120
+ def submit(*args, **kwargs, &block)
121
+ raise ArgumentError.new('No block given') unless block
122
+
123
+ Future.new.tap do |future|
124
+ # Attempt to make everything shareable upon submit
125
+ # so that if making shareable would raise an exception
126
+ # the caller can know immediately
127
+ # that a given value won't work.
128
+ # Otherwise the errors could easily get swallowed in a background thread
129
+ # and the caller would never know.
130
+ sh_block = Ractor.shareable_proc(&block)
131
+ sh_args = @make_args_shareable ? Ractor.make_shareable(args, copy: @copy_args) : args
132
+ sh_kwargs = @make_args_shareable ? Ractor.make_shareable(kwargs, copy: @copy_args) : kwargs
133
+ ractor_task = [future, sh_block, sh_args, sh_kwargs]
134
+
135
+ @tasks.push(ractor_task)
136
+ maybe_spawn_worker
137
+ maybe_spawn_task_feeder
138
+ maybe_spawn_result_feeder
139
+ rescue ClosedQueueError
140
+ raise 'RactorExecutor instance is shutdown'
141
+ end
142
+ end
143
+
144
+ # :nocov:
145
+
146
+ # Always returns `true`
147
+ # for `RactorExecutor`.
148
+ def support_concurrency?
149
+ true
150
+ end
151
+
152
+ # :nocov:
153
+
154
+ # Shutdown `RactorExecutor` instance.
155
+ #
156
+ # See `AsyncFutures::Executor.shutdown` for full documentation.
157
+ def shutdown(wait: true, cancel_futures: false, &block)
158
+ block&.call(self)
159
+ ensure
160
+ unless check_and_set_shutdown!
161
+ if cancel_futures
162
+ while (task = @tasks.pop)
163
+ future = task[0]
164
+ future.cancel
165
+ end
166
+ end
167
+
168
+ synchronize { wait_until { @worker_tasks_ports.closed? && @worker_tasks_ports.empty? } } if wait
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ def synchronize(&block)
175
+ @mutex.synchronize do
176
+ block.call
177
+ ensure
178
+ @condition.broadcast
179
+ end
180
+ end
181
+
182
+ def wait_until
183
+ @condition.wait(@mutex) until yield
184
+ end
185
+
186
+ # Returns the current shutdown state,
187
+ # then sets internal shutdown state to `true`.
188
+ # This is all done atomically to avoid race conditions.
189
+ def check_and_set_shutdown!
190
+ synchronize do
191
+ return true if @tasks.closed?
192
+
193
+ @tasks.close
194
+ return false
195
+ end
196
+ end
197
+
198
+ def new_worker_name
199
+ if @worker_name_prefix
200
+ "#{@worker_name_prefix}_#{@worker_count += 1}"
201
+ else
202
+ "#{self.class.name}_#{object_id}_worker_#{@worker_count += 1}"
203
+ end
204
+ end
205
+
206
+ def maybe_spawn_task_feeder
207
+ synchronize { spawn_task_feeder unless @task_feeder }
208
+ end
209
+
210
+ def spawn_task_feeder # rubocop:disable Metrics/AbcSize
211
+ @task_feeder = Thread.new("task_feeder_#{object_id}") do |feeder_name|
212
+ Thread.current.name = feeder_name
213
+
214
+ while (task = @tasks.pop)
215
+ future, block, args, kwargs = task
216
+
217
+ next unless future.set_running_or_notify_cancel
218
+
219
+ next_tasks_port = @worker_tasks_ports.pop
220
+
221
+ # `block` was already made shareable
222
+ # in the submitting thread.
223
+ # `args` and `kwargs` _may_ have been made shareable already.
224
+ # `object_id` is an `Integer`
225
+ # and thus is inherently immutable and shareable.
226
+ ractor_task = [future.object_id, block, args, kwargs].freeze
227
+ next_tasks_port.send(ractor_task, move: @move_args)
228
+
229
+ synchronize do
230
+ @futures[future.object_id] = future # rubocop:disable Lint/HashCompareByIdentity
231
+ end
232
+ end
233
+
234
+ # once the task queue closes
235
+ # that means the executor is shutdown.
236
+ # We need to shutdown all workers until
237
+ # the worker queue gets closed by the `@results_feeder`.
238
+ while (next_tasks_port = @worker_tasks_ports.pop)
239
+ next_tasks_port.send(:shutdown)
240
+ end
241
+ ensure
242
+ synchronize do
243
+ @task_feeder = nil
244
+ end
245
+ end
246
+ end
247
+
248
+ def maybe_spawn_result_feeder
249
+ synchronize { spawn_result_feeder unless @result_feeder }
250
+ end
251
+
252
+ def spawn_result_feeder # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
253
+ @result_feeder = Thread.new("result_feeder_#{object_id}") do |feeder_name|
254
+ Thread.current.name = feeder_name
255
+
256
+ loop do
257
+ break_loop, results_ports_keys = synchronize do
258
+ wait_until { !@results_ports.empty? || (@pool.empty? && @tasks.closed? && @tasks.empty?) }
259
+
260
+ [@results_ports.empty? && @pool.empty? && @tasks.closed? && @tasks.empty?, @results_ports.keys]
261
+ end
262
+
263
+ break if break_loop
264
+
265
+ port, msg = Ractor.select(*results_ports_keys)
266
+
267
+ case msg
268
+ when :exited
269
+ synchronize do
270
+ worker = @results_ports[port]
271
+ @pool.delete worker
272
+ @results_ports.delete(port)
273
+ end
274
+ port.close
275
+ else # Must be an Array
276
+ future_id, type, value = msg
277
+
278
+ future = synchronize { @futures.delete(future_id) { raise "future_id not found #{future_id}" } }
279
+
280
+ future.set_exception(value) if type.equal? :exception
281
+ future.set_result(value) if type.equal? :result
282
+
283
+ tasks_port = synchronize do
284
+ worker = @results_ports[port]
285
+ @worker_to_task_port_map[worker]
286
+ end
287
+ @worker_tasks_ports.push tasks_port
288
+ end
289
+ end
290
+
291
+ # We synchronize here so that we broadcast the condition afterward.
292
+ synchronize { @worker_tasks_ports.close }
293
+ end
294
+ end
295
+
296
+ # Only spawn a worker if one is needed.
297
+ def maybe_spawn_worker
298
+ # synchronize when interacting directly with @pool
299
+ spawn_worker if synchronize { @pool.empty? || (!@tasks.empty? && @pool.size < @max_workers) }
300
+ end
301
+
302
+ # Always spawn a worker
303
+ def spawn_worker # rubocop:disable Metrics/AbcSize
304
+ new_results_port = Ractor::Port.new
305
+
306
+ # Coverage doesn't currently work outside the main Ractor,
307
+ # so just skip it for now.
308
+ # :nocov:
309
+ worker = Ractor.new(
310
+ new_results_port,
311
+ @move_result,
312
+ name: new_worker_name
313
+ ) do |results_port, move_result|
314
+ tasks_port = Ractor::Port.new
315
+
316
+ results_port.send(tasks_port)
317
+
318
+ Ractor.current.default_port.close
319
+
320
+ loop do
321
+ case (task = tasks_port.receive)
322
+ when :shutdown
323
+ break
324
+ when Array
325
+ future_id, block, args, kwargs = task
326
+ else
327
+ raise RactorError.new("Unknown message received: #{task}")
328
+ end
329
+
330
+ begin
331
+ result = block.call(*args, **kwargs)
332
+ rescue Exception => e # rubocop:disable Lint/RescueException
333
+ results_port.send([future_id, :exception, e], move: move_result)
334
+ else
335
+ results_port.send([future_id, :result, result], move: move_result)
336
+ end
337
+ end
338
+ ensure
339
+ tasks_port.close
340
+ end
341
+ # :nocov:
342
+
343
+ new_tasks_port = new_results_port.receive
344
+ worker.monitor new_results_port
345
+ synchronize do
346
+ @results_ports[new_results_port] = worker
347
+ @pool.add worker
348
+ @worker_to_task_port_map[worker] = new_tasks_port
349
+ end
350
+ @worker_tasks_ports.push new_tasks_port
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'executor'
4
+
5
+ require 'etc'
6
+ require 'set' # rubocop:disable Lint/RedundantRequireStatement
7
+
8
+ module AsyncFutures
9
+ # `Executor` implementation based on `Thread` primitives
10
+ # that uses a pool of up to `max_workers` to execute calls concurrently.
11
+ #
12
+ # `ThreadExecutor` specific submission considerations:
13
+ #
14
+ # For `ThreadExecutor`, with no arguments passed,
15
+ # the tasks are not run immediately upon submission.
16
+ # They are placed into a work queue
17
+ # to be picked up later by worker threads.
18
+ #
19
+ # This does _not_ guarantee
20
+ # that any particular task will be run concurrently
21
+ # with any other particular task;
22
+ # that is dependent on how many worker threads and tasks there are
23
+ # at any given point in time
24
+ # and whether the `strict_concurrency` argument is passed.
25
+ class ThreadExecutor # rubocop:disable Metrics/ClassLength
26
+ include Executor
27
+
28
+ # Create a new `ThreadExecutor`.
29
+ #
30
+ # Uses a pool of up to `max_workers`
31
+ # to execute tasks concurrently.
32
+ # If no value is given for `max_workers`
33
+ # it will default to `[32, Etc.nprocessors + 4].min`.
34
+ # Workers are spawned lazily as needed
35
+ # when tasks are added to the work queue.
36
+ #
37
+ # The `strict_concurrency` argument
38
+ # changes the behavior of both `submit` and `submit_concurrent`.
39
+ # When the argument is `false`
40
+ # then both methods will just put tasks on a queue
41
+ # and assume they will get picked up later.
42
+ # When the argument is `true` then the following happens:
43
+ #
44
+ # - `submit`: if adding another task to the queue
45
+ # would make more tasks than available (or potential) workers,
46
+ # then the task is run immediately
47
+ # and a completed future is returned to the caller.
48
+ # - `submit_concurrent`: if adding another task to the queue
49
+ # would make more tasks than available (or potential) workers,
50
+ # then a `NoConcurrencyError` is raised.
51
+ #
52
+ # With `strict_concurrency: false`
53
+ # you can do interesting/dangerous things.
54
+ # For example, you can add tasks to the executor
55
+ # from within a executor worker thread,
56
+ # even if the max worker count is only `1`.
57
+ # If you think through this scenario
58
+ # you will realize that joining on the returned future
59
+ # will deadlock the worker thread.
60
+ #
61
+ # This defaults to `false` precisely because
62
+ # scenarios like this are uncommon.
63
+ # The most common scenario is firing of many tasks
64
+ # from the main thread of execution
65
+ # that do not interact other than to return a value
66
+ # to the main thread.
67
+ #
68
+ # A scenario where you might want `strict_concurrency` to be `true`:
69
+ # you have client and server tasks
70
+ # and they *must* run concurrent to each other in order to work correctly.
71
+ #
72
+ # Consider this pseudocode, for example:
73
+ #
74
+ # ```ruby
75
+ # ThreadExecutor.new(max_workers: 1, strict_concurrency: true).shutdown do |executor|
76
+ # # ... other work, potentially using executor ...
77
+ #
78
+ # # This can fail if all workers are busy. Good! We want it to.
79
+ # # It doesn't make sense to run the client code afterward
80
+ # # if the server isn't first running concurrently.
81
+ # executor.submit_concurrent { Server.new.listen() }
82
+ #
83
+ # # The client doesn't *need* to run concurrently;
84
+ # # It is logically correct to run it either concurrently OR immediately,
85
+ # # so we use `submit` instead of `submit_concurrent` for client code.
86
+ # executor.submit do
87
+ # client = Client.new
88
+ # client.ping()
89
+ # # ... interact with `client` while server runs in background concurrently ...
90
+ # ensure
91
+ # client.signal_server_shutdown()
92
+ # end
93
+ #
94
+ # # ... maybe more code after ...
95
+ # end
96
+ # ```
97
+ #
98
+ # If the `reap_after` keyword argument is given,
99
+ # worker threads will be shut down
100
+ # if they haven't received any work after this amount of seconds.
101
+ # If it is `nil` or not given,
102
+ # they will not be reaped until the `ThreadExecutor` instance is `shutdown`.
103
+ #
104
+ # The parameter `worker_name_prefix` can be used
105
+ # to optionally add a prefix to generated `Thread` worker names.
106
+ def initialize(
107
+ max_workers: nil,
108
+ strict_concurrency: false,
109
+ reap_after: nil,
110
+ worker_name_prefix: nil
111
+ )
112
+ @max_workers = (max_workers || [32, Etc.nprocessors + 4].min).to_i
113
+ @strict_concurrency = strict_concurrency
114
+ @reap_after = reap_after
115
+ @worker_name_prefix = worker_name_prefix
116
+ @mutex = Thread::Mutex.new
117
+ @tasks = Thread::Queue.new
118
+
119
+ # Set Hash value to `true` when a worker is running
120
+ # and `false` otherwise.
121
+ @pool = {}
122
+ @worker_count = 0
123
+
124
+ at_exit { shutdown(wait: false) }
125
+ end
126
+
127
+ # Asynchronously submit a task for execution.
128
+ #
129
+ # May run task immediately
130
+ # and return a completed `Future`
131
+ # under certain circumstances.
132
+ #
133
+ # See `AsyncFutures::Executor.submit` method for full documentation.
134
+ def submit(*args, **kwargs, &block)
135
+ raise ArgumentError.new('No block given') unless block
136
+
137
+ Future.new.tap do |f|
138
+ f.complete(*args, **kwargs, &block) unless queue_task(f, *args, **kwargs, &block)
139
+ rescue ClosedQueueError
140
+ raise 'ThreadExecutor instance is shutdown'
141
+ end
142
+ end
143
+
144
+ # Submit a task for concurrent execution.
145
+ #
146
+ # Will raise `NoConcurrencyError`
147
+ # if it is not possible
148
+ # to run the task concurrently
149
+ # with other already scheduled tasks.
150
+ #
151
+ # See `AsyncFutures::Executor.submit_concurrent` method for full documentation.
152
+ def submit_concurrent(*args, **kwargs, &block)
153
+ raise ArgumentError.new('No block given') unless block
154
+
155
+ Future.new.tap do |f|
156
+ raise NoConcurrencyError.new('Tasks exceed potential workers') unless queue_task(f, *args, **kwargs, &block)
157
+ rescue ClosedQueueError
158
+ raise 'ThreadExecutor instance is shutdown'
159
+ end
160
+ end
161
+
162
+ # Return the current size of the worker pool
163
+ def pool_size
164
+ synchronize { @pool.size }
165
+ end
166
+
167
+ # :nocov:
168
+
169
+ # Always returns `true`
170
+ # for `ThreadExecutor`.
171
+ def support_concurrency?
172
+ true
173
+ end
174
+
175
+ # :nocov:
176
+
177
+ # Shutdown `ThreadExecutor` instance.
178
+ #
179
+ # See `AsyncFutures::Executor.shutdown` for full documentation.
180
+ def shutdown(wait: true, cancel_futures: false, &block)
181
+ block&.call(self)
182
+ ensure
183
+ unless check_and_set_shutdown!
184
+ if cancel_futures
185
+ while (task = @tasks.pop)
186
+ future = task[0]
187
+ future.cancel
188
+ end
189
+ end
190
+
191
+ if wait
192
+ synchronize { @pool.dup }.each do |thread|
193
+ thread.join
194
+ synchronize { @pool.delete(thread) }
195
+ end
196
+ end
197
+ end
198
+ end
199
+
200
+ private
201
+
202
+ def synchronize(&)
203
+ @mutex.synchronize(&)
204
+ end
205
+
206
+ # Returns the current shutdown state,
207
+ # then sets internal shutdown state to `true`.
208
+ # This is all done atomically to avoid race conditions.
209
+ def check_and_set_shutdown!
210
+ synchronize do
211
+ return true if @tasks.closed?
212
+
213
+ @tasks.close
214
+ return false
215
+ end
216
+ end
217
+
218
+ # Attempt to queue task.
219
+ # Return `true` if successful, `false` otherwise.
220
+ #
221
+ # Raises `ClosedQueueError` if the task queue is closed.
222
+ #
223
+ # If `@strict_concurrency` is `false`,
224
+ # this method always queues the task.
225
+ #
226
+ # If `@strict_concurrency` is `true`,
227
+ # task may or may not be queued
228
+ # based on whether there are any potentially available workers.
229
+ #
230
+ # May spawn a new worker, if the task was queued.
231
+ def queue_task(future, *args, **kwargs, &block)
232
+ queued = if @strict_concurrency
233
+ synchronize do
234
+ potential_workers = (@max_workers - @pool.size) + @pool.values.count(&:!)
235
+ if (@tasks.size + 1) <= potential_workers
236
+ @tasks.push([future, block, args, kwargs])
237
+ true
238
+ else
239
+ raise ClosedQueueError if @tasks.closed?
240
+
241
+ false
242
+ end
243
+ end
244
+ else
245
+ @tasks.push([future, block, args, kwargs])
246
+ true
247
+ end
248
+
249
+ queued.tap { maybe_spawn_worker if queued }
250
+ end
251
+
252
+ # Only spawn a worker if one is needed.
253
+ def maybe_spawn_worker
254
+ # synchronize when interacting directly with @pool
255
+ spawn_worker if !@tasks.empty? && synchronize { @pool.size } < @max_workers
256
+ end
257
+
258
+ def new_worker_name
259
+ synchronize do
260
+ if @worker_name_prefix
261
+ "#{@worker_name_prefix}_#{@worker_count += 1}"
262
+ else
263
+ "#{self.class.name}_#{object_id}_worker_#{@worker_count += 1}"
264
+ end
265
+ end
266
+ end
267
+
268
+ # Always spawn a worker
269
+ def spawn_worker # rubocop:disable Metrics/AbcSize
270
+ thread = Thread.new do
271
+ Thread.current.name = new_worker_name
272
+ while (task = @tasks.pop(timeout: @reap_after))
273
+ synchronize { @pool[Thread.current] = true }
274
+
275
+ tfuture, tblock, targs, tkwargs = task
276
+ tfuture.complete(*targs, **tkwargs, &tblock)
277
+
278
+ synchronize { @pool[Thread.current] = false }
279
+ end
280
+ ensure
281
+ synchronize { @pool.delete Thread.current }
282
+ end
283
+ synchronize { @pool[thread] ||= false }
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsyncFutures
4
+ # Version of the library as a String instance.
5
+ VERSION = '0.1.2'
6
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Library to create Future instances.
4
+ #
5
+ # Has Executor implementations for Ractor, Thread, and Fiber primitives.
6
+ module AsyncFutures
7
+ end
8
+
9
+ # In order of dependency (roughly)
10
+ require_relative 'async_futures/logger'
11
+ require_relative 'async_futures/version'
12
+ require_relative 'async_futures/error'
13
+ require_relative 'async_futures/future'
14
+ require_relative 'async_futures/executor'
15
+ require_relative 'async_futures/fiber_executor'
16
+ require_relative 'async_futures/thread_executor'
17
+
18
+ # ractor executor is only support in version 4.x or greater,
19
+ # so it must be required explicitly.
20
+ # require_relative 'async_futures/ractor_executor'
@@ -0,0 +1,4 @@
1
+ module AsyncFutures
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end