Will my variable be modified in a `do` block? Very subtle to anticipate

I notice this difference

julia> Threads.nthreads()
4

julia> my_lock = Threads.ReentrantLock()
ReentrantLock(nothing, 0x00000000, 0x00, Base.GenericCondition{Base.Threads.SpinLock}(Base.IntrusiveLinkedList{Task}(nothing, nothing), Base.Threads.SpinLock(0)), (0, 0, 0))

julia> function my_dumb_fun(af)
       end;

julia> for k = 1:1
           Threads.@threads for i = 1:3
               a = -10i
               my_dumb_fun() do
                   a = 10i
               end
               println("at the end of i = $i, a = $a")
           end
       end
at the end of i = 1, a = -10
at the end of i = 3, a = -30
at the end of i = 2, a = -20

julia> for k = 1:1
           Threads.@threads for i = 1:3
               a = -10i
               Threads.lock(my_lock) do
                   a = 10i
               end
               println("at the end of i = $i, a = $a")
           end
       end
at the end of i = 1, a = 10
at the end of i = 3, a = 30
at the end of i = 2, a = 20

I don’t know how to explain.
related post

In the example with the my_dumb_fun, the do-block is never executed.

Try:

function my_dumb_fun(af)
    af()
end
1 Like

Yes, it will be different.
But my point was also on the scope rule of do block. Isn’t it a hard local scope so that the inner a will not affect the outer a?

julia> function my_dumb_fun(af)
           af()
       end;

julia> 

julia> for k = 1:1                                                                                              
           for i = 1:3                                                                                          
               a = -10i                                                                                         
               my_dumb_fun() do                                                                                 
                   a = 10i                                                                                      
               end                                                                                              
               println("at the end of i = $i, a = $a")                                                          
           end                                                                                                  
       end
at the end of i = 1, a = 10
at the end of i = 2, a = 20
at the end of i = 3, a = 30

Why is this?

No. The do-block is just made into an anonymous function. So

fun() do
    a = 14
end

is semantically equivalent to

function someuniquename() 
    a = 14
end
fun(someuniquename)

So if a variable a exists in the scope of the do-block, that a is used. If you want a separate a it must be declared as local:

my_dumb_fun() do
    local a = 10i
end
2 Likes

This is actually one of the few things I strongly dislike with julia. If you happen to have a variable a outside the @threads loop, and use a inside, all the parallel tasks share the same a. It’s relatively harmless if a is only read inside the loop, and quite nice for setting up constants and whatever, but it is devastating if a is used as a temporary variable which is assigned to in the parallel tasks.

1 Like

But here is one immediate question—why doesn’t this work Well, maybe don’t need to ask

julia> function my_dumb_fun(af)
           af()
       end;

julia> function someuniquename()
           a = 10i
       end;

julia> for _ = 1:1
           for i = 1:3
               a = -10i
               my_dumb_fun(someuniquename)
               println("at the end of i = $i, a = $a")
           end
       end
ERROR: UndefVarError: `i` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
Stacktrace:
 [1] someuniquename()
   @ Main .\REPL[2]:2
 [2] my_dumb_fun
   @ .\REPL[1]:2 [inlined]
 [3] top-level scope
   @ .\REPL[3]:4

To be honest, I am greatly shocked. Look at this image

My thought was aligned with ChatGPT’s, which is incorrect. I just realized that these are different

julia> for i = 1:3
           a = -10i
           let
               a = 10i
           end
           println("a = $a")
       end
a = 10
a = 20
a = 30

julia> for i = 1:3
           a = -10i
           let a = 10i
               a = 1a
           end
           println("a = $a")
       end
a = -10
a = -20
a = -30

So a let is very unlike a function, as shown in the follows

julia> for i = 1:2
           a = -10i
           let
               a = 10i
           end
           println("a = $a")
       end
a = 10
a = 20

julia> function my_let(i)
           a = 10i
       end;

julia> for i = 1:2
           a = -10i
           my_let(i)
           println("a = $a")
       end
a = -10
a = -20

I thought that they were the same.


Therefore I don’t think it is appropriate to make an analogy between do and function blocks, just like between let and functions. What’s your opinion?

Writing an a = 10i in the body of a let block, versus writing the same a = 10i in the body of a function, is very unlike.

This is a slightly different phenomenon.

Your example with the function my_let is in the REPL, i.e. at the julia prompt. The scope rules are somewhat different there. The function my_let will not look in the global scope to find the a when you assign to it, only in outer local scopes. For that to work, you would need global a = 10i.

Since the scoping rules are somewhat different at the REPL (outer level), it is good practice to enclose all code in functions.

