Skip to content

Commit 19a20ec

Browse files
committed
Add deferred "release" queue for PyObject finalizers
This is required to avoid deadlocking in the GC as it executes finalizers.
1 parent 12243fd commit 19a20ec

File tree

6 files changed

+123
-27
lines changed

6 files changed

+123
-27
lines changed

src/PyCall.jl

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ mutable struct PyObject
7777
o::PyPtr # the actual PyObject*
7878
function PyObject(o::PyPtr)
7979
po = new(o)
80-
finalizer(pydecref, po)
80+
finalizer(_defer_Py_DecRef, po)
8181
return po
8282
end
8383
end
@@ -116,9 +116,7 @@ it is equivalent to a `PyNULL()` object.
116116
ispynull(o::PyObject) = o PyPtr_NULL
117117

118118
function pydecref_(o::PyPtr)
119-
if o !== PyPtr_NULL && !_finalized[]
120-
@with_GIL ccall(@pysym(:Py_DecRef), Cvoid, (PyPtr,), o)
121-
end
119+
@with_GIL ccall(@pysym(:Py_DecRef), Cvoid, (PyPtr,), o)
122120
return o
123121
end
124122

src/gc.jl

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
# when po is deallocated, and this callback function removes
1212
# jo from pycall_gc so that Julia can garbage-collect it.
1313

14+
using Base.Threads: atomic_add!, atomic_sub!, Atomic, SpinLock
15+
1416
const pycall_gc = Dict{PyPtr,Any}()
1517

1618
function weakref_callback(callback::PyPtr, wo::PyPtr)
@@ -43,3 +45,74 @@ function pyembed(po::PyObject, jo::Any)
4345
pycall_gc[wo] = jo
4446
return po
4547
end
48+
49+
# Deferred `finalizer` / destructor queue(s):
50+
#
51+
# * In a `finalizer` context, it is unsafe to take the GIL since it
52+
# can cause deadlocks such as:
53+
#
54+
# 1. Task A holds the GIL and waits for Task B to finish something
55+
# 2. Task B enters GC and tries runs finalizers
56+
# 3. Task B cannot acquire the GIL and Task A is stuck waiting on B
57+
#
58+
# * To work around this, we defer any GIL-requiring operations to run
59+
# at the end of next `@with_GIL` block.
60+
61+
const _deferred_count = Atomic{Int}(0)
62+
const _deferred_Py_DecRef = Vector{PyPtr}()
63+
const _deferred_PyBuffer_Release = Vector{PyBuffer}()
64+
65+
# Since it is illegal for finalizers to `yield()` to the Julia scheduler, this
66+
# lock MUST be a `SpinLock` or similar. Most other locks in `Base` implicitly
67+
# yield.
68+
const _deferred_queue_lock = SpinLock()
69+
70+
# Defers a `Py_DecRef(o)` call to be performed later, when the GIL is available
71+
function _defer_Py_DecRef(o::PyObject)
72+
lock(_deferred_queue_lock)
73+
try
74+
push!(_deferred_Py_DecRef, getfield(o, :o))
75+
atomic_add!(_deferred_count, 1)
76+
finally
77+
unlock(_deferred_queue_lock)
78+
end
79+
return
80+
end
81+
82+
# Defers a `PyBuffer_Release(o)` call to be performed later, when the GIL is available
83+
function _defer_PyBuffer_Release(o::PyBuffer)
84+
lock(_deferred_queue_lock)
85+
try
86+
push!(_deferred_PyBuffer_Release, o)
87+
atomic_add!(_deferred_count, 1)
88+
finally
89+
unlock(_deferred_queue_lock)
90+
end
91+
return
92+
end
93+
94+
# Called at the end of every `@with_GIL` block, this performs any deferred destruction
95+
# operations from finalizers that could not be done immediately due to not holding the GIL
96+
@noinline function _drain_release_queues()
97+
lock(_deferred_queue_lock)
98+
99+
atomic_sub!(_deferred_count, length(_deferred_Py_DecRef))
100+
atomic_sub!(_deferred_count, length(_deferred_PyBuffer_Release))
101+
102+
Py_DecRefs = copy(_deferred_Py_DecRef)
103+
PyBuffer_Releases = copy(_deferred_PyBuffer_Release)
104+
105+
empty!(_deferred_Py_DecRef)
106+
empty!(_deferred_PyBuffer_Release)
107+
108+
unlock(_deferred_queue_lock)
109+
110+
for o in Py_DecRefs
111+
ccall(@pysym(:Py_DecRef), Cvoid, (PyPtr,), o)
112+
end
113+
for o in PyBuffer_Releases
114+
ccall(@pysym(:PyBuffer_Release), Cvoid, (Ref{PyBuffer},), o)
115+
end
116+
117+
return nothing
118+
end

