Skip to content

Commit 3ebdb9c

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 30a73de commit 3ebdb9c

10 files changed

+199
-22
lines changed

base/Base.jl

Lines changed: 8 additions & 4 deletions
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+
# ScopedVariables
343+
include("scopedvariables.jl")
344+
using .ScopedVariables
345+
346+
# Logging
347+
include("logging.jl")
348+
using .CoreLogging
349+
346350
include("env.jl")
347351

348352
# functions defined in Random

base/boot.jl

Lines changed: 1 addition & 1 deletion
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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,10 @@ export
648648
sprint,
649649
summary,
650650

651+
# ScopedVariable
652+
scoped,
653+
ScopedVariable,
654+
651655
# logging
652656
@debug,
653657
@info,

base/logging.jl

Lines changed: 4 additions & 12 deletions
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 = ScopedVariable{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) = scoped(f, CURRENT_LOGSTATE => logstate)
520512

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

base/scopedvariables.jl

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# This file is a part of Julia. License is MIT: https://julialang.org/license
2+
3+
module ScopedVariables
4+
5+
export ScopedVariable, scoped
6+
7+
mutable struct Scope
8+
const parent::Union{Nothing, Scope}
9+
end
10+
11+
current_scope() = current_task().scope::Union{Nothing, Scope}
12+
13+
"""
14+
ScopedVariable(x)
15+
16+
Create a container that propagates values across scopes.
17+
Use [`scoped`](@ref) to create and enter a new scope.
18+
19+
Values can only be set when entering a new scope,
20+
and the value referred to will be constant during the
21+
execution of a scope.
22+
23+
Dynamic scopes are propagated across tasks.
24+
25+
# Examples
26+
```jldoctest
27+
julia> const svar = ScopedVariable(1);
28+
29+
julia> svar[]
30+
1
31+
32+
julia> scoped(svar => 2) do
33+
svar[]
34+
end
35+
2
36+
```
37+
38+
!!! compat "Julia 1.11"
39+
This method requires at least Julia 1.11. In Julia 1.7+ this
40+
is available from the package ScopedVariables.jl.
41+
"""
42+
mutable struct ScopedVariable{T}
43+
const values::WeakKeyDict{Scope, T}
44+
const initial_value::T
45+
ScopedVariable{T}(initial_value) where {T} = new{T}(WeakKeyDict{Scope, T}(), initial_value)
46+
end
47+
ScopedVariable(initial_value::T) where {T} = ScopedVariable{T}(initial_value)
48+
49+
Base.eltype(::Type{ScopedVariable{T}}) where {T} = T
50+
51+
function Base.getindex(var::ScopedVariable{T})::T where T
52+
scope = current_scope()
53+
if scope === nothing
54+
return var.initial_value
55+
end
56+
@lock var.values begin
57+
while scope !== nothing
58+
if haskey(var.values.ht, scope)
59+
return var.values.ht[scope]
60+
end
61+
scope = scope.parent
62+
end
63+
end
64+
return var.initial_value
65+
end
66+
67+
function Base.show(io::IO, var::ScopedVariable)
68+
print(io, ScopedVariable)
69+
print(io, '{', eltype(var), '}')
70+
print(io, '(')
71+
show(io, var[])
72+
print(io, ')')
73+
end
74+
75+
function __set_var!(scope::Scope, var::ScopedVariable{T}, val::T) where T
76+
# PRIVATE API! Wrong usage will break invariants of ScopedVariable.
77+
if scope === nothing
78+
error("ScopedVariable: Currently not in scope.")
79+
end
80+
@lock var.values begin
81+
if haskey(var.values.ht, scope)
82+
error("ScopedVariable: Variable is already set for this scope.")
83+
end
84+
var.values[scope] = val
85+
end
86+
end
87+
88+
"""
89+
scoped(f, var::ScopedVariable{T} => val::T)
90+
91+
Execute `f` in a new scope with `var` set to `val`.
92+
"""
93+
function scoped(f, pair::Pair{<:ScopedVariable{T}, T}) where T
94+
@nospecialize
95+
ct = Base.current_task()
96+
current_scope = ct.scope::Union{Nothing, Scope}
97+
try
98+
scope = Scope(current_scope)
99+
__set_var!(scope, pair...)
100+
ct.scope = scope
101+
return f()
102+
finally
103+
ct.scope = current_scope
104+
end
105+
end
106+
107+
"""
108+
scoped(f, vars...::ScopedVariable{T} => val::T)
109+
110+
Execute `f` in a new scope with each scoped variable set to the provided `val`.
111+
"""
112+
function scoped(f, pairs::Pair{<:ScopedVariable}...)
113+
@nospecialize
114+
ct = Base.current_task()
115+
current_scope = ct.scope::Union{Nothing, Scope}
116+
try
117+
scope = Scope(current_scope)
118+
for (var, val) in pairs
119+
__set_var!(scope, var, val)
120+
end
121+
ct.scope = scope
122+
return f()
123+
finally
124+
ct.scope = current_scope
125+
end
126+
end
127+
128+
end # module ScopedVariables

