diff --git a/NEWS.md b/NEWS.md index 938bf9a84ae3b..278ca263dc36e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,6 +3,7 @@ Julia v1.11 Release Notes New language features --------------------- +* `ScopedValue` implement dynamic scope with inheritance across tasks ([#50958]). Language changes ---------------- diff --git a/base/Base.jl b/base/Base.jl index fbc39e8104fba..b440fda78a90a 100644 --- a/base/Base.jl +++ b/base/Base.jl @@ -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"))) @@ -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 diff --git a/base/boot.jl b/base/boot.jl index 62edb4f178b40..637b16e04c13e 100644 --- a/base/boot.jl +++ b/base/boot.jl @@ -163,7 +163,7 @@ # result::Any # exception::Any # backtrace::Any -# logstate::Any +# scope::Any # code::Any #end diff --git a/base/exports.jl b/base/exports.jl index bc22c95e1a919..1f0b5c1b63e06 100644 --- a/base/exports.jl +++ b/base/exports.jl @@ -648,6 +648,11 @@ export sprint, summary, +# ScopedValue + with, + @with, + ScopedValue, + # logging @debug, @info, diff --git a/base/logging.jl b/base/logging.jl index 04eef9ed2ae51..208774bcecf38 100644 --- a/base/logging.jl +++ b/base/logging.jl @@ -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 diff --git a/base/scopedvalues.jl b/base/scopedvalues.jl new file mode 100644 index 0000000000000..4c46809e75ffe --- /dev/null +++ b/base/scopedvalues.jl @@ -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 diff --git a/doc/make.jl b/doc/make.jl index 087b033fcf79c..0ae74a55aceee 100644 --- a/doc/make.jl +++ b/doc/make.jl @@ -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", diff --git a/doc/src/base/scopedvalues.md b/doc/src/base/scopedvalues.md new file mode 100644 index 0000000000000..5c3318259ca55 --- /dev/null +++ b/doc/src/base/scopedvalues.md @@ -0,0 +1,281 @@ +# [Scoped Values](@id scoped-values) + +Scoped values provide an implementation of dynamic scoping in Julia. + +!!! note "Lexical scoping vs dynamic scoping" + [Lexical scoping](@ref scope-of-variables) is the default behavior in Julia. + Under lexical scoping the scope of a variable is determined by the lexical + (textual) structure of a program. + Under dynamic scoping a variable is bound to the most recent assigned value + during the program's execution. + +The state of a scoped value is dependent on the execution path of the program. +This means that for a scoped value you may observe multiple different values +concurrently. + +!!! 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. + +In its simplest form you can create a [`ScopedValue`](@ref) with a +default value and then use [`with`](@ref Base.with) or [`@with`](@ref) to +enter a new dynamic scope. + +The new scope will inherit all values from the parent scope +(and recursively from all outer scopes) with the provided scoped +value taking priority over previous definitions. + +Let's first look at an example of **lexical** scope: + +A `let` statements begins a new lexical scope within which the outer definition +of `x` is shadowed by it's inner definition. + +```julia +x = 1 +let x = 5 + @show x # 5 +end +@show x # 1 +``` + +Since Julia uses lexical scope the variable `x` is bound within the function `f` +to the global scope and entering a `let` scope does not change the value `f` +observes. + +```julia +x = 1 +f() = @show x +let x = 5 + f() # 1 +end +f() # 1 +``` + +Now using a `ScopedValue` we can use **dynamic** scoping. + +```julia +x = ScopedValue(1) +f() = @show x[] +with(x=>5) do + f() # 5 +end +f() # 1 +``` + +Not that the observed value of the `ScopedValue` is dependent on the execution +path of the program. + +It often makes sense to use a `const` variable to point to a scoped value, +and you can set the value of multiple `ScopedValue`s with one call to `with`. + + +```julia +const scoped_val = ScopedValue(1) +const scoped_val2 = ScopedValue(0) + +# Enter a new dynamic scope and set value +@show scoped_val[] # 1 +@show scoped_val2[] # 0 +with(scoped_val => 2) do + @show scoped_val[] # 2 + @show scoped_val2[] # 0 + with(scoped_val => 3, scoped_val2 => 5) do + @show scoped_val[] # 3 + @show scoped_val2[] # 5 + end + @show scoped_val[] # 2 + @show scoped_val2[] # 0 +end +@show scoped_val[] # 1 +@show scoped_val2[] # 0 +``` + +Since `with` requires a closure or a function and creates another call-frame, +it can sometimes be beneficial to use the macro form. + +```julia +const STATE = ScopedValue{State}() +with_state(f, state::State) = @with(STATE => state, f()) +``` + +!!! note + Dynamic scopes are inherited by [`Task`](@ref)s, at the moment of task creation. Dynamic scopes are **not** propagated through `Distributed.jl` operations. + +In the example below we open a new dynamic scope before launching a task. +The parent task and the two child tasks observe independent values of the +same scoped value at the same time. + +```julia +import Base.Threads: @spawn +const scoped_val = ScopedValue(1) +@sync begin + with(scoped_val => 2) + @spawn @show scoped_val[] # 2 + end + with(scoped_val => 3) + @spawn @show scoped_val[] # 3 + end + @show scoped_val[] # 1 +end +``` + +Scoped values are constant throughout a scope, but you can store mutable +state in a scoped value. Just keep in mind that the usual caveats +for global variables apply in the context of concurrent programming. + +Care is also required when storing references to mutable state in scoped +values. You might want to explicitly [unshare mutable state](@ref unshare_mutable_state) +when entering a new dynamic scope. + +```julia +import Base.Threads: @spawn +const sval_dict = ScopedValue(Dict()) + +# Example of using a mutable value wrongly +@sync begin + # `Dict` is not thread-safe the usage below is invalid + @spawn (sval_dict[][:a] = 3) + @spawn (sval_dict[][:b] = 3) +end + +@sync begin + # If we instead pass a unique dictionary to each + # task we can access the dictonaries race free. + with(sval_dict => Dict()) do + @spawn (sval_dict[][:a] = 3) + end + with(sval_dict => Dict()) do + @spawn (sval_dict[][:b] = 3) + end +end +``` + +## Example + +In the example below we use a scoped value to implement a permission check in +a web-application. After determining the permissions of the request, +a new dynamic scope is entered and the scoped value `LEVEL` is set. +Other parts of the application can query the scoped value and will receive +the appropriate value. Other alternatives like task-local storage and global variables +are not well suited for this kind of propagation; our only alternative would have +been to thread a value through the entire call-chain. + +```julia +const LEVEL = ScopedValue(:GUEST) + +function serve(request, response) + level = isAdmin(request) ? :ADMIN : :GUEST + with(LEVEL => level) do + Threads.@spawn handle(request, respone) + end +end + +function open(connection::Database) + level = LEVEL[] + if level !== :ADMIN + error("Access disallowed") + end + # ... open connection +end + +function handle(request, response) + # ... + open(Database(#=...=#)) + # ... +end +``` + +## Idioms +### [Unshare mutable state](@id unshare_mutable_state) + +```julia +import Base.Threads: @spawn +const sval_dict = ScopedValue(Dict()) + +# If you want to add new values to the dict, instead of replacing +# it, unshare the values explicitly. In this example we use `merge` +# to unshare the state of the dictonary in parent scope. +@sync begin + with(sval_dict => merge(sval_dict[], Dict(:a => 10))) do + @spawn @show sval_dict[][:a] + end + @spawn sval_dict[][:a] = 3 # Not a race since they are unshared. +end +``` + +### Scoped values as globals + +In order to access the value of a scoped value, the scoped value itself has to +be in (lexical) scope. This means most often you likely want to use scoped values +as constant globals. + +```julia +const sval = ScopedValue(1) +``` + +Indeed one can think of scoped values as hidden function arguments. + +This does not preclude their use as non-globals. + +```julia +import Base.Threads: @spawn +function main() + role = ScopedValue(:client) + + function launch() + #... + role[] + end + + @with role => :server @spawn launch() + launch() +end +``` + +But it might have been simpler to just directly pass the function argument +in these cases. + +### Very many ScopedValues + +If you find yourself creating many `ScopedValue`'s for one given module, +it may be better to use a dedicated struct to hold them. + +```julia +Base.@kwdef struct Configuration + color::Bool = false + verbose::Bool = false +end + +const CONFIG = ScopedValue(Configuration()) + +@with CONFIG => Configuration(CONFIG[], color=true) begin + @show CONFIG[].color # true + @show CONFIG[].verbose # false +end +``` + +## API docs + +```@docs +Base.ScopedValues.ScopedValue +Base.ScopedValues.with +Base.ScopedValues.@with +Base.isassigned(::ScopedValue) +Base.ScopedValues.get +``` + +## Implementation notes and performance + +`Scope`s use a persistent dictionary. Lookup and insertion is `O(log(32, n))`, +upon dynamic scope entry a small amount of data is copied and the unchanged +data is shared among other scopes. + +The `Scope` object itself is not user-facing and may be changed in a future +version of Julia. + +## Design inspiration + +This design was heavily inspired by [JEPS-429](https://openjdk.org/jeps/429), +which in turn was inspired by dynamically scoped free variables in many Lisp dialects. In particular Interlisp-D and it's deep binding strategy. + +A prior design discussed was context variables ala [PEPS-567](https://peps.python.org/pep-0567/) and implemented in Julia as [ContextVariablesX.jl](https://github.com/tkf/ContextVariablesX.jl). diff --git a/src/jltypes.c b/src/jltypes.c index b2775c6a92de6..f38197e49353d 100644 --- a/src/jltypes.c +++ b/src/jltypes.c @@ -3233,7 +3233,7 @@ void jl_init_types(void) JL_GC_DISABLED "storage", "donenotify", "result", - "logstate", + "scope", "code", "rngState0", "rngState1", diff --git a/src/julia.h b/src/julia.h index 84a749b4e08ea..1b3b2c7a70138 100644 --- a/src/julia.h +++ b/src/julia.h @@ -2037,7 +2037,7 @@ typedef struct _jl_task_t { jl_value_t *tls; jl_value_t *donenotify; jl_value_t *result; - jl_value_t *logstate; + jl_value_t *scope; jl_function_t *start; // 4 byte padding on 32-bit systems // uint32_t padding0; diff --git a/src/task.c b/src/task.c index 73d9033f0cb50..bd00090ee115d 100644 --- a/src/task.c +++ b/src/task.c @@ -1068,8 +1068,8 @@ JL_DLLEXPORT jl_task_t *jl_new_task(jl_function_t *start, jl_value_t *completion t->result = jl_nothing; t->donenotify = completion_future; jl_atomic_store_relaxed(&t->_isexception, 0); - // Inherit logger state from parent task - t->logstate = ct->logstate; + // Inherit scope from parent task + t->scope = ct->scope; // Fork task-local random state from parent jl_rng_split(t->rngState, ct->rngState); // there is no active exception handler available on this stack yet @@ -1670,7 +1670,7 @@ jl_task_t *jl_init_root_task(jl_ptls_t ptls, void *stack_lo, void *stack_hi) ct->result = jl_nothing; ct->donenotify = jl_nothing; jl_atomic_store_relaxed(&ct->_isexception, 0); - ct->logstate = jl_nothing; + ct->scope = jl_nothing; ct->eh = NULL; ct->gcstack = NULL; ct->excstack = NULL; diff --git a/test/choosetests.jl b/test/choosetests.jl index 3139ea60270e3..2f77b11767dee 100644 --- a/test/choosetests.jl +++ b/test/choosetests.jl @@ -29,6 +29,7 @@ const TESTNAMES = [ "channels", "iostream", "secretbuffer", "specificity", "reinterpretarray", "syntax", "corelogging", "missing", "asyncmap", "smallarrayshrink", "opaque_closure", "filesystem", "download", + "scopedvalues", ] const INTERNET_REQUIRED_LIST = [ diff --git a/test/scopedvalues.jl b/test/scopedvalues.jl new file mode 100644 index 0000000000000..c9d376ab05cbd --- /dev/null +++ b/test/scopedvalues.jl @@ -0,0 +1,123 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license +import Base: ScopedValues + +@testset "errors" begin + @test ScopedValue{Float64}(1)[] == 1.0 + @test_throws InexactError ScopedValue{Int}(1.5) + val = ScopedValue(1) + @test_throws MethodError val[] = 2 + with() do + @test_throws MethodError val[] = 2 + end + val = ScopedValue{Int}() + @test_throws KeyError val[] + @test_throws MethodError ScopedValue() +end + +const sval = ScopedValue(1) +@testset "inheritance" begin + @test sval[] == 1 + with() do + @test sval[] == 1 + with() do + @test sval[] == 1 + end + with(sval => 2) do + @test sval[] == 2 + end + @test sval[] == 1 + end + @test sval[] == 1 +end + +const sval_float = ScopedValue(1.0) + +@testset "multiple scoped values" begin + with(sval => 2, sval_float => 2.0) do + @test sval[] == 2 + @test sval_float[] == 2.0 + end + with(sval => 2, sval => 3) do + @test sval[] == 3 + end +end + +emptyf() = nothing + +@testset "conversion" begin + with(emptyf, sval_float=>2) + @test_throws MethodError with(emptyf, sval_float=>"hello") +end + +import Base.Threads: @spawn +@testset "tasks" begin + @test fetch(@spawn begin + sval[] + end) == 1 + with(sval => 2) do + @test fetch(@spawn begin + sval[] + end) == 2 + end +end + +@testset "show" begin + @test sprint(show, ScopedValue{Int}()) == "ScopedValue{$Int}(undefined)" + @test sprint(show, sval) == "ScopedValue{$Int}(1)" + @test sprint(show, ScopedValues.current_scope()) == "nothing" + with(sval => 2.0) do + @test sprint(show, sval) == "ScopedValue{$Int}(2)" + objid = sprint(show, Base.objectid(sval)) + @test sprint(show, ScopedValues.current_scope()) == "Base.ScopedValues.Scope(ScopedValue{$Int}@$objid => 2)" + end +end + +const depth = ScopedValue(0) +function nth_with(f, n) + if n <= 0 + f() + else + with(depth => n) do + nth_with(f, n-1) + end + end +end + + +@testset "nested with" begin + @testset for depth in 1:16 + nth_with(depth) do + @test sval_float[] == 1.0 + end + with(sval_float=>2.0) do + nth_with(depth) do + @test sval_float[] == 2.0 + end + end + nth_with(depth) do + with(sval_float=>2.0) do + @test sval_float[] == 2.0 + end + end + end + with(sval_float=>2.0) do + nth_with(15) do + @test sval_float[] == 2.0 + with(sval_float => 3.0) do + @test sval_float[] == 3.0 + end + end + end +end + +@testset "macro" begin + @with sval=>2 sval_float=>2.0 begin + @test sval[] == 2 + @test sval_float[] == 2.0 + end + # Doesn't do much... + @with begin + @test sval[] == 1 + @test sval_float[] == 1.0 + end +end