crystal-lang / crystal

The Crystal Programming Language
https://crystal-lang.org
Apache License 2.0
19.33k stars 1.61k forks source link

Ensuring `class_getter` runs exactly once under concurrent access #14905

Open HertzDevil opened 4 weeks ago

HertzDevil commented 4 weeks ago

A class_getter with a block usually runs exactly once:

module Foo
  class_getter(x : Int32) { 1 }

  # same as:
  def self.x : Int32
    if (x = @@x).nil?
      @@x = 1
    else
      x
    end
  end
end

However, if the current fiber is ever suspended, the block might be run multiple times:

module Foo
  class_getter x : Int32 do
    puts "Foo.x" # this runs 5 times
    Fiber.yield
    1
  end
end

ch = Channel(Int32).new
5.times { spawn { ch.send Foo.x } }
5.times { ch.receive }

And if -Dpreview_mt is in effect, the block also gets run more than once, even without the Fiber.yield.

Often, Foo.x represents some kind of cached object that might be expensive to compute, like #14891 and the Unicode data tables; if we also disregard the block's return value, then this would include the various interrupt handlers guarded by Atomic::Flags too. There should be an easier way in the standard library to ensure this block is really run exactly once, regardless of the degree of concurrent access.

Constants are one alternative, but whether they run at program startup or on first access is rather opaque, and also they are slightly broken, such as in #13054.

This could apply to class_property as well, and less likely to the instance variants getter and property.

straight-shoota commented 4 weeks ago

Perhaps we can jerry rig an atomic compare and set on the type id? Then we'll just need an otherwise unused type to signal that someone else is already in the process of calculating.

HertzDevil commented 4 weeks ago

Wouldn't that only work for mixed unions?

straight-shoota commented 4 weeks ago

I suppose we could force a mixed union using an appropriate type to signal work in progress? The value of the class variable would effectively be typeof({{ yield }}) | WorkInProgress | Nil. If the type of the block is a reference type, WorkInProgress could be a struct type and vice versa. That would mean two different signal types and select the appropriate one.