Skip to content

Commit 08d9224

Browse files
committed
Add ScopedVariables
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 `ScopedVariable` is constant within that scope and can only be set upon introduction of a new scope. Scopes are propagated across tasks boundaries. In contrast to #35833 the storage of the per-scope data is assoicated with the ScopedVariables object and does not require copies upon scope entry. This also means that libraries can use scoped variables without paying for scoped variables introduces in other libraries. Finding the current value of a ScopedVariable, involves walking the scope chain upwards and checking if the scoped variable has a value for the current or one of its parent scopes. This means the cost of a lookup scales with the depth of the dynamic scoping. This could be amortized by using a task-local cache.
1 parent 8599e2f commit 08d9224

13 files changed

+623
-22
lines changed

NEWS.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Julia v1.11 Release Notes
33

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

78
Language changes
89
----------------

base/Base.jl

+8-4
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,6 @@ using .Libc: getpid, gethostname, time, memcpy, memset, memmove, memcmp
330330
const libblas_name = "libblastrampoline" * (Sys.iswindows() ? "-5" : "")
331331
const liblapack_name = libblas_name
332332

333-
# Logging
334-
include("logging.jl")
335-
using .CoreLogging
336-
337333
# Concurrency (part 2)
338334
# Note that `atomics.jl` here should be deprecated
339335
Core.eval(Threads, :(include("atomics.jl")))
@@ -343,6 +339,14 @@ include("task.jl")
343339
include("threads_overloads.jl")
344340
include("weakkeydict.jl")
345341

342+
# ScopedValues
343+
include("scopedvalues.jl")
344+
using .ScopedValues
345+
346+
# Logging
347+
include("logging.jl")
348+
using .CoreLogging
349+
346350
include("env.jl")
347351

348352
# functions defined in Random

base/boot.jl

+1-1
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
# result::Any
164164
# exception::Any
165165
# backtrace::Any
166-
# logstate::Any
166+
# scope::Any
167167
# code::Any
168168
#end
169169

base/exports.jl

+5
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,11 @@ export
648648
sprint,
649649
summary,
650650

651+
# ScopedValue
652+
with,
653+
@with,
654+
ScopedValue,
655+
651656
# logging
652657
@debug,
653658
@info,

base/logging.jl

+4-12
Original file line numberDiff line numberDiff line change
@@ -492,8 +492,10 @@ end
492492

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

495+
const CURRENT_LOGSTATE = ScopedValue{Union{Nothing, LogState}}(nothing)
496+
495497
function current_logstate()
496-
logstate = current_task().logstate
498+
logstate = CURRENT_LOGSTATE[]
497499
return (logstate !== nothing ? logstate : _global_logstate)::LogState
498500
end
499501

@@ -506,17 +508,7 @@ end
506508
return nothing
507509
end
508510

509-
function with_logstate(f::Function, logstate)
510-
@nospecialize
511-
t = current_task()
512-
old = t.logstate
513-
try
514-
t.logstate = logstate
515-
f()
516-
finally
517-
t.logstate = old
518-
end
519-
end
511+
with_logstate(f::Function, logstate) = @with(CURRENT_LOGSTATE => logstate, f())
520512

521513
#-------------------------------------------------------------------------------
522514
# Control of the current logger and early log filtering

base/scopedvalues.jl

