Skip to content

Commit bcd2bbb

Browse files
authored
More thread-safe GC (#529)
* slightly more thread safe gc * use Channel not Vector and make disable/enable a no-op * document GCHook * cannot lock channels on julia 1.6 * revert to using a vector for the queue * restore test script * combine queue into a single item * prefer Fix2 over anonymous function * update docs * test multithreaded * test gc from python * add gc tests * fix test * add deprecation warnings * safer locking (plus explanatory comments) * ref of weakref * SpinLock -> ReentrantLock * SpinLock -> ReentrantLock * typo: testset -> testitem * delete redundant test * remove out of date comment * comment erroneous test --------- Co-authored-by: Christopher Doris <github.com/cjdoris>
1 parent ce8bd7a commit bcd2bbb

File tree

8 files changed

+188
-39
lines changed

8 files changed

+188
-39
lines changed

.github/workflows/tests-nightly.yml

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ jobs:
3838
- uses: julia-actions/julia-runtest@v1
3939
env:
4040
JULIA_DEBUG: PythonCall
41+
JULIA_NUM_THREADS: '2'
4142
- uses: julia-actions/julia-processcoverage@v1
4243
- uses: codecov/codecov-action@v1
4344
with:

.github/workflows/tests.yml

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ jobs:
4343
uses: julia-actions/julia-runtest@v1
4444
env:
4545
JULIA_DEBUG: PythonCall
46+
JULIA_NUM_THREADS: '2'
4647
- name: Process coverage
4748
uses: julia-actions/julia-processcoverage@v1
4849
- name: Upload coverage to Codecov
@@ -82,6 +83,8 @@ jobs:
8283
- name: Run tests
8384
run: |
8485
pytest -s --nbval --cov=pysrc ./pytest/
86+
env:
87+
PYTHON_JULIACALL_THREADS: '2'
8588
- name: Upload coverage to Codecov
8689
uses: codecov/codecov-action@v2
8790
env:

docs/src/faq.md

+17-13
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@
44

55
No.
66

7-
Some rules if you are writing multithreaded code:
8-
- Only call Python functions from the first thread.
9-
- You probably also need to call `PythonCall.GC.disable()` on the main thread before any
10-
threaded block of code. Remember to call `PythonCall.GC.enable()` again afterwards.
11-
(This is because Julia finalizers can be called from any thread.)
12-
- Julia intentionally causes segmentation faults as part of the GC safepoint mechanism.
13-
If unhandled, these segfaults will result in termination of the process. To enable signal handling,
14-
set `PYTHON_JULIACALL_HANDLE_SIGNALS=yes` before any calls to import juliacall. This is equivalent
15-
to starting julia with `julia --handle-signals=yes`, the default behavior in Julia.
16-
See discussion [here](https://github.com/JuliaPy/PythonCall.jl/issues/219#issuecomment-1605087024) for more information.
17-
- You may still encounter problems.
18-
19-
Related issues: [#201](https://github.com/JuliaPy/PythonCall.jl/issues/201), [#202](https://github.com/JuliaPy/PythonCall.jl/issues/202)
7+
However it is safe to use PythonCall with Julia with multiple threads, provided you only
8+
call Python code from the first thread. (Before v0.9.22, tricks such as disabling the
9+
garbage collector were required.)
10+
11+
From Python, to use JuliaCall with multiple threads you probably need to set
12+
[`PYTHON_JULIACALL_HANDLE_SIGNALS=yes`](@ref julia-config) before importing JuliaCall.
13+
This is because Julia intentionally causes segmentation faults as part of the GC
14+
safepoint mechanism. If unhandled, these segfaults will result in termination of the
15+
process. This is equivalent to starting julia with `julia --handle-signals=yes`, the
16+
default behavior in Julia. See discussion
17+
[here](https://github.com/JuliaPy/PythonCall.jl/issues/219#issuecomment-1605087024)
18+
for more information.
19+
20+
Related issues:
21+
[#201](https://github.com/JuliaPy/PythonCall.jl/issues/201),
22+
[#202](https://github.com/JuliaPy/PythonCall.jl/issues/202),
23+
[#529](https://github.com/JuliaPy/PythonCall.jl/pull/529)
2024

2125
## Issues when Numpy arrays are expected
2226

docs/src/releasenotes.md

+8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Release Notes
22

3+
## Unreleased
4+
* Finalizers are now thread-safe, meaning PythonCall now works in the presence of
5+
multi-threaded Julia code. Previously, tricks such as disabling the garbage collector
6+
were required. Python code must still be called on the main thread.
7+
* `GC.disable()` and `GC.enable()` are now a no-op and deprecated since they are no
8+
longer required for thread-safety. These will be removed in v1.
9+
* Adds `GC.gc()`.
10+
311
## 0.9.21 (2024-07-20)
412
* `Serialization.serialize` can use `dill` instead of `pickle` by setting the env var `JULIA_PYTHONCALL_PICKLE=dill`.
513
* `numpy.bool_` can now be converted to `Bool` and other number types.

pytest/test_all.py

+23
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,26 @@ def test_issue_433():
7575
"""
7676
)
7777
assert out == 25
78+
79+
def test_julia_gc():
80+
from juliacall import Main as jl
81+
# We make a bunch of python objects with no reference to them,
82+
# then call GC to try to finalize them.
83+
# We want to make sure we don't segfault.
84+
# We also programmatically check things are working by verifying the queue is empty.
85+
# Debugging note: if you get segfaults, then run the tests with
86+
# `PYTHON_JULIACALL_HANDLE_SIGNALS=yes python3 -X faulthandler -m pytest -p no:faulthandler -s --nbval --cov=pysrc ./pytest/`
87+
# in order to recover a bit more information from the segfault.
88+
jl.seval(
89+
"""
90+
using PythonCall, Test
91+
let
92+
pyobjs = map(pylist, 1:100)
93+
Threads.@threads for obj in pyobjs
94+
finalize(obj)
95+
end
96+
end
97+
GC.gc()
98+
@test isempty(PythonCall.GC.QUEUE.items)
99+
"""
100+
)

src/GC/GC.jl

+100-25
Original file line numberDiff line numberDiff line change
@@ -3,77 +3,152 @@
33
44
Garbage collection of Python objects.
55
6-
See `disable` and `enable`.
6+
See [`gc`](@ref).
77
"""
88
module GC
99

1010
using ..C: C
1111

12-
const ENABLED = Ref(true)
13-
const QUEUE = C.PyPtr[]
12+
const QUEUE = (; items = C.PyPtr[], lock = Threads.SpinLock())
13+
const HOOK = Ref{WeakRef}()
1414

1515
"""
1616
PythonCall.GC.disable()
1717
18-
Disable the PythonCall garbage collector.
18+
Do nothing.
1919
20-
This means that whenever a Python object owned by Julia is finalized, it is not immediately
21-
freed but is instead added to a queue of objects to free later when `enable()` is called.
20+
!!! note
2221
23-
Like most PythonCall functions, you must only call this from the main thread.
22+
Historically this would disable the PythonCall garbage collector. This was required
23+
for safety in multi-threaded code but is no longer needed, so this is now a no-op.
2424
"""
2525
function disable()
26-
ENABLED[] = false
27-
return
26+
Base.depwarn(
27+
"disabling the PythonCall GC is no longer needed for thread-safety",
28+
:disable,
29+
)
30+
nothing
2831
end
2932

3033
"""
3134
PythonCall.GC.enable()
3235
33-
Re-enable the PythonCall garbage collector.
36+
Do nothing.
3437
35-
This frees any Python objects which were finalized while the GC was disabled, and allows
36-
objects finalized in the future to be freed immediately.
38+
!!! note
3739
38-
Like most PythonCall functions, you must only call this from the main thread.
40+
Historically this would enable the PythonCall garbage collector. This was required
41+
for safety in multi-threaded code but is no longer needed, so this is now a no-op.
3942
"""
4043
function enable()
41-
ENABLED[] = true
42-
if !isempty(QUEUE)
43-
for ptr in QUEUE
44+
Base.depwarn(
45+
"disabling the PythonCall GC is no longer needed for thread-safety",
46+
:enable,
47+
)
48+
nothing
49+
end
50+
51+
"""
52+
PythonCall.GC.gc()
53+
54+
Free any Python objects waiting to be freed.
55+
56+
These are objects that were finalized from a thread that was not holding the Python
57+
GIL at the time.
58+
59+
Like most PythonCall functions, this must only be called from the main thread (i.e. the
60+
thread currently holding the Python GIL.)
61+
"""
62+
function gc()
63+
if C.CTX.is_initialized
64+
unsafe_free_queue()
65+
end
66+
nothing
67+
end
68+
69+
function unsafe_free_queue()
70+
Base.@lock QUEUE.lock begin
71+
for ptr in QUEUE.items
4472
if ptr != C.PyNULL
4573
C.Py_DecRef(ptr)
4674
end
4775
end
76+
empty!(QUEUE.items)
4877
end
49-
empty!(QUEUE)
50-
return
78+
nothing
5179
end
5280

5381
function enqueue(ptr::C.PyPtr)
82+
# If the ptr is NULL there is nothing to free.
83+
# If C.CTX.is_initialized is false then the Python interpreter hasn't started yet
84+
# or has been finalized; either way attempting to free will cause an error.
5485
if ptr != C.PyNULL && C.CTX.is_initialized
55-
if ENABLED[]
86+
if C.PyGILState_Check() == 1
87+
# If the current thread holds the GIL, then we can immediately free.
5688
C.Py_DecRef(ptr)
89+
# We may as well also free any other enqueued objects.
90+
if !isempty(QUEUE.items)
91+
unsafe_free_queue()
92+
end
5793
else
58-
push!(QUEUE, ptr)
94+
# Otherwise we push the pointer onto the queue to be freed later, either:
95+
# (a) If a future Python object is finalized on the thread holding the GIL
96+
# in the branch above.
97+
# (b) If the GCHook() object below is finalized in an ordinary GC.
98+
# (c) If the user calls PythonCall.GC.gc().
99+
Base.@lock QUEUE.lock push!(QUEUE.items, ptr)
59100
end
60101
end
61-
return
102+
nothing
62103
end
63104

64105
function enqueue_all(ptrs)
65-
if C.CTX.is_initialized
66-
if ENABLED[]
106+
if any(!=(C.PYNULL), ptrs) && C.CTX.is_initialized
107+
if C.PyGILState_Check() == 1
67108
for ptr in ptrs
68109
if ptr != C.PyNULL
69110
C.Py_DecRef(ptr)
70111
end
71112
end
113+
if !isempty(QUEUE.items)
114+
unsafe_free_queue()
115+
end
72116
else
73-
append!(QUEUE, ptrs)
117+
Base.@lock QUEUE.lock append!(QUEUE.items, ptrs)
74118
end
75119
end
76-
return
120+
nothing
121+
end
122+
123+
"""
124+
GCHook()
125+
126+
An immortal object which frees any pending Python objects when Julia's GC runs.
127+
128+
This works by creating it but not holding any strong reference to it, so it is eligible
129+
to be finalized by Julia's GC. The finalizer empties the PythonCall GC queue if
130+
possible. The finalizer also re-attaches itself, so the object does not actually get
131+
collected and so the finalizer will run again at next GC.
132+
"""
133+
mutable struct GCHook
134+
function GCHook()
135+
finalizer(_gchook_finalizer, new())
136+
end
137+
end
138+
139+
function _gchook_finalizer(x)
140+
if C.CTX.is_initialized
141+
finalizer(_gchook_finalizer, x)
142+
if !isempty(QUEUE.items) && C.PyGILState_Check() == 1
143+
unsafe_free_queue()
144+
end
145+
end
146+
nothing
147+
end
148+
149+
function __init__()
150+
HOOK[] = WeakRef(GCHook())
151+
nothing
77152
end
78153

79154
end # module GC

test/GC.jl

+27-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,27 @@
1-
# TODO
1+
@testitem "GC.gc()" begin
2+
let
3+
pyobjs = map(pylist, 1:100)
4+
Threads.@threads for obj in pyobjs
5+
finalize(obj)
6+
end
7+
end
8+
# The GC sometimes actually frees everything before this line.
9+
# We can uncomment this line if we GIL.@release the above block once we have it.
10+
# Threads.nthreads() > 1 && @test !isempty(PythonCall.GC.QUEUE.items)
11+
PythonCall.GC.gc()
12+
@test isempty(PythonCall.GC.QUEUE.items)
13+
end
14+
15+
@testitem "GC.GCHook" begin
16+
let
17+
pyobjs = map(pylist, 1:100)
18+
Threads.@threads for obj in pyobjs
19+
finalize(obj)
20+
end
21+
end
22+
# The GC sometimes actually frees everything before this line.
23+
# We can uncomment this line if we GIL.@release the above block once we have it.
24+
# Threads.nthreads() > 1 && @test !isempty(PythonCall.GC.QUEUE.items)
25+
GC.gc()
26+
@test isempty(PythonCall.GC.QUEUE.items)
27+
end

test/finalize_test_script.jl

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using PythonCall
2+
3+
# This would consistently segfault pre-GC-thread-safety
4+
let
5+
pyobjs = map(pylist, 1:100)
6+
Threads.@threads for obj in pyobjs
7+
finalize(obj)
8+
end
9+
end

0 commit comments

Comments
 (0)