Skip to content

Commit 360ce33

Browse files
Add thread safety to default random (#16174)
`Random::DEFAULT` is not an alias for the `Random::PCG32` default implementation but a global instance of it. It's initialized once then shared across all threads, but the algorithm itself is not thread safe (two or more threads generating a random number will mess with the internal state). Co-authored-by: Johannes Müller <straightshoota@gmail.com>
1 parent 79c6796 commit 360ce33

File tree

9 files changed

+45
-15
lines changed

9 files changed

+45
-15
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ override FLAGS += -D strict_multi_assign -D preview_overload_order $(if $(releas
4646
# NOTE: USE_PCRE1 is only used for testing compatibility with legacy environments that don't provide libpcre2.
4747
# Newly built compilers should never be distributed with libpcre to ensure syntax consistency.
4848
override COMPILER_FLAGS += $(if $(interpreter),,-Dwithout_interpreter )$(if $(docs_sanitizer),,-Dwithout_libxml2 ) -Dwithout_openssl -Dwithout_zlib $(if $(USE_PCRE1),-Duse_pcre,-Duse_pcre2)
49-
SPEC_WARNINGS_OFF := --exclude-warnings spec/std --exclude-warnings spec/compiler --exclude-warnings spec/primitives --exclude-warnings src/float/printer
49+
SPEC_WARNINGS_OFF := --exclude-warnings spec/std --exclude-warnings spec/compiler --exclude-warnings spec/primitives --exclude-warnings src/float/printer --exclude-warnings src/random.cr
5050
override SPEC_FLAGS += $(if $(verbose),-v )$(if $(junit_output),--junit_output $(junit_output) )$(if $(order),--order=$(order) )
5151
CRYSTAL_CONFIG_LIBRARY_PATH := '$$ORIGIN/../lib/crystal'
5252
CRYSTAL_CONFIG_BUILD_COMMIT ?= $(shell git rev-parse --short HEAD 2> /dev/null)

spec/std/spec/context_spec.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe Spec::ExampleGroup do
66
root = build_spec("f.cr", count: 20)
77

88
before_randomize = all_spec_descriptions(root)
9-
root.randomize(Random::DEFAULT)
9+
root.randomize(Random.new)
1010
after_randomize = all_spec_descriptions(root)
1111

1212
after_randomize.should_not eq before_randomize

src/enumerable.cr

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,8 +1596,9 @@ module Enumerable(T)
15961596
ary = Array(T).new(n)
15971597
return ary if n == 0
15981598

1599-
# FIXME: thread unsafe (#each may yield and the fiber switch threads)
1600-
rng = random || Random::DEFAULT
1599+
# must split the default random instance (thread local) because #each might
1600+
# yield the current fiber that may be resumed by another thread
1601+
rng = random || Random.split_on_stack
16011602

16021603
each_with_index do |elem, i|
16031604
if i < n
@@ -1636,8 +1637,9 @@ module Enumerable(T)
16361637
value = uninitialized T
16371638
found = false
16381639

1639-
# FIXME: thread unsafe (#each may yield and the fiber switch threads)
1640-
rng = random || Random::DEFAULT
1640+
# must split the default random instance (thread local) because #each might
1641+
# yield the current fiber that may be resumed by another thread
1642+
rng = random || Random.split_on_stack
16411643

16421644
each_with_index do |elem, i|
16431645
if !found

src/indexable.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,7 @@ module Indexable(T)
965965
# ```
966966
def sample(random : Random? = nil)
967967
raise IndexError.new("Can't sample empty collection") if size == 0
968-
rng = random || Random::DEFAULT
968+
rng = random || Random.thread_default
969969
unsafe_fetch(rng.rand(size))
970970
end
971971

src/indexable/mutable.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ module Indexable::Mutable(T)
271271
# a # => [3, 2, 4, 5, 1]
272272
# ```
273273
def shuffle!(random : Random? = nil) : self
274-
rng = random || Random::DEFAULT
274+
rng = random || Random.thread_default
275275
(size - 1).downto(1) do |i|
276276
j = rng.rand(i + 1)
277277
swap(i, j)

src/kernel.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ end
593593

594594
# additional reinitialization
595595
->Random::DEFAULT.new_seed,
596+
-> { Random.thread_default.new_seed },
596597
] of -> Nil
597598
end
598599
end

src/pointer.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ struct Pointer(T)
389389
# ptr.to_slice(4) # => Slice[3, 4, 1, 2]
390390
# ```
391391
def shuffle!(count : Int, random : Random? = nil)
392-
rng = random || Random::DEFAULT
392+
rng = random || Random.thread_default
393393
(count - 1).downto(1) do |i|
394394
j = rng.rand(i + 1)
395395
swap(i, j)

src/random.cr

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,30 @@ require "random/pcg32"
5050
# slower but cryptographically secure, so a third party can't deduce incoming
5151
# numbers.
5252
module Random
53+
@[Deprecated("Use `#rand`, `Random.next_int` or `Random::Secure.random_bytes` for example, or create a local instance with `Random.new` instead.")]
5354
DEFAULT = PCG32.new
5455

56+
# :nodoc:
57+
thread_local(thread_default : ::Random) do
58+
::Random::PCG32.new.as(::Random)
59+
end
60+
61+
# :nodoc:
62+
#
63+
# Same as `Random.thread_default#split` but allocates the dup on the stack
64+
# when possible (hence the macro).
65+
macro split_on_stack
66+
{% if compare_versions(Crystal::VERSION, "1.12.0") >= 0 %}
67+
%thread_rng = ::Random.thread_default.as(::Random::PCG32)
68+
%buf = uninitialized ::ReferenceStorage(::Random::PCG32)
69+
%copy = ::Random::PCG32.unsafe_construct(pointerof(%buf), %thread_rng)
70+
%thread_rng.split_internal(%copy)
71+
%copy
72+
{% else %}
73+
::Random.thread_default.split
74+
{% end %}
75+
end
76+
5577
# Initializes an instance with the given *seed* and *sequence*.
5678
def self.new(seed, sequence = 0_u64)
5779
PCG32.new(seed.to_u64, sequence)
@@ -62,6 +84,11 @@ module Random
6284
PCG32.new
6385
end
6486

87+
# Reseed the generator.
88+
def new_seed
89+
raise NotImplementedError.new("{{@type}}#new_seed")
90+
end
91+
6592
# Generates a random unsigned integer.
6693
#
6794
# The integers must be uniformly distributed between `0` and
@@ -456,22 +483,22 @@ module Random
456483

457484
# See `#next_bool`.
458485
def self.next_bool : Bool
459-
DEFAULT.next_bool
486+
thread_default.next_bool
460487
end
461488

462489
# See `#next_int`.
463490
def self.next_int : Int32
464-
DEFAULT.next_int
491+
thread_default.next_int
465492
end
466493

467494
# See `#rand`.
468495
def self.rand : Float64
469-
DEFAULT.rand
496+
thread_default.rand
470497
end
471498

472499
# See `#rand(x)`.
473500
def self.rand(x)
474-
DEFAULT.rand(x)
501+
thread_default.rand(x)
475502
end
476503
end
477504

src/range.cr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ struct Range(B, E)
345345
#
346346
# Raises `ArgumentError` if `self` is an open range.
347347
def sample(random : Random? = nil)
348-
rng = random || Random::DEFAULT
348+
rng = random || Random.thread_default
349349

350350
{% if B < Int && E < Int %}
351351
rng.rand(self)
@@ -371,7 +371,7 @@ struct Range(B, E)
371371
# once. Thus, *random* will be left in a different state compared to the
372372
# implementation in `Enumerable`.
373373
def sample(n : Int, random : Random? = nil)
374-
rng = random || Random::DEFAULT
374+
rng = random || Random.thread_default
375375

376376
if self.begin.nil? || self.end.nil?
377377
raise ArgumentError.new("Can't sample an open range")

0 commit comments

Comments
 (0)