1 Like

Consider this example:

julia> a = 12
12

julia> function fun()
           function my_let(i)
               a = 10i
           end

           for i = 1:2
               a = -10i
               my_let(i)
               println("a = $a")
           end
       end
fun (generic function with 1 method)

julia> function fun2()
           function my_let(i)
               a = 10i
           end

           for i = 1:2
               a = -10i
               my_let(i)
               println("a = $a")
           end

           a = 42
           return nothing
       end
fun2 (generic function with 1 method)

julia> fun()
a = -10
a = -20

julia> fun2()
a = 10
a = 20

julia> a
12

In function fun, the my_let assigns to a, but there is no a in the outer scope (inside fun), because the for loop creates its own scope, so the a in my_let is local, and different from the one in the loop.

In function fun2, there is an a in the outer scope (the a = 42 at the end), so the a in my_let and inside the loop is the same. It’s a bit contrived with the a = 42 at the end, but imagine you write such a function, and you have various loops and functions which all are supposed to act on a. It would work just as expected.

1 Like

In my applications, I would typically work in the julia REPL, meaning that I would not purposefully wrap all my code within a function, since in that case debugging is inconvenient. Therefore in the preceding discussions you see my writing a dumb for k = 1:1 loop at the outermost layer.


Even considering only the julia REPL context, the behavior of the let is rocket science! I assume that it is very hard for users to predict whether the a = 10i written within the body of let can modify an outer a.:smiling_face_with_tear:

julia> let
           a = 0
       end;

julia> @isdefined a
false

julia> for i = 1:2
           a = -10i
           let # can modify outer
               a = 10i
           end
           println("i = $i, a = $a")
       end
i = 1, a = 10
i = 2, a = 20

julia> @isdefined a
false

julia> a = -10;

julia> let i = 1 # cannot modify outer
           a = 10i
       end;

julia> a
-10

julia> for _ = 1:1
           for i = 1:2
               a = -10i
               let # cannot modify outer
                   a = 10i
               end
               println("i = $i, a = $a")
           end
       end
i = 1, a = -10
i = 2, a = -20

I assume that the body of do are almost the same as the let case. Am I right?
The head line of do and let both introduce local variables—I just realize it today.

Note that in addition to some annoying scoping rules, there’s another drawback with working at the REPL. Julia’s unit of compilation is the function. If your code is not in a function, it will not be compiled, but interpreted. Which means it will be slower by 1-3 orders of magnitude.

julia> const n = 100000000
100000000

julia> @time "repl" begin
           s = 0.0
           for i in 1:n
               s += 1/i
           end
           s
       end
repl: 5.050498 seconds (200.00 M allocations: 2.980 GiB, 1.69% gc time)
18.997896413852555

julia> function sumthem(n)
           s = 0.0
           for i in 1:n
               s += 1/i
           end
           return s
       end
sumthem (generic function with 1 method)

julia> @time "function" sumthem(n)
function: 0.100164 seconds
18.997896413852555
1 Like

I see. Thanks for the advice, I’ll remember.

Perhaps here is an example

julia> s = 0;

julia> for i = 1:3                                                                                                           
           s = i                                                                                                             
       end

julia> s # 3
3

julia> s = 0;

julia> Threads.@threads for i = 1:3                                                                                          
           s = i # maybe this usage is wrong                                                                                 
       end

julia> s # 0
0

So you make judgements with the source code of my my_dumb_fun. This is to say, the scope inference about the do block is much more difficult. (I am not aware what the source code of the Threads.lock function is).

Therefore, if the circumstance is like this, I cannot make a deterministic inference

julia> function modify_a_randomly(af)                                                                                        
           rand() > .5 && return                                                                                             
           af()                                                                                                              
       end;

julia> for i = 1:5
           a = -10i
           modify_a_randomly() do
               a = 10i
           end
           println("i = $i, a = $a")
       end
i = 1, a = 10
i = 2, a = -20
i = 3, a = 30
i = 4, a = -40
i = 5, a = 50

That one behaves differently inside a function:

function fun()
    s = 0
    Threads.@threads for i = 1:3
        s = i
    end
    return s
end
julia> fun()
3

The content of the @threads loop is put inside an anonymous function by the @threads macro. It will look for an s in an outer local scope. The s in the function is local, so it will be used.

On the other hand, when the @threads loop is defined at top-level, the outer scope with the s is global, so it will not be looked up.

The for loop without @threads is not in a function, it creates a soft scope, and works differently at the REPL.
There’s a lengthy explanation in Scope of Variables · The Julia Language.

There are good reasons for the put code in functions advice.

2 Likes

Thanks, I take your point.