+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# This file is a part of Julia. License is MIT: https://julialang.org/license
2+
3+
module ScopedValues
4+
5+
export ScopedValue, with, @with
6+
7+
"""
8+
ScopedValue(x)
9+
10+
Create a container that propagates values across dynamic scopes.
11+
Use [`with`](@ref) to create and enter a new dynamic scope.
12+
13+
Values can only be set when entering a new dynamic scope,
14+
and the value referred to will be constant during the
15+
execution of a dynamic scope.
16+
17+
Dynamic scopes are propagated across tasks.
18+
19+
# Examples
20+
```jldoctest
21+
julia> const sval = ScopedValue(1);
22+
23+
julia> sval[]
24+
1
25+
26+
julia> with(sval => 2) do
27+
sval[]
28+
end
29+
2
30+
31+
julia> sval[]
32+
1
33+
```
34+
35+
!!! compat "Julia 1.11"
36+
Scoped values were introduced in Julia 1.11. In Julia 1.8+ a compatible
37+
implementation is available from the package ScopedValues.jl.
38+
"""
39+
mutable struct ScopedValue{T}
40+
const initial_value::T
41+
end
42+
43+
Base.eltype(::Type{ScopedValue{T}}) where {T} = T
44+
45+
##
46+
# Notes on the implementation.
47+
# We want lookup to be unreasonably fast.
48+
# - IDDict/Dict are ~10ns
49+
# - ImmutableDict is faster up to about ~15 entries
50+
# - ScopedValue are meant to be constant, Immutabilty
51+
# is thus a boon
52+
# - If we were to use IDDict/Dict we would need to split
53+
# the cache portion and the value portion of the hash-table,
54+
# the value portion is read-only/write-once, but the cache version
55+
# would need a lock which makes ImmutableDict incredibly attractive.
56+
# We could also use task-local-storage, but that added about 12ns.
57+
# - Values are GC'd when scopes become unreachable, one could use
58+
# a WeakKeyDict to also ensure that values get GC'd when ScopedValues
59+
# become unreachable.
60+
# - Scopes are an inline implementation of an ImmutableDict, if we wanted
61+
# be really fancy we could use a CTrie or HAMT.
62+
63+
mutable struct Scope
64+
const parent::Union{Nothing, Scope}
65+
const key::ScopedValue
66+
const value::Any
67+
Scope(parent, key::ScopedValue{T}, value::T) where T = new(parent, key, value)
68+
end
69+
Scope(parent, key::ScopedValue{T}, value) where T =
70+
Scope(parent, key, convert(T, value))
71+
72+
function Scope(scope, pairs::Pair{<:ScopedValue}...)
73+
for pair in pairs
74+
scope = Scope(scope, pair...)
75+
end
76+
return scope
77+
end
78+
79+
"""
80+
current_scope()::Union{Nothing, Scope}
81+
82+
Return the current dynamic scope.
83+
"""
84+
current_scope() = current_task().scope::Union{Nothing, Scope}
85+
86+
function Base.show(io::IO, scope::Scope)
87+
print(io, Scope, "(")
88+
seen = Set{ScopedValue}()
89+
while scope !== nothing
90+
if scope.key seen
91+
if !isempty(seen)
92+
print(io, ", ")
93+
end
94+
print(io, typeof(scope.key), "@")
95+
show(io, Base.objectid(scope.key))
96+
print(io, " => ")
97+
show(IOContext(io, :typeinfo => eltype(scope.key)), scope.value)
98+
push!(seen, scope.key)
99+
end
100+
scope = scope.parent
101+
end
102+
print(io, ")")
103+
end
104+
105+
function Base.getindex(var::ScopedValue{T})::T where T
106+
scope = current_scope()
107+
while scope !== nothing
108+
if scope.key === var
109+
return scope.value::T
110+
end
111+
scope = scope.parent
112+
end
113+
return var.initial_value
114+
end
115+
116+
function Base.show(io::IO, var::ScopedValue)
117+
print(io, ScopedValue)
118+
print(io, '{', eltype(var), '}')
119+
print(io, '(')
120+
show(IOContext(io, :typeinfo => eltype(var)), var[])
121+
print(io, ')')
122+
end
123+
124+
"""
125+
with(f, (var::ScopedValue{T} => val::T)...)
126+
127+
Execute `f` in a new scope with `var` set to `val`.
128+
"""
129+
function with(f, pair::Pair{<:ScopedValue}, rest::Pair{<:ScopedValue}...)
130+
@nospecialize
131+
ct = Base.current_task()
132+
current_scope = ct.scope::Union{Nothing, Scope}
133+
scope = Scope(current_scope, pair, rest...)
134+
ct.scope = scope
135+
try
136+
return f()
137+
finally
138+
ct.scope = current_scope
139+
end
140+
end
141+
142+
with(@nospecialize(f)) = f()
143+
144+
"""
145+
@with vars... expr
146+
147+
Macro version of `with(f, vars...)` but with `expr` instead of `f` function.
148+
This is similar to using [`with`](@ref) with a `do` block, but avoids creating
149+
a closure.
150+
"""
151+
macro with(exprs...)
152+
if length(exprs) > 1
153+
ex = last(exprs)
154+
exprs = exprs[1:end-1]
155+
elseif length(exprs) == 1
156+
ex = only(exprs)
157+
exprs = ()
158+
else
159+
error("@with expects at least one argument")
160+
end
161+
for expr in exprs
162+
if expr.head !== :call || first(expr.args) !== :(=>)
163+
error("@with expects arguments of the form `A => 2` got $expr")
164+
end
165+
end
166+
exprs = map(esc, exprs)
167+
ct = gensym(:ct)
168+
current_scope = gensym(:current_scope)
169+
body = Expr(:tryfinally, esc(ex), :($(ct).scope = $(current_scope)))
170+
quote
171+
$(ct) = $(Base.current_task)()
172+
$(current_scope) = $(ct).scope::$(Union{Nothing, Scope})
173+
$(ct).scope = $(Scope)($(current_scope), $(exprs...))
174+
$body
175+
end
176+
end
177+
178+
end # module ScopedValues

doc/make.jl

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ BaseDocs = [
112112
"base/arrays.md",
113113
"base/parallel.md",
114114
"base/multi-threading.md",
115+
"base/scopedvalues.md",
115116
"base/constants.md",
116117
"base/file.md",
117118
"base/io-network.md",

0 commit comments

Comments
 (0)