Skip to content

Commit

Permalink
Scoped values (#50958)
Browse files Browse the repository at this point in the history
ScopedVariables are containers whose observed value depends the current
dynamic scope. This implementation is inspired by
https://openjdk.org/jeps/446

A scope is introduced with the `scoped` function that takes a lambda to
execute within the new scope. The value of a `ScopedValue` is
constant within that scope and can only be set upon introduction
of a new scope.

Scopes are propagated across tasks boundaries.

Storage is implemented using a persistent dictionary.
  • Loading branch information
vchuravy authored and NHDaly committed Sep 20, 2023
1 parent 00672b3 commit a6811c6
Show file tree
Hide file tree
Showing 13 changed files with 631 additions and 24 deletions.
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Julia v1.11 Release Notes

New language features
---------------------
* `ScopedValue` implement dynamic scope with inheritance across tasks ([#50958]).

Language changes
----------------
Expand Down
12 changes: 8 additions & 4 deletions base/Base.jl
Original file line number Diff line number Diff line change
Expand Up @@ -330,10 +330,6 @@ using .Libc: getpid, gethostname, time, memcpy, memset, memmove, memcmp
const libblas_name = "libblastrampoline" * (Sys.iswindows() ? "-5" : "")
const liblapack_name = libblas_name

# Logging
include("logging.jl")
using .CoreLogging

# Concurrency (part 2)
# Note that `atomics.jl` here should be deprecated
Core.eval(Threads, :(include("atomics.jl")))
Expand All @@ -343,6 +339,14 @@ include("task.jl")
include("threads_overloads.jl")
include("weakkeydict.jl")

# ScopedValues
include("scopedvalues.jl")
using .ScopedValues

# Logging
include("logging.jl")
using .CoreLogging

include("env.jl")

# functions defined in Random
Expand Down
2 changes: 1 addition & 1 deletion base/boot.jl
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
# result::Any
# exception::Any
# backtrace::Any
# logstate::Any
# scope::Any
# code::Any
#end

Expand Down
5 changes: 5 additions & 0 deletions base/exports.jl
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,11 @@ export
sprint,
summary,

# ScopedValue
with,
@with,
ScopedValue,

# logging
@debug,
@info,
Expand Down
20 changes: 6 additions & 14 deletions base/logging.jl
Original file line number Diff line number Diff line change
Expand Up @@ -492,31 +492,23 @@ end

LogState(logger) = LogState(LogLevel(_invoked_min_enabled_level(logger)), logger)

const CURRENT_LOGSTATE = ScopedValue{LogState}()

function current_logstate()
logstate = current_task().logstate
return (logstate !== nothing ? logstate : _global_logstate)::LogState
maybe = @inline Base.ScopedValues.get(CURRENT_LOGSTATE)
return something(maybe, _global_logstate)::LogState
end

# helper function to get the current logger, if enabled for the specified message type
@noinline Base.@constprop :none function current_logger_for_env(std_level::LogLevel, group, _module)
logstate = current_logstate()
logstate = @inline current_logstate()
if std_level >= logstate.min_enabled_level || env_override_minlevel(group, _module)
return logstate.logger
end
return nothing
end

function with_logstate(f::Function, logstate)
@nospecialize
t = current_task()
old = t.logstate
try
t.logstate = logstate
f()
finally
t.logstate = old
end
end
with_logstate(f::Function, logstate) = @with(CURRENT_LOGSTATE => logstate, f())

#-------------------------------------------------------------------------------
# Control of the current logger and early log filtering
Expand Down
199 changes: 199 additions & 0 deletions base/scopedvalues.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# This file is a part of Julia. License is MIT: https://julialang.org/license

module ScopedValues

export ScopedValue, with, @with

"""
ScopedValue(x)
Create a container that propagates values across dynamic scopes.
Use [`with`](@ref) to create and enter a new dynamic scope.
Values can only be set when entering a new dynamic scope,
and the value referred to will be constant during the
execution of a dynamic scope.
Dynamic scopes are propagated across tasks.
# Examples
```jldoctest
julia> const sval = ScopedValue(1);
julia> sval[]
1
julia> with(sval => 2) do
sval[]
end
2
julia> sval[]
1
```
!!! compat "Julia 1.11"
Scoped values were introduced in Julia 1.11. In Julia 1.8+ a compatible
implementation is available from the package ScopedValues.jl.
"""
mutable struct ScopedValue{T}
const has_default::Bool
const default::T
ScopedValue{T}() where T = new(false)
ScopedValue{T}(val) where T = new{T}(true, val)
ScopedValue(val::T) where T = new{T}(true, val)
end

Base.eltype(::ScopedValue{T}) where {T} = T

"""
isassigned(val::ScopedValue)
Test if the ScopedValue has a default value.
"""
Base.isassigned(val::ScopedValue) = val.has_default

const ScopeStorage = Base.PersistentDict{ScopedValue, Any}

mutable struct Scope
values::ScopeStorage
end

function Scope(parent::Union{Nothing, Scope}, key::ScopedValue{T}, value) where T
val = convert(T, value)
if parent === nothing
return Scope(ScopeStorage(key=>val))
end
return Scope(ScopeStorage(parent.values, key=>val))
end

function Scope(scope, pairs::Pair{<:ScopedValue}...)
for pair in pairs
scope = Scope(scope, pair...)
end
return scope::Scope
end
Scope(::Nothing) = nothing

"""
current_scope()::Union{Nothing, Scope}
Return the current dynamic scope.
"""
current_scope() = current_task().scope::Union{Nothing, Scope}

function Base.show(io::IO, scope::Scope)
print(io, Scope, "(")
first = true
for (key, value) in scope.values
if first
first = false
else
print(io, ", ")
end
print(io, typeof(key), "@")
show(io, Base.objectid(key))
print(io, " => ")
show(IOContext(io, :typeinfo => eltype(key)), value)
end
print(io, ")")
end

struct NoValue end
const novalue = NoValue()

"""
get(val::ScopedValue{T})::Union{Nothing, Some{T}}
If the scoped value isn't set and doesn't have a default value,
return `nothing`. Otherwise returns `Some{T}` with the current
value.
"""
function get(val::ScopedValue{T}) where {T}
# Inline current_scope to avoid doing the type assertion twice.
scope = current_task().scope
if scope === nothing
isassigned(val) && return Some(val.default)
return nothing
end
scope = scope::Scope
if isassigned(val)
return Some(Base.get(scope.values, val, val.default)::T)
else
v = Base.get(scope.values, val, novalue)
v === novalue || return Some(v::T)
end
return nothing
end

function Base.getindex(val::ScopedValue{T})::T where T
maybe = get(val)
maybe === nothing && throw(KeyError(val))
return something(maybe)::T
end

function Base.show(io::IO, val::ScopedValue)
print(io, ScopedValue)
print(io, '{', eltype(val), '}')
print(io, '(')
v = get(val)
if v === nothing
print(io, "undefined")
else
show(IOContext(io, :typeinfo => eltype(val)), something(v))
end
print(io, ')')
end

"""
with(f, (var::ScopedValue{T} => val::T)...)
Execute `f` in a new scope with `var` set to `val`.
"""
function with(f, pair::Pair{<:ScopedValue}, rest::Pair{<:ScopedValue}...)
@nospecialize
ct = Base.current_task()
current_scope = ct.scope::Union{Nothing, Scope}
ct.scope = Scope(current_scope, pair, rest...)
try
return f()
finally
ct.scope = current_scope
end
end

with(@nospecialize(f)) = f()

"""
@with vars... expr
Macro version of `with(f, vars...)` but with `expr` instead of `f` function.
This is similar to using [`with`](@ref) with a `do` block, but avoids creating
a closure.
"""
macro with(exprs...)
if length(exprs) > 1
ex = last(exprs)
exprs = exprs[1:end-1]
elseif length(exprs) == 1
ex = only(exprs)
exprs = ()
else
error("@with expects at least one argument")
end
for expr in exprs
if expr.head !== :call || first(expr.args) !== :(=>)
error("@with expects arguments of the form `A => 2` got $expr")
end
end
exprs = map(esc, exprs)
quote
ct = $(Base.current_task)()
current_scope = ct.scope::$(Union{Nothing, Scope})
ct.scope = $(Scope)(current_scope, $(exprs...))
$(Expr(:tryfinally, esc(ex), :(ct.scope = current_scope)))
end
end

end # module ScopedValues
1 change: 1 addition & 0 deletions doc/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ BaseDocs = [
"base/arrays.md",
"base/parallel.md",
"base/multi-threading.md",
"base/scopedvalues.md",
"base/constants.md",
"base/file.md",
"base/io-network.md",
Expand Down
Loading

0 comments on commit a6811c6

Please sign in to comment.