Skip to content

Commit 9b7fd8d

Browse files
Add fiber safety to crystal/once (crystal-lang#15370)
Removes the global `Mutex` (previously only used when the `preview_mt` flag is defined) that would prevent concurrent initializers to run in parallel, while still being able to detect reentrancy. It works by keeping a list of pending operations (one per `flag` pointer) protected by a global lock that doesn't block other initializers from running. Using a linked-list of stack allocated structs may sound inefficient compared to a `Hash` but there should usually not be many concurrent / parallel / nested lazy initializations that following a few pointers would be significant compared to calculating a hash and (re)allocating memory. This isn't very important for the initialization of class vars and constants which happens sequentially at the start of the program, before we even start threads. But we plan to reuse the feature to implement lazy initializers for class variables (crystal-lang#14905) which can kick in at any time. Introduces the `Crystal::Once` namespace. The initialization entry is now `Crystal::Once.init` instead of `Crystal.once_init`. Reverts the once flag to be a bool so we keep a single implementation. This avoids having two implementations and it will simplify the call site when we protect class getter/property macros with lazy initializers: the flag is always `Bool`, not either `Crystal::Once::State` or `Bool` depending on the compiler. ## CREDITS @BlobCodes (David Keller <davidkeller@tuta.io>) wrote the initial implementation in crystal-lang/crystal@master...BlobCodes:crystal:perf/crystal-once-v2. I mostly removed the fences (SpinLock already deals with memory order), made it compatible with both the enum or bool variants, where the enum allows to skip over checking the operations when processing —maybe not very significant? I shall read his code much more carefully now 🙇 Co-authored-by: David Keller <davidkeller@tuta.io>
1 parent 1cf95a8 commit 9b7fd8d

7 files changed

Lines changed: 120 additions & 113 deletions

File tree

src/compiler/crystal/codegen/class_var.cr

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ class Crystal::CodeGenVisitor
2525
initialized_flag_name = class_var_global_initialized_name(class_var)
2626
initialized_flag = @main_mod.globals[initialized_flag_name]?
2727
unless initialized_flag
28-
initialized_flag = @main_mod.globals.add(@main_llvm_context.int8, initialized_flag_name)
29-
initialized_flag.initializer = @main_llvm_context.int8.const_int(0)
28+
initialized_flag = @main_mod.globals.add(@main_llvm_context.int1, initialized_flag_name)
29+
initialized_flag.initializer = @main_llvm_context.int1.const_int(0)
3030
initialized_flag.linkage = LLVM::Linkage::Internal if @single_module
3131
initialized_flag.thread_local = true if class_var.thread_local?
3232
end
@@ -61,7 +61,7 @@ class Crystal::CodeGenVisitor
6161
initialized_flag_name = class_var_global_initialized_name(class_var)
6262
initialized_flag = @llvm_mod.globals[initialized_flag_name]?
6363
unless initialized_flag
64-
initialized_flag = @llvm_mod.globals.add(llvm_context.int8, initialized_flag_name)
64+
initialized_flag = @llvm_mod.globals.add(llvm_context.int1, initialized_flag_name)
6565
initialized_flag.thread_local = true if class_var.thread_local?
6666
end
6767
end

src/compiler/crystal/codegen/const.cr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ class Crystal::CodeGenVisitor
6464
initialized_flag_name = const.initialized_llvm_name
6565
initialized_flag = @main_mod.globals[initialized_flag_name]?
6666
unless initialized_flag
67-
initialized_flag = @main_mod.globals.add(@main_llvm_context.int8, initialized_flag_name)
68-
initialized_flag.initializer = @main_llvm_context.int8.const_int(0)
67+
initialized_flag = @main_mod.globals.add(@main_llvm_context.int1, initialized_flag_name)
68+
initialized_flag.initializer = @main_llvm_context.int1.const_int(0)
6969
initialized_flag.linkage = LLVM::Linkage::Internal if @single_module
7070
end
7171
initialized_flag

src/compiler/crystal/codegen/once.cr

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,6 @@ class Crystal::CodeGenVisitor
4040
end
4141

4242
state = load(once_state_type, once_state_global)
43-
{% if LibLLVM::IS_LT_150 %}
44-
flag = bit_cast(flag, @llvm_context.int1.pointer) # cast Int8* to Bool*
45-
{% end %}
4643
args = [state, flag, initializer]
4744
end
4845

src/crystal/main.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ module Crystal
5656
# so we explicitly initialize their class vars, then init crystal/once
5757
Thread.init
5858
Fiber.init
59-
Crystal.once_init
59+
Crystal::Once.init
6060
end
6161

6262
# :nodoc:

src/crystal/once.cr

Lines changed: 110 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -7,138 +7,148 @@
77
# with older compiler releases. It is executed only once at the beginning of the
88
# program and, for the legacy implementation, the result is passed on each call
99
# to `__crystal_once`.
10-
#
11-
# In multithread mode a mutex is used to avoid race conditions between threads.
12-
#
13-
# On Win32, `Crystal::System::FileDescriptor#@@reader_thread` spawns a new
14-
# thread even without the `preview_mt` flag, and the thread can also reference
15-
# Crystal constants, leading to race conditions, so we always enable the mutex.
1610

17-
{% if compare_versions(Crystal::VERSION, "1.16.0-dev") >= 0 %}
18-
# This implementation uses an enum over the initialization flag pointer for
19-
# each value to find infinite loops and raise an error.
20-
21-
module Crystal
22-
# :nodoc:
23-
enum OnceState : Int8
24-
Processing = -1
25-
Uninitialized = 0
26-
Initialized = 1
11+
require "crystal/pointer_linked_list"
12+
require "crystal/spin_lock"
13+
14+
module Crystal
15+
# :nodoc:
16+
module Once
17+
struct Operation
18+
include PointerLinkedList::Node
19+
20+
getter fiber : Fiber
21+
getter flag : Bool*
22+
23+
def initialize(@flag : Bool*, @fiber : Fiber)
24+
@waiting = PointerLinkedList(Fiber::PointerLinkedListNode).new
25+
end
26+
27+
def add_waiter(node) : Nil
28+
@waiting.push(node)
29+
end
30+
31+
def resume_all : Nil
32+
@waiting.each(&.value.enqueue)
33+
end
2734
end
2835

29-
{% if flag?(:preview_mt) || flag?(:win32) %}
30-
@@once_mutex = uninitialized Mutex
31-
{% end %}
36+
@@spin = uninitialized SpinLock
37+
@@operations = uninitialized PointerLinkedList(Operation)
3238

33-
# :nodoc:
34-
def self.once_init : Nil
35-
{% if flag?(:preview_mt) || flag?(:win32) %}
36-
@@once_mutex = Mutex.new(:reentrant)
37-
{% end %}
39+
def self.init : Nil
40+
@@spin = SpinLock.new
41+
@@operations = PointerLinkedList(Operation).new
3842
end
3943

40-
# :nodoc:
41-
# Using @[NoInline] so LLVM optimizes for the hot path (var already
42-
# initialized).
43-
@[NoInline]
44-
def self.once(flag : OnceState*, initializer : Void*) : Nil
45-
{% if flag?(:preview_mt) || flag?(:win32) %}
46-
@@once_mutex.synchronize { once_exec(flag, initializer) }
47-
{% else %}
48-
once_exec(flag, initializer)
49-
{% end %}
44+
protected def self.exec(flag : Bool*, &)
45+
@@spin.lock
46+
47+
if flag.value
48+
@@spin.unlock
49+
elsif operation = processing?(flag)
50+
check_reentrancy(operation)
51+
wait_initializer(operation)
52+
else
53+
run_initializer(flag) { yield }
54+
end
5055

5156
# safety check, and allows to safely call `Intrinsics.unreachable` in
5257
# `__crystal_once`
53-
unless flag.value.initialized?
54-
System.print_error "BUG: failed to initialize constant or class variable\n"
55-
LibC._exit(1)
58+
return if flag.value
59+
60+
System.print_error "BUG: failed to initialize class variable or constant\n"
61+
LibC._exit(1)
62+
end
63+
64+
private def self.processing?(flag)
65+
@@operations.each do |operation|
66+
return operation if operation.value.flag == flag
5667
end
5768
end
5869

59-
private def self.once_exec(flag : OnceState*, initializer : Void*) : Nil
60-
case flag.value
61-
in .initialized?
62-
return
63-
in .uninitialized?
64-
flag.value = :processing
65-
Proc(Nil).new(initializer, Pointer(Void).null).call
66-
flag.value = :initialized
67-
in .processing?
70+
private def self.check_reentrancy(operation)
71+
if operation.value.fiber == Fiber.current
72+
@@spin.unlock
6873
raise "Recursion while initializing class variables and/or constants"
6974
end
7075
end
76+
77+
private def self.wait_initializer(operation)
78+
waiting = Fiber::PointerLinkedListNode.new(Fiber.current)
79+
operation.value.add_waiter(pointerof(waiting))
80+
@@spin.unlock
81+
Fiber.suspend
82+
end
83+
84+
private def self.run_initializer(flag, &)
85+
operation = Operation.new(flag, Fiber.current)
86+
@@operations.push pointerof(operation)
87+
@@spin.unlock
88+
89+
yield
90+
91+
@@spin.lock
92+
flag.value = true
93+
@@operations.delete pointerof(operation)
94+
@@spin.unlock
95+
96+
operation.resume_all
97+
end
7198
end
7299

73100
# :nodoc:
74101
#
75-
# Using `@[AlwaysInline]` allows LLVM to optimize const accesses. Since this
76-
# is a `fun` the function will still appear in the symbol table, though it
77-
# will never be called.
78-
@[AlwaysInline]
79-
fun __crystal_once(flag : Crystal::OnceState*, initializer : Void*) : Nil
80-
return if flag.value.initialized?
81-
82-
Crystal.once(flag, initializer)
102+
# Never inlined to avoid bloating the call site with the slow-path that should
103+
# usually not be taken.
104+
@[NoInline]
105+
def self.once(flag : Bool*, initializer : Void*)
106+
Once.exec(flag, &Proc(Nil).new(initializer, Pointer(Void).null))
107+
end
83108

84-
# tell LLVM that it can optimize away repeated `__crystal_once` calls for
85-
# this global (e.g. repeated access to constant in a single funtion);
86-
# this is truly unreachable otherwise `Crystal.once` would have panicked
87-
Intrinsics.unreachable unless flag.value.initialized?
109+
# :nodoc:
110+
#
111+
# NOTE: should also never be inlined, but that would capture the block, which
112+
# would be a breaking change when we use this method to protect class getter
113+
# and class property macros with lazy initialization (the block may return or
114+
# break).
115+
#
116+
# TODO: consider a compile time flag to enable/disable the capture? returning
117+
# from the block is unexpected behavior: the returned value won't be saved in
118+
# the class variable.
119+
def self.once(flag : Bool*, &)
120+
Once.exec(flag) { yield } unless flag.value
88121
end
89-
{% else %}
90-
# This implementation uses a global array to store the initialization flag
91-
# pointers for each value to find infinite loops and raise an error.
92-
93-
module Crystal
94-
# :nodoc:
95-
class OnceState
96-
@rec = [] of Bool*
97-
98-
@[NoInline]
99-
def once(flag : Bool*, initializer : Void*)
100-
unless flag.value
101-
if @rec.includes?(flag)
102-
raise "Recursion while initializing class variables and/or constants"
103-
end
104-
@rec << flag
105-
106-
Proc(Nil).new(initializer, Pointer(Void).null).call
107-
flag.value = true
108-
109-
@rec.pop
110-
end
111-
end
122+
end
112123

113-
{% if flag?(:preview_mt) || flag?(:win32) %}
114-
@mutex = Mutex.new(:reentrant)
115-
116-
@[NoInline]
117-
def once(flag : Bool*, initializer : Void*)
118-
unless flag.value
119-
@mutex.synchronize do
120-
previous_def
121-
end
122-
end
123-
end
124-
{% end %}
125-
end
124+
{% if compare_versions(Crystal::VERSION, "1.16.0-dev") >= 0 %}
125+
# :nodoc:
126+
#
127+
# We always inline this accessor to optimize for the fast-path (already
128+
# initialized).
129+
@[AlwaysInline]
130+
fun __crystal_once(flag : Bool*, initializer : Void*)
131+
return if flag.value
132+
Crystal.once(flag, initializer)
126133

127-
# :nodoc:
128-
def self.once_init : Nil
129-
end
134+
# tells LLVM to assume that the flag is true, this avoids repeated access to
135+
# the same constant or class variable to check the flag and try to run the
136+
# initializer (only the first access will)
137+
Intrinsics.unreachable unless flag.value
130138
end
131-
139+
{% else %}
132140
# :nodoc:
141+
#
142+
# Unused. Kept for backward compatibility with older compilers.
133143
fun __crystal_once_init : Void*
134-
Crystal::OnceState.new.as(Void*)
144+
Pointer(Void).null
135145
end
136146

137147
# :nodoc:
138148
@[AlwaysInline]
139149
fun __crystal_once(state : Void*, flag : Bool*, initializer : Void*)
140150
return if flag.value
141-
state.as(Crystal::OnceState).once(flag, initializer)
151+
Crystal.once(flag, initializer)
142152
Intrinsics.unreachable unless flag.value
143153
end
144154
{% end %}

src/crystal/spin_lock.cr

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ struct Crystal::SpinLock
33
private UNLOCKED = 0
44
private LOCKED = 1
55

6-
{% if flag?(:preview_mt) %}
6+
{% if flag?(:preview_mt) || flag?(:win32) %}
77
@m = Atomic(Int32).new(UNLOCKED)
88
{% end %}
99

1010
def lock
11-
{% if flag?(:preview_mt) %}
11+
{% if flag?(:preview_mt) || flag?(:win32) %}
1212
while @m.swap(LOCKED, :acquire) == LOCKED
1313
while @m.get(:relaxed) == LOCKED
1414
Intrinsics.pause
@@ -18,7 +18,7 @@ struct Crystal::SpinLock
1818
end
1919

2020
def unlock
21-
{% if flag?(:preview_mt) %}
21+
{% if flag?(:preview_mt) || flag?(:win32) %}
2222
@m.set(UNLOCKED, :release)
2323
{% end %}
2424
end

src/prelude.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
# appear in the API docs.
99

1010
# This list requires ordered statements
11-
require "crystal/once"
1211
require "lib_c"
1312
require "macros"
1413
require "object"
14+
require "crystal/once"
1515
require "comparable"
1616
require "exception"
1717
require "iterable"

0 commit comments

Comments
 (0)