Multithreading is hard.
Correctly and safely locking and unlocking can be hard.
For these reasons, we prefer abstractions that help us do things safely and correctly. One such abstraction is the lock()
method that takes a function argument:
lock(f::Function, lock)
Acquire the
lock
, executef
with thelock
held, and release thelock
whenf
returns. If the lock is already locked by a different task/thread, wait for it to become available.When this function returns, the
lock
has been released, so the caller should not attempt tounlock
it.
Used like this:
lock(l) do
...
end
This guarantees that we always correctly unlock the lock whenever we exit the computation by any means, whether we return or an exception is thrown. Which is great.
However, as discussed before (here: Performance of closures, and here: JuliaLang/julia#15276), it’s easy for this to perform poorly. From what i can tell, this introduces allocations if the lambda captures any non-isbits objects.
This is unfortunate and unexpected because I would have expected the lambda to be completely compiled away - it’s anonymous, and only ever passed to the one function where it’s invoked, so I would have expected this all to inline away. However, instead it causes allocations:
EDIT: See the thread below for a better example with nontrivial allocations
julia> const l = ReentrantLock()
ReentrantLock(nothing, Base.GenericCondition{Base.Threads.SpinLock}(Base.InvasiveLinkedList{Task}(nothing, nothing), Base.Threads.SpinLock(0)), 0)
julia> function f_locked(x::Vector)
lock(l) do
push!(x, 2)
end
end
f_locked (generic function with 1 method)
julia> function f(x::Vector)
push!(x, 1)
end
f (generic function with 1 method)
julia> @time f([])
0.000002 seconds (2 allocations: 128 bytes)
1-element Array{Any,1}:
1
julia> @time f_locked([])
0.000008 seconds (3 allocations: 144 bytes)
1-element Array{Any,1}:
2
That seems small, but locks tend to show up in very performance critical inner-loop type code, and if we lock and unlock several times in the same function, this adds up quite quickly.
But if the lock/unlock is written manually, there’s no overhead:
julia> function f_locked_manually(x::Vector)
lock(l)
try
push!(x, 2)
finally
unlock(l)
end
end
f_locked_manually (generic function with 1 method)
julia> @time f_locked_manually([])
0.000005 seconds (2 allocations: 128 bytes)
1-element Array{Any,1}:
2
# So, now my questions:
- Is this a known thing? Has it always been like this, or was there a regression at some point where this stopped performing as well?
- What is the recommended workaround?
# Proposed Workaround: A `@lock` macro
Regarding workarounds, at RelationalAI we've started using this macro, instead, which works perfectly well since in fact this is an entirely syntactic transformation:macro lock(l, ex)
quote
local lk = $(esc(l))
lock(lk)
try
$(esc(ex))
finally
unlock(lk)
end
end
end
@lock l begin
...
end
Is there anything we’re missing why this isn’t a good idea? If it seems okay, is this something we should spread more widely, and everyone should just always prefer to do this? Should we put this in base, and recommend it instead of the closure form? (other reasonable names include @withlock
or @locked
, etc)
Thanks all!
Cheers,
~Nathan