@mbauman I wrote up a a toy example to help illustrate the use of ownership checking for thread safety. First, regular code:
increment_counter!(ref::Ref) = (ref[] += 1)
function create_thread_race()
# (Oops, I forgot to make this Atomic!)
shared_counter = Ref(0)
Threads.@threads for _ in 1:10000
increment_counter!(shared_counter)
end
println("Final counter value: $(shared_counter[])\nExpected value: 10000")
end
If you run this, you get some variation of:
julia> create_thread_race()
Final counter value: 8306
Expected value: 10000
This is bad because it’s a silent error. Unless we test specifically for this value, or are able to spot such errors by eye 100% of the time, we might miss these thread race conditions.
If you had used an ownership system to write this, it would help inform you of the design flaw. Here’s a contrived example (tons of ways to cheat the system right now!!) –
using BorrowChecker
function bc_create_thread_race()
# (Oops, I forgot to make this Atomic!)
@own shared_counter = Ref(0)
Threads.@threads for _ in 1:10000
increment_counter!(@take shared_counter)
end
println("Final counter value: $(shared_counter)\nExpected value: 10000")
end
Running this will give you the following issue:
julia> bc_create_thread_race()
ERROR: TaskFailedException
nested task error: Cannot use shared_counter: value has been moved
Stacktrace:
This error occurs because we have already unwrapped shared_counter
in another thread, so the variable is marked moved
and can’t be read anymore. This pattern forces you to rethink the design of your code to one that doesn’t rely on shared mutable variables. For example, here’s one way that satisfies the borrowing rules by splitting up work beforehand, and aggregating results at the end:
function counter(thread_count::Integer)
@own local_counter = 0
for _ in 1:thread_count
@set local_counter = local_counter + 1
end
@take local_counter
end
function bc_correct_counter()
@own const num_threads = 4
@own const total_count = 10000
@own const count_per_thread = total_count ÷ num_threads
@own tasks = Task[]
for t_id in 1:num_threads
@own const thread_count = count_per_thread + (t_id == 1) * (total_count % num_threads)
@own const t = Threads.@spawn counter($(@take thread_count))
push!(tasks, @take(t))
end
return sum(map(fetch, @take(tasks)))
end
Which gives us the correct result
julia> bc_correct_counter()
10000
There are still some limitations to note. For example, Threads.@spawn
doesn’t actually require us to @take
the thread_count
, so you could “cheat” really easily here. But committing to the ownership system might help in enforcing better patterns.
It would be really nice to have automatic @take
and borrows here to make it less verbose. It also seems crucial to have a way of enforcing that the user, via escape analysis (like the page @Mason pointed to), to @take
or @ref
any variables going into the Threads.@spawn
! Likewise, for calling external functions it would be nice to enforce such things.
I mean it would be even better to do the whole thing automatically but I have a feeling this might not be possible.