src/jltypes.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3231,7 +3231,7 @@ void jl_init_types(void) JL_GC_DISABLED
32313231
"storage",
32323232
"donenotify",
32333233
"result",
3234-
"logstate",
3234+
"scope",
32353235
"code",
32363236
"rngState0",
32373237
"rngState1",

src/julia.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2012,7 +2012,7 @@ typedef struct _jl_task_t {
20122012
jl_value_t *tls;
20132013
jl_value_t *donenotify;
20142014
jl_value_t *result;
2015-
jl_value_t *logstate;
2015+
jl_value_t *scope;
20162016
jl_function_t *start;
20172017
// 4 byte padding on 32-bit systems
20182018
// uint32_t padding0;

src/task.c

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,8 +1068,8 @@ JL_DLLEXPORT jl_task_t *jl_new_task(jl_function_t *start, jl_value_t *completion
10681068
t->result = jl_nothing;
10691069
t->donenotify = completion_future;
10701070
jl_atomic_store_relaxed(&t->_isexception, 0);
1071-
// Inherit logger state from parent task
1072-
t->logstate = ct->logstate;
1071+
// Inherit scope from parent task
1072+
t->scope = ct->scope;
10731073
// Fork task-local random state from parent
10741074
jl_rng_split(t->rngState, ct->rngState);
10751075
// 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)
16701670
ct->result = jl_nothing;
16711671
ct->donenotify = jl_nothing;
16721672
jl_atomic_store_relaxed(&ct->_isexception, 0);
1673-
ct->logstate = jl_nothing;
1673+
ct->scope = jl_nothing;
16741674
ct->eh = NULL;
16751675
ct->gcstack = NULL;
16761676
ct->excstack = NULL;

test/choosetests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const TESTNAMES = [
2929
"channels", "iostream", "secretbuffer", "specificity",
3030
"reinterpretarray", "syntax", "corelogging", "missing", "asyncmap",
3131
"smallarrayshrink", "opaque_closure", "filesystem", "download",
32+
"scopedvariables",
3233
]
3334

3435
const INTERNET_REQUIRED_LIST = [

test/scopedvariables.jl

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# This file is a part of Julia. License is MIT: https://julialang.org/license
2+
3+
const svar1 = ScopedVariable(1)
4+
5+
@testset "errors" begin
6+
var = ScopedVariable(1)
7+
@test_throws MethodError var[] = 2
8+
scoped() do
9+
@test_throws MethodError var[] = 2
10+
end
11+
end
12+
13+
const svar = ScopedVariable(1)
14+
@testset "inheritance" begin
15+
@test svar[] == 1
16+
scoped() do
17+
@test svar[] == 1
18+
scoped() do
19+
@test svar[] == 1
20+
end
21+
scoped(svar => 2) do
22+
@test svar[] == 2
23+
end
24+
@test svar[] == 1
25+
end
26+
@test svar[] == 1
27+
end
28+
29+
const svar_float = ScopedVariable(1.0)
30+
31+
@testset "multiple scoped variables" begin
32+
scoped(svar => 2, svar_float => 2.0) do
33+
@test svar[] == 2
34+
@test svar_float[] == 2.0
35+
end
36+
end
37+
38+
import Base.Threads: @spawn
39+
@testset "tasks" begin
40+
@test fetch(@spawn begin
41+
svar[]
42+
end) == 1
43+
scoped(svar => 2) do
44+
@test fetch(@spawn begin
45+
svar[]
46+
end) == 2
47+
end
48+
end

0 commit comments

Comments
 (0)