Skip to content

Commit a395a25

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 a395a25

File tree

6 files changed

+124
-27
lines changed

6 files changed

+124
-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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
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
15+
using Base: ThreadSynchronizer
16+
1417
const pycall_gc = Dict{PyPtr,Any}()
1518

1619
function weakref_callback(callback::PyPtr, wo::PyPtr)
@@ -43,3 +46,75 @@ function pyembed(po::PyObject, jo::Any)
4346
pycall_gc[wo] = jo
4447
return po
4548
end
49+
50+
# Deferred `finalizer` / destructor queue(s):
51+
#
52+
# * In a `finalizer` context, it is unsafe to take the GIL since it
53+
# can cause deadlocks such as:
54+
#
55+
# 1. Task A holds the GIL
56+
# 2. Task B enters GC and runs finalizers
57+
# 3. Task A / B deadlock waiting for the GC to make progress, but
58+
# Task B cannot acquire the GIL to run the finalizer.
59+
#
60+
# * To work around this, we defer any GIL-requiring operations to run
61+
# at the end of next `@with_GIL` block.
62+
63+
const _deferred_count = Atomic{Int}(0)
64+
const _deferred_Py_DecRef = Vector{PyPtr}()
65+
const _deferred_PyBuffer_Release = Vector{PyBuffer}()
66+
67+
# Since it is illegal for finalizers to `yield()` to the Julia scheduler, this
68+
# lock MUST be a `ThreadSynchronizer` or similar. Most other locks in `Base`
69+
# implicitly yield.
70+
const _deferred_queue_lock = ThreadSynchronizer()
71+
72+
# Defers a `Py_DecRef(o)` call to be performed later, when the GIL is available
73+
function _defer_Py_DecRef(o::PyObject)
74+
lock(_deferred_queue_lock)
75+
try
76+
push!(_deferred_Py_DecRef, getfield(o, :o))
77+
atomic_add!(_deferred_count, 1)
78+
finally
79+
unlock(_deferred_queue_lock)
80+
end
81+
return
82+
end
83+
84+
# Defers a `PyBuffer_Release(o)` call to be performed later, when the GIL is available
85+
function _defer_PyBuffer_Release(o::PyBuffer)
86+
lock(_deferred_queue_lock)
87+
try
88+
push!(_deferred_PyBuffer_Release, o)
89+
atomic_add!(_deferred_count, 1)
90+
finally
91+
unlock(_deferred_queue_lock)
92+
end
93+
return
94+
end
95+
96+
# Called at the end of every `@with_GIL` block, this performs any deferred destruction
97+
# operations from finalizers that could not be done immediately due to not holding the GIL
98+
@noinline function _drain_release_queues()
99+
lock(_deferred_queue_lock)
100+
101+
atomic_sub!(_deferred_count, length(_deferred_Py_DecRef))
102+
atomic_sub!(_deferred_count, length(_deferred_PyBuffer_Release))
103+
104+
Py_DecRefs = copy(_deferred_Py_DecRef)
105+
PyBuffer_Releases = copy(_deferred_PyBuffer_Release)
106+
107+
empty!(_deferred_Py_DecRef)
108+
empty!(_deferred_PyBuffer_Release)
109+
110+
unlock(_deferred_queue_lock)
111+
112+
for o in Py_DecRefs
113+
ccall(@pysym(:Py_DecRef), Cvoid, (PyPtr,), o)
114+
end
115+
for o in PyBuffer_Releases
116+
ccall(@pysym(:PyBuffer_Release), Cvoid, (Ref{PyBuffer},), o)
117+
end
118+
119+
return nothing
120+
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: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,41 @@
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 B cannot complete GC and Task A cannot advance -> deadlock
24+
25+
o = PyObject(42)
26+
PyCall.pyincref(o)
27+
28+
PyCall.@with_GIL begin
29+
t = Threads.@spawn begin
30+
finalize(o)
31+
return true
32+
end
33+
result = timedwait(() -> istaskdone(t), 5.0)
34+
@test result === :ok
35+
@test fetch(t) === true
36+
@test !isempty(PyCall._deferred_Py_DecRef)
37+
end
38+
@test isempty(PyCall._deferred_Py_DecRef)
1339
end
14-
@test fetch(t) != 0
40+
1541
end

0 commit comments

Comments
 (0)