Skip to content

Commit 31a9e26

Browse files
NHDalyomus
andauthored
Make Time Zone Cache thread-safe with lazily-allocated thread-local caches (#344)
Co-authored-by: Curtis Vogt <[email protected]>
1 parent 3e78a3e commit 31a9e26

File tree

8 files changed

+111
-9
lines changed

8 files changed

+111
-9
lines changed

Diff for: src/TimeZones.jl

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const DEPS_DIR = joinpath(PKG_DIR, "deps")
3838
abstract type Local <: TimeZone end
3939

4040
function __init__()
41+
# Initialize the thread-local TimeZone cache (issue #342)
42+
_reset_tz_cache()
43+
4144
# Base extension needs to happen everytime the module is loaded (issue #24)
4245
Dates.CONVERSION_SPECIFIERS['z'] = TimeZone
4346
Dates.CONVERSION_SPECIFIERS['Z'] = TimeZone

Diff for: src/build.jl

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function build(version::AbstractString=tzdata_version(); force::Bool=false)
1515
end
1616

1717
# Reset cached information
18-
empty!(TIME_ZONE_CACHE)
18+
_reset_tz_cache()
1919

2020
@info "Successfully built TimeZones"
2121
end

Diff for: src/types/timezone.jl

+28-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,28 @@
1-
const TIME_ZONE_CACHE = Dict{String,Tuple{TimeZone,Class}}()
1+
# Thread-local TimeZone cache, which caches time zones _per thread_, allowing thread-safe
2+
# caching. Note that this means the cache will grow in size, and may store redundant objects
3+
# accross multiple threads, but this extra space usage allows for fast, lock-free access
4+
# to the cache, while still being thread-safe.
5+
const THREAD_TZ_CACHES = Vector{Dict{String,Tuple{TimeZone,Class}}}()
6+
7+
# Based upon the thread-safe Global RNG implementation in the Random stdlib:
8+
# https://github.com/JuliaLang/julia/blob/e4fcdf5b04fd9751ce48b0afc700330475b42443/stdlib/Random/src/RNGs.jl#L369-L385
9+
@inline _tz_cache() = _tz_cache(Threads.threadid())
10+
@noinline function _tz_cache(tid::Int)
11+
0 < tid <= length(THREAD_TZ_CACHES) || _tz_cache_length_assert()
12+
if @inbounds isassigned(THREAD_TZ_CACHES, tid)
13+
@inbounds cache = THREAD_TZ_CACHES[tid]
14+
else
15+
cache = eltype(THREAD_TZ_CACHES)()
16+
@inbounds THREAD_TZ_CACHES[tid] = cache
17+
end
18+
return cache
19+
end
20+
@noinline _tz_cache_length_assert() = @assert false "0 < tid <= length(THREAD_TZ_CACHES)"
21+
22+
function _reset_tz_cache()
23+
# ensures that we didn't save a bad object
24+
resize!(empty!(THREAD_TZ_CACHES), Threads.nthreads())
25+
end
226

327
"""
428
TimeZone(str::AbstractString) -> TimeZone
@@ -43,7 +67,7 @@ TimeZone(::AbstractString, ::Class)
4367
function TimeZone(str::AbstractString, mask::Class=Class(:DEFAULT))
4468
# Note: If the class `mask` does not match the time zone we'll still load the
4569
# information into the cache to ensure the result is consistent.
46-
tz, class = get!(TIME_ZONE_CACHE, str) do
70+
tz, class = get!(_tz_cache(), str) do
4771
tz_path = joinpath(TZData.COMPILED_DIR, split(str, "/")...)
4872

4973
if isfile(tz_path)
@@ -98,12 +122,12 @@ function istimezone(str::AbstractString, mask::Class=Class(:DEFAULT))
98122
end
99123

100124
# Perform more expensive checks against pre-compiled time zones
101-
tz, class = get(TIME_ZONE_CACHE, str) do
125+
tz, class = get(_tz_cache(), str) do
102126
tz_path = joinpath(TZData.COMPILED_DIR, split(str, "/")...)
103127

104128
if isfile(tz_path)
105129
# Cache the data since we're already performing the deserialization
106-
TIME_ZONE_CACHE[str] = open(deserialize, tz_path, "r")
130+
_tz_cache()[str] = open(deserialize, tz_path, "r")
107131
else
108132
nothing, Class(:NONE)
109133
end

Diff for: src/tzdata/compile.jl

+9-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ using Dates
22
using Serialization
33
using Dates: parse_components
44

5-
using ...TimeZones: TIME_ZONE_CACHE
5+
using ...TimeZones: _tz_cache
66
using ...TimeZones: TimeZones, TimeZone, FixedTimeZone, VariableTimeZone, Transition, Class
77
using ...TimeZones: rename
88
using ..TZData: TimeOffset, ZERO, MIN_GMT_OFFSET, MAX_GMT_OFFSET, MIN_SAVE, MAX_SAVE,
@@ -694,7 +694,14 @@ function compile(tz_source::TZSource, dest_dir::AbstractString; kwargs...)
694694
results = compile(tz_source; kwargs...)
695695

696696
isdir(dest_dir) || error("Destination directory doesn't exist")
697-
empty!(TIME_ZONE_CACHE)
697+
# When we recompile the TimeZones from a new source, we clear all the existing cached
698+
# TimeZone objects, so that newly constructed objects pick up the newly compiled rules.
699+
# Since we use thread-local caches, we spawn a task on _each thread_ to clear that
700+
# thread's local cache.
701+
Threads.@threads for i in 1:Threads.nthreads()
702+
@assert Threads.threadid() === i "TimeZones.TZData.compile() must be called from the main, top-level Task."
703+
empty!(_tz_cache())
704+
end
698705

699706
for (tz, class) in results
700707
parts = split(TimeZones.name(tz), '/')

Diff for: test/helpers.jl

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ show_compact = (io, args...) -> show(IOContext(io, :compact => true), args...)
3636
# not be used and only should be required if the test tzdata version and built tzdata
3737
# version do not match.
3838
function cache_tz((tz, class)::Tuple{TimeZone, TimeZones.Class})
39-
TimeZones.TIME_ZONE_CACHE[TimeZones.name(tz)] = (tz, class)
39+
TimeZones._tz_cache()[TimeZones.name(tz)] = (tz, class)
4040
return tz
4141
end

Diff for: test/runtests.jl

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ include("helpers.jl")
7272
include("rounding.jl")
7373
include("parse.jl")
7474
include("plotting.jl")
75+
VERSION >= v"1.3" && include("thread-safety.jl")
7576

7677
# Note: Run the build tests last to ensure that re-compiling the time zones files
7778
# doesn't interfere with other tests.

Diff for: test/thread-safety.jl

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Test that TimeZones.jl can be safely used in a multithreaded environment.
2+
# Note that the number of threads being used cannot be changed dynamically, so
3+
# this test file spawns a new julia process running with multiple threads.
4+
5+
using Test
6+
7+
const program = """
8+
using TimeZones
9+
using Test
10+
11+
@assert Threads.nthreads() > 1 "This system does not support multiple threads, so the thread-safety tests cannot be run."
12+
13+
@testset "Multithreaded TimeZone brute force test" begin
14+
function create_zdt(year, month, day, tz_name)
15+
ZonedDateTime(DateTime(year, month, day), TimeZone(tz_name))
16+
end
17+
function cycle_zdts()
18+
return [
19+
try
20+
create_zdt(year, month, day, tz_name)
21+
catch e
22+
# Ignore ZonedDateTimes that aren't valid
23+
e isa Union{ArgumentError,AmbiguousTimeError,NonExistentTimeError} || rethrow()
24+
nothing
25+
end
26+
for year in 2000:2020
27+
for month in 1:5
28+
for day in 10:15
29+
for tz_name in timezone_names()
30+
]
31+
end
32+
33+
outputs = Channel(Inf)
34+
@sync begin
35+
for _ in 1:15
36+
Threads.@spawn begin
37+
put!(outputs, cycle_zdts())
38+
end
39+
end
40+
end
41+
close(outputs)
42+
43+
tzs = collect(outputs)
44+
45+
# Test that every Task produced the same result
46+
allsame(x) = all(y -> y == first(x), x)
47+
@test allsame(tzs)
48+
end
49+
50+
#----------------------------------------------------
51+
52+
@testset "Interleaved compile() and TimeZone construction" begin
53+
@sync for i in 1:20
54+
if (i % 5 == 0)
55+
TimeZones.TZData.compile()
56+
end
57+
Threads.@spawn begin
58+
TimeZone("US/Eastern", TimeZones.Class(:LEGACY))
59+
end
60+
end
61+
end
62+
"""
63+
64+
@info "Running Thread Safety tests"
65+
@testset "Multithreaded TimeZone construction" begin
66+
run(`$(Base.julia_cmd()) -t8 --proj -E $(program)`)
67+
end

Diff for: test/types/timezone.jl

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ using TimeZones: Class
22

33
@testset "istimezone" begin
44
# Invalidate the cache to ensure that `istimezone` works for non-loaded time zones.
5-
empty!(TimeZones.TIME_ZONE_CACHE)
5+
TimeZones._reset_tz_cache()
66

77
@test istimezone("Europe/Warsaw")
88
@test istimezone("UTC+02")

0 commit comments

Comments
 (0)