Project

General

Profile

Actions

Feature #19058

closed

Introduce `Fiber.inheritable` attributes/variables for dealing with shared state.

Added by ioquatix (Samuel Williams) over 1 year ago. Updated over 1 year ago.

Status:
Closed
Target version:
-
[ruby-core:110302]

Description

There are many instances of programs using globals, thread locals and fiber locals for shared state. Unfortunately these programs often have bugs or unusual behaviour when objects are used on different threads or fibers.

Here is a simple example of the kind of problem that can occur:

class Users
  def each
    return to_enum unless block_given?
    p each: Fiber.current
    yield "A"
    yield "B"
    yield "C"
  end
end

p Users.new.each.zip(Users.new.each)

When the enumeration depends on a fiber-local connection instance (e.g. Thread.current[:connection]) it could unexpectedly break the operation because within the enumerate, the fiber is different leading to missing connection.

In all these cases, the problem can be solved by not relying on implicit/invisible state. Unfortunately, many programs take advantage of process (global), thread or fiber local variables to create more ergonomic interfaces, e.g.

DB.connect(username, password, host)

DB.query("SELECT * FROM BUGS"); # implicit dependency on connection (ideally from shared pool).

Ruby provides several, somewhat confusing options.

Thread.current[:x] # Fiber local.
Thread.current.thread_variable_get(:x) # Thread local.

class Thread
  attr :x
end

Thread.current.x # thread local

class Fiber
  attr :x
end

Fiber.current.x # fiber local

Over the years there have been multiple issues about the above behaviour:

The heart of the issue is: there should be a consistent and convenient way to define state attached to the current execution context, which gets inherited by child execution contexts. Essentially:

let(x: 10) do
  # In every execution context created within this block, unless otherwise changed, `x` is bound to the value 10. That means, threads, fibers, etc.
end

This is sometimes referred to as dynamic scope. This proposal is to introduce a similar dynamic scope for fiber inheritable attributes.

Proposal

We have several units of execution in Ruby, implicitly a process, which has threads which has fibers. Internally, Ruby has an "execution context". Each fiber has an execution context. Each thread has a main fiber and execution context. I propose to introduce an interface for defining a per-fiber context which is semantically very similar to dynamic variables. True dynamic variables are stack frame scoped, but my proposal doesn't go that far. This proposal is similar to Kotlin's Coroutine Contexts https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html and JEP 429: Extent-Local Variables https://openjdk.org/jeps/429.

require 'fiber'

# Compatible shim to introduce proposed behaviour:
class Fiber
  module Inheritable
    def initialize(...)
      super(...)
      
      self.inherit_attributes_from(Fiber.current)
    end
    
    def self.prepended(klass)
      klass.extend(Singleton)
      klass.instance_variable_set(:@inheritable_attributes, Hash.new)
    end
    
    module Singleton
      def inheritable(key, default: nil)
        @inheritable_attributes[:"@#{key}"] = default
      end
      
      def inheritable_attributes
        @inheritable_attributes
      end
    end
    
    def inherit_attributes_from(fiber)
      self.class.inheritable_attributes.each do |name, default|
        value = fiber.instance_variable_get(name) || default
        self.instance_variable_set(name, value)
      end
    end
  end
end

unless Fiber.respond_to?(:inheritable)
  Fiber.prepend(Fiber::Inheritable)
end

This allows you to write the following code:

class Fiber
  inheritable attr_accessor :connection
end

# When lazy enumerator creates internal fiber, the connection and related state will be inherited correctly:
p User.first(100).find_each.zip(Post.first(100).find_each)

This proposed implementation was discussed here too: https://github.com/socketry/fiber-local/pull/1.

Some open questions:

  • Should we introduce the same interface for Thread?
  • (or) Should Thread.new's main fiber inherit from the current Fiber?
  • Kotlin's coroutine context can be shared but is also immutable. This makes it a little bit harder to use. Should we consider their design more closely?
  • Should we have options to bypass inheriting attributes? e.g. Fiber.new(inherit_attributes_from: ...).

Related issues 2 (0 open2 closed)

Related to Ruby master - Feature #19062: Introduce `Fiber#locals` for shared inheritable state.Closedioquatix (Samuel Williams)Actions
Related to Ruby master - Feature #19078: Introduce `Fiber#storage` for inheritable fiber-scoped variables.Closedioquatix (Samuel Williams)Actions
Actions #1

Updated by ioquatix (Samuel Williams) over 1 year ago

  • Description updated (diff)
Actions #2

Updated by ioquatix (Samuel Williams) over 1 year ago

  • Description updated (diff)
Actions #3

Updated by ioquatix (Samuel Williams) over 1 year ago

  • Subject changed from Introduce `Fiber.inheritable` attributes/varaibles for dealing with shared state. to Introduce `Fiber.inheritable` attributes/variables for dealing with shared state.
Actions #4

Updated by ioquatix (Samuel Williams) over 1 year ago

  • Description updated (diff)
Actions #5

Updated by ioquatix (Samuel Williams) over 1 year ago

  • Tracker changed from Bug to Feature
  • Backport deleted (2.7: UNKNOWN, 3.0: UNKNOWN, 3.1: UNKNOWN)
Actions #6

Updated by ioquatix (Samuel Williams) over 1 year ago

  • Description updated (diff)
Actions #7

Updated by ioquatix (Samuel Williams) over 1 year ago

  • Description updated (diff)

Updated by ioquatix (Samuel Williams) over 1 year ago

Here is another interesting idea (suggested by @tenderlovemaking (Aaron Patterson)) which uses binding locals which more closely resembles dynamically scoped variables:

require 'fiber'

module FiberLocalBindings
  attr_reader :parent

  def initialize(&block)
    @parent = Fiber.current
    @block = block
    super()
  end

  def method_missing thing, other = nil
    if thing.to_s =~ /^(\w+)=$/
      @block.binding.local_variable_set($1.to_sym, other)
    else
      @block.binding.local_variable_get(thing) || parent.send(thing)
    end
  end
end

Fiber.prepend(FiberLocalBindings)

x = 123
p outer: binding
f = Fiber.new do
  p Fiber.current.x
  Fiber.current.x = 456
end
f.resume
p x

Assigning to Fiber.current.x appears to change the outermost x which I assume is because Ruby local_variable-set follows the same behaviour as Ruby's normal lexical scoping?

Updated by ioquatix (Samuel Williams) over 1 year ago

Implementing the proposal efficiently would require some more work. The proposed implementation is O(number of inheritable attributes) per fiber creation which is not ideal.

If we had efficient copy-on-write hash table, something like this might work:

class Fiber
  def initialize(locals: Fiber.current.locals.dup)
    # efficient copy-on-write required for mutation.
    @locals = locals
  end

  attr :locals
end
Actions #10

Updated by Eregon (Benoit Daloze) over 1 year ago

  • Related to Feature #19062: Introduce `Fiber#locals` for shared inheritable state. added
Actions #11

Updated by ioquatix (Samuel Williams) over 1 year ago

  • Description updated (diff)

Updated by ioquatix (Samuel Williams) over 1 year ago

  • Status changed from Open to Closed

The performance cost and complexity of this proposal is too high.

Actions #13

Updated by Eregon (Benoit Daloze) over 1 year ago

  • Related to Feature #19078: Introduce `Fiber#storage` for inheritable fiber-scoped variables. added
Actions

Also available in: Atom PDF

Like0
Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0