diff --git a/src/TimeZones.jl b/src/TimeZones.jl index c4d384c77..452c643d2 100644 --- a/src/TimeZones.jl +++ b/src/TimeZones.jl @@ -7,6 +7,8 @@ using RecipesBase: RecipesBase, @recipe using Unicode using InlineStrings: InlineString15 +using Base: @lock + import Dates: TimeZone, UTC export TimeZone, @tz_str, istimezone, FixedTimeZone, VariableTimeZone, ZonedDateTime, @@ -40,7 +42,7 @@ abstract type Local <: TimeZone end function __init__() # Initialize the thread-local TimeZone cache (issue #342) - _reset_tz_cache() + _init_tz_cache() # Base extension needs to happen everytime the module is loaded (issue #24) Dates.CONVERSION_SPECIFIERS['z'] = TimeZone diff --git a/src/types/timezone.jl b/src/types/timezone.jl index 8a381c137..f74099e65 100644 --- a/src/types/timezone.jl +++ b/src/types/timezone.jl @@ -4,6 +4,12 @@ # to the cache, while still being thread-safe. const THREAD_TZ_CACHES = Vector{Dict{String,Tuple{TimeZone,Class}}}() +# Holding a lock during construction of a specific TimeZone prevents multiple Tasks (on the +# same or different threads) from attempting to construct the same TimeZone object, and +# allows them all to share the result. +const TZ_CACHE_MUTEX = ReentrantLock() +const TZ_CACHE_FUTURES = Dict{String,Channel{Tuple{TimeZone,Class}}}() # Guarded by: TZ_CACHE_MUTEX + # Based upon the thread-safe Global RNG implementation in the Random stdlib: # https://github.com/JuliaLang/julia/blob/e4fcdf5b04fd9751ce48b0afc700330475b42443/stdlib/Random/src/RNGs.jl#L369-L385 @inline _tz_cache() = _tz_cache(Threads.threadid()) @@ -19,10 +25,22 @@ const THREAD_TZ_CACHES = Vector{Dict{String,Tuple{TimeZone,Class}}}() end @noinline _tz_cache_length_assert() = @assert false "0 < tid <= length(THREAD_TZ_CACHES)" -function _reset_tz_cache() - # ensures that we didn't save a bad object +function _init_tz_cache() resize!(empty!(THREAD_TZ_CACHES), Threads.nthreads()) end +# ensures that we didn't save a bad object +function _reset_tz_cache() + # Since we use thread-local caches, we spawn a task on _each thread_ to clear that + # thread's local cache. + Threads.@threads for i in 1:Threads.nthreads() + @assert Threads.threadid() === i "TimeZones.TZData.compile() must be called from the main, top-level Task." + empty!(_tz_cache()) + end + @lock TZ_CACHE_MUTEX begin + empty!(TZ_CACHE_FUTURES) + end + return nothing +end """ TimeZone(str::AbstractString) -> TimeZone @@ -68,20 +86,40 @@ function TimeZone(str::AbstractString, mask::Class=Class(:DEFAULT)) # Note: If the class `mask` does not match the time zone we'll still load the # information into the cache to ensure the result is consistent. tz, class = get!(_tz_cache(), str) do - tz_path = joinpath(TZData.COMPILED_DIR, split(str, "/")...) - - if isfile(tz_path) - open(deserialize, tz_path, "r") - elseif occursin(FIXED_TIME_ZONE_REGEX, str) - FixedTimeZone(str), Class(:FIXED) - elseif !isdir(TZData.COMPILED_DIR) || isempty(readdir(TZData.COMPILED_DIR)) - # Note: Julia 1.0 supresses the build logs which can hide issues in time zone - # compliation which result in no tzdata time zones being available. - throw(ArgumentError( - "Unable to find time zone \"$str\". Try running `TimeZones.build()`." - )) + # Even though we're using Thread-local caches, we still need to lock during + # construction to prevent multiple tasks redundantly constructing the same object, + # and potential thread safety violations due to Tasks migrating threads. + # NOTE that we only grab the lock if the TZ doesn't exist, so the mutex contention + # is not on the critical path for most constructors. :) + constructing = false + # We lock the mutex, but for only a short, *constant time* duration, to grab the + # future for this TimeZone, or create the future if it doesn't exist. + future = @lock TZ_CACHE_MUTEX begin + get!(TZ_CACHE_FUTURES, str) do + constructing = true + Channel{Tuple{TimeZone,Class}}(1) + end + end + if constructing + tz_path = joinpath(TZData.COMPILED_DIR, split(str, "/")...) + + t = if isfile(tz_path) + open(deserialize, tz_path, "r") + elseif occursin(FIXED_TIME_ZONE_REGEX, str) + FixedTimeZone(str), Class(:FIXED) + elseif !isdir(TZData.COMPILED_DIR) || isempty(readdir(TZData.COMPILED_DIR)) + # Note: Julia 1.0 supresses the build logs which can hide issues in time zone + # compliation which result in no tzdata time zones being available. + throw(ArgumentError( + "Unable to find time zone \"$str\". Try running `TimeZones.build()`." + )) + else + throw(ArgumentError("Unknown time zone \"$str\"")) + end + + put!(future, t) else - throw(ArgumentError("Unknown time zone \"$str\"")) + fetch(future) end end diff --git a/src/tzdata/TZData.jl b/src/tzdata/TZData.jl index a5d9f23c8..b0057e460 100644 --- a/src/tzdata/TZData.jl +++ b/src/tzdata/TZData.jl @@ -2,7 +2,7 @@ module TZData using LazyArtifacts using Printf -using ...TimeZones: DEPS_DIR +using ...TimeZones: DEPS_DIR, _reset_tz_cache # Note: The tz database is made up of two parts: code and data. TimeZones.jl only requires # the "tzdata" archive or more specifically the "tz source" files within the archive diff --git a/src/tzdata/compile.jl b/src/tzdata/compile.jl index aa89401a5..a66dc7a38 100644 --- a/src/tzdata/compile.jl +++ b/src/tzdata/compile.jl @@ -2,7 +2,7 @@ using Dates using Serialization using Dates: parse_components -using ...TimeZones: _tz_cache +using ...TimeZones: _reset_tz_cache using ...TimeZones: TimeZones, TimeZone, FixedTimeZone, VariableTimeZone, Transition, Class using ...TimeZones: rename using ..TZData: TimeOffset, ZERO, MIN_GMT_OFFSET, MAX_GMT_OFFSET, MIN_SAVE, MAX_SAVE, @@ -696,12 +696,7 @@ function compile(tz_source::TZSource, dest_dir::AbstractString; kwargs...) isdir(dest_dir) || error("Destination directory doesn't exist") # When we recompile the TimeZones from a new source, we clear all the existing cached # TimeZone objects, so that newly constructed objects pick up the newly compiled rules. - # Since we use thread-local caches, we spawn a task on _each thread_ to clear that - # thread's local cache. - Threads.@threads for i in 1:Threads.nthreads() - @assert Threads.threadid() === i "TimeZones.TZData.compile() must be called from the main, top-level Task." - empty!(_tz_cache()) - end + _reset_tz_cache() for (tz, class) in results parts = split(TimeZones.name(tz), '/')