src/gil.jl

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
const _GIL_owner = Threads.Atomic{UInt}(0)
44

5+
# Forward declare deferred destructors
6+
function _defer_Py_DecRef end
7+
function _defer_PyBuffer_Release end
8+
59
# Acquires the GIL.
610
# This lock can be re-acquired if already held by the OS thread (it is re-entrant).
711
function GIL_lock()
@@ -52,7 +56,11 @@ macro with_GIL(expr)
5256
else
5357
local _state = GIL_lock()
5458
try
55-
$(esc(expr))
59+
local val = $(esc(expr))
60+
if _deferred_count[] != 0
61+
_drain_release_queues()
62+
end
63+
val
5664
finally
5765
GIL_unlock(_state)
5866
end

src/pybuffer.jl

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ mutable struct PyBuffer
3232
b = new(Py_buffer(C_NULL, PyPtr_NULL, 0, 0,
3333
0, 0, C_NULL, C_NULL, C_NULL, C_NULL,
3434
C_NULL, C_NULL, C_NULL))
35-
finalizer(pydecref, b)
35+
finalizer(_defer_PyBuffer_Release, b)
3636
return b
3737
end
3838
end
@@ -45,11 +45,7 @@ It is an error to call this function on a PyBuffer that was not obtained via
4545
the python c-api function `PyObject_GetBuffer()`, unless o.obj is a PyPtr(C_NULL)
4646
"""
4747
function pydecref(o::PyBuffer)
48-
# note that PyBuffer_Release sets o.obj to NULL, and
49-
# is a no-op if o.obj is already NULL
50-
if !_finalized[]
51-
@with_GIL ccall(@pysym(:PyBuffer_Release), Cvoid, (Ref{PyBuffer},), o)
52-
end
48+
@with_GIL ccall(@pysym(:PyBuffer_Release), Cvoid, (Ref{PyBuffer},), o)
5349
o
5450
end
5551

src/pyinit.jl

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,9 @@ end
109109

110110
#########################################################################
111111

112-
const _finalized = Threads.Atomic{Bool}(false)
113-
# This flag is set via `Py_AtExit` to avoid calling `pydecref_` after
114-
# Python is finalized.
115-
116112
function _set_finalized()
117113
# This function MUST NOT invoke any Python APIs.
118114
# https://docs.python.org/3/c-api/sys.html#c.Py_AtExit
119-
_finalized[] = true
120115
_GIL_owner[] = UInt(0)
121116
return nothing
122117
end
@@ -130,7 +125,6 @@ end
130125
function __init__()
131126
# Clear out global states. This is required only for PyCall
132127
# AOT-compiled into system image.
133-
_finalized[] = false
134128
_GIL_owner[] = UInt(0)
135129
empty!(_namespaces)
136130
empty!(eventloops)

test/gil.jl

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
11
using Test, PyCall
22
using Base.Threads
33

4-
@testset "GIL / multi-threading" begin
5-
o = PyObject(randn(3,3))
6-
t = Threads.@spawn begin
7-
# Test that accessing `PyObject` across threads / tasks
8-
# does not immediately segfault (GIL is acquired correctly).
9-
iob = IOBuffer()
10-
println(iob, propertynames(o))
11-
str = String(take!(iob))
12-
return length(str)
4+
@testset "GIL" begin
5+
6+
@testset "basic multi-threading" begin
7+
o = PyObject(randn(3,3))
8+
t = Threads.@spawn begin
9+
# Test that accessing `PyObject` across threads / tasks
10+
# does not immediately segfault (GIL is acquired correctly).
11+
iob = IOBuffer()
12+
println(iob, propertynames(o))
13+
str = String(take!(iob))
14+
return length(str)
15+
end
16+
@test fetch(t) != 0
17+
end
18+
19+
@testset "finalizer deadlock" begin
20+
# Test that we avoid finalizer deadlocks like this:
21+
# 1. Task A holds the GIL
22+
# 2. Task B triggers a PyObject finalizer (e.g. via GC)
23+
# 3. Task A waits on Task B
24+
# 4. Task B cannot complete GC and Task A cannot advance -> deadlock
25+
26+
o = PyObject(42)
27+
PyCall.pyincref(o)
28+
29+
PyCall.@with_GIL begin
30+
t = Threads.@spawn begin
31+
finalize(o)
32+
return true
33+
end
34+
result = timedwait(() -> istaskdone(t), 5.0)
35+
@test result === :ok
36+
@test fetch(t) === true
37+
@test !isempty(PyCall._deferred_Py_DecRef)
38+
end
39+
@test isempty(PyCall._deferred_Py_DecRef)
1340
end
14-
@test fetch(t) != 0
41+
1542
end

0 commit comments

Comments
 (0)