Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 4a1ee78

Browse files
authoredAug 7, 2024··
GIL functions for genuine multi-threading (#535)
* 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 * add PythonCall.GIL * add tests for PythonCall.GIL * add GIL to release notes * add GIL release tests from Python * typo: testset -> testitem * delete redundant test * remove out of date comment * comment erroneous test * re-enable commented test * adds AnyValue._jl_call_nogil * add RawValue._jl_call_nogil * add docstrings * add warnings about the GIL to docstrings * add reference docstrings * remove big pycall comparison and move pycall help to faq * document new threading features * update release notes * clarification * rename GIL.release to GIL.unlock and use lock/unlock terminology consistently --------- Co-authored-by: Christopher Doris <github.com/cjdoris>
1 parent bcd2bbb commit 4a1ee78

File tree

17 files changed

+416
-105
lines changed

17 files changed

+416
-105
lines changed
 

‎README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,8 @@ In this example we use the Python module JuliaCall from an IPython notebook to t
4040

4141
## What about PyCall?
4242

43-
The existing package [PyCall](https://github.com/JuliaPy/PyCall.jl) is another similar interface to Python. Here we note some key differences, but a more detailed comparison is in the documentation.
43+
The existing package [PyCall](https://github.com/JuliaPy/PyCall.jl) is another similar interface to Python. Here we note some key differences:.
4444
- PythonCall supports a wider range of conversions between Julia and Python, and the conversion mechanism is extensible.
4545
- PythonCall by default never copies mutable objects when converting, but instead directly wraps the mutable object. This means that modifying the converted object modifies the original, and conversion is faster.
4646
- PythonCall does not usually automatically convert results to Julia values, but leaves them as Python objects. This makes it easier to do Pythonic things with these objects (e.g. accessing methods) and is type-stable.
47-
- PythonCall installs dependencies into a separate Conda environment for each Julia project. This means each Julia project can have an isolated set of Python dependencies.
48-
- PythonCall supports Julia 1.6.1+ and Python 3.8+ whereas PyCall supports Julia 0.7+ and Python 2.7+.
47+
- PythonCall installs dependencies into a separate Conda environment for each Julia project using [CondaPkg](https://github.com/JuliaPy/CondaPkg.jl). This means each Julia project can have an isolated set of Python dependencies.

‎docs/make.jl

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ makedocs(
1919
],
2020
"compat.md",
2121
"faq.md",
22-
"pycall.md",
2322
"releasenotes.md",
2423
],
2524
)

‎docs/src/faq.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
# FAQ & Troubleshooting
22

3-
## Is PythonCall/JuliaCall thread safe?
3+
## Can I use PythonCall and PyCall together?
4+
5+
Yes, you can use both PyCall and PythonCall in the same Julia session. This is platform-dependent:
6+
- On most systems the Python interpreter used by PythonCall and PyCall must be the same (see below).
7+
- On Windows it appears to be possible for PythonCall and PyCall to use different interpreters.
8+
9+
To force PythonCall to use the same Python interpreter as PyCall, set the environment variable [`JULIA_PYTHONCALL_EXE`](@ref pythoncall-config) to `"@PyCall"`. Note that this will opt out of automatic dependency management using CondaPkg.
410

5-
No.
11+
Alternatively, to force PyCall to use the same interpreter as PythonCall, set the environment variable `PYTHON` to [`PythonCall.python_executable_path()`](@ref) and then `Pkg.build("PyCall")`. You will need to do this each time you change project, because PythonCall by default uses a different Python for each project.
12+
13+
## Is PythonCall/JuliaCall thread safe?
614

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.)
15+
Yes, as of v0.9.22, provided you handle the GIL correctly. See the guides for
16+
[PythonCall](@ref jl-multi-threading) and [JuliaCall](@ref py-multi-threading).
1017

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.
18+
Before, tricks such as disabling the garbage collector were required. See the
19+
[old docs](https://juliapy.github.io/PythonCall.jl/v0.9.21/faq/#Is-PythonCall/JuliaCall-thread-safe?).
1920

2021
Related issues:
2122
[#201](https://github.com/JuliaPy/PythonCall.jl/issues/201),

‎docs/src/juliacall-reference.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# JuliaCall API Reference
1+
# [JuliaCall API Reference](@id jl-reference)
22

33
## Constants
44

@@ -93,8 +93,9 @@ replaced with `!!`.
9393
9494
###### Members
9595
- `_jl_raw()`: Convert to a [`RawValue`](#juliacall.RawValue). (See also [`pyjlraw`](@ref).)
96-
- `_jl_display()`: Display the object using Julia's display mechanism.
97-
- `_jl_help()`: Display help for the object.
96+
- `_jl_display(mime=None)`: Display the object using Julia's display mechanism.
97+
- `_jl_help(mime=None)`: Display help for the object.
98+
- `_jl_call_nogil(*args, **kwargs)`: Call this with the GIL disabled.
9899
`````
99100

100101
`````@customdoc
@@ -217,4 +218,5 @@ single tuple, it will need to be wrapped in another tuple.
217218
###### Members
218219
- `_jl_any()`: Convert to a [`AnyValue`](#juliacall.AnyValue) (or subclass). (See also
219220
[`pyjl`](@ref).)
221+
- `_jl_call_nogil(*args, **kwargs)`: Call this with the GIL disabled.
220222
`````

‎docs/src/juliacall.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,79 @@ be configured in two ways:
124124
| `-X juliacall-threads=<N\|auto>` | `PYTHON_JULIACALL_THREADS=<N\|auto>` | Launch N threads. |
125125
| `-X juliacall-warn-overwrite=<yes\|no>` | `PYTHON_JULIACALL_WARN_OVERWRITE=<yes\|no>` | Enable or disable method overwrite warnings. |
126126
| `-X juliacall-autoload-ipython-extension=<yes\|no>` | `PYTHON_JULIACALL_AUTOLOAD_IPYTHON_EXTENSION=<yes\|no>` | Enable or disable IPython extension autoloading. |
127+
128+
## [Multi-threading](@id py-multi-threading)
129+
130+
From v0.9.22, JuliaCall supports multi-threading in Julia and/or Python, with some
131+
caveats.
132+
133+
Most importantly, you can only call Python code while Python's
134+
[Global Interpreter Lock (GIL)](https://docs.python.org/3/glossary.html#term-global-interpreter-lock)
135+
is locked by the current thread. You can use JuliaCall from any Python thread, and the GIL
136+
will be locked whenever any JuliaCall function is used. However, to leverage the benefits
137+
of multi-threading, you can unlock the GIL while executing any Julia code that does not
138+
interact with Python.
139+
140+
The simplest way to do this is using the `_jl_call_nogil` method on Julia functions to
141+
call the function with the GIL unlocked.
142+
143+
```python
144+
from concurrent.futures import ThreadPoolExecutor, wait
145+
from juliacall import Main as jl
146+
pool = ThreadPoolExecutor(4)
147+
fs = [pool.submit(jl.Libc.systemsleep._jl_call_nogil, 5) for _ in range(4)]
148+
wait(fs)
149+
```
150+
151+
In the above example, we call `Libc.systemsleep(5)` on four threads. Because we
152+
called it with `_jl_call_nogil`, the GIL was unlocked, allowing the threads to run in
153+
parallel, taking about 5 seconds in total.
154+
155+
If we did not use `_jl_call_nogil` (i.e. if we did `pool.submit(jl.Libc.systemsleep, 5)`)
156+
then the above code will take 20 seconds because the sleeps run one after another.
157+
158+
It is very important that any function called with `_jl_call_nogil` does not interact
159+
with Python at all unless it re-locks the GIL first, such as by using
160+
[PythonCall.GIL.@lock](@ref).
161+
162+
You can also use [multi-threading from Julia](@ref jl-multi-threading).
163+
164+
### Caveat: Julia's task scheduler
165+
166+
If you try the above example with a Julia function that yields to the task scheduler,
167+
such as `sleep` instead of `Libc.systemsleep`, then you will likely experience a hang.
168+
169+
In this case, you need to yield back to Julia's scheduler periodically to allow the task
170+
to continue. You can use the following pattern instead of `wait(fs)`:
171+
```python
172+
jl_yield = getattr(jl, "yield")
173+
while True:
174+
# yield to Julia's task scheduler
175+
jl_yield()
176+
# wait for up to 0.1 seconds for the threads to finish
177+
state = wait(fs, timeout=0.1)
178+
# if they finished then stop otherwise try again
179+
if not state.not_done:
180+
break
181+
```
182+
183+
Set the `timeout` parameter smaller to let Julia's scheduler cycle more frequently.
184+
185+
Future versions of JuliaCall may provide tooling to make this simpler.
186+
187+
### [Caveat: Signal handling](@id py-multi-threading-signal-handling)
188+
189+
We recommend setting [`PYTHON_JULIACALL_HANDLE_SIGNALS=yes`](@ref julia-config)
190+
before importing JuliaCall with multiple threads.
191+
192+
This is because Julia intentionally causes segmentation faults as part of the GC
193+
safepoint mechanism. If unhandled, these segfaults will result in termination of the
194+
process. See discussion
195+
[here](https://github.com/JuliaPy/PythonCall.jl/issues/219#issuecomment-1605087024)
196+
for more information.
197+
198+
Note however that this interferes with Python's own signal handling, so for example
199+
Ctrl-C will not raise `KeyboardInterrupt`.
200+
201+
Future versions of JuliaCall may make this the default behaviour when using multiple
202+
threads.

‎docs/src/pycall.md

Lines changed: 0 additions & 75 deletions
This file was deleted.

‎docs/src/pythoncall-reference.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,19 @@ Py(x::MyType) = x.py
218218
@pyconst
219219
```
220220

221+
## Multi-threading
222+
223+
These functions are not exported. They support multi-threading of Python and/or Julia.
224+
See also [`juliacall.AnyValue._jl_call_nogil`](@ref julia-wrappers).
225+
226+
```@docs
227+
PythonCall.GIL.lock
228+
PythonCall.GIL.@lock
229+
PythonCall.GIL.unlock
230+
PythonCall.GIL.@unlock
231+
PythonCall.GC.gc
232+
```
233+
221234
## The Python interpreter
222235

223236
These functions are not exported. They give information about which Python interpreter is

‎docs/src/pythoncall.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,3 +362,43 @@ end
362362

363363
If your package depends on some Python packages, you must generate a `CondaPkg.toml` file.
364364
See [Installing Python packages](@ref python-deps).
365+
366+
## [Multi-threading](@id jl-multi-threading)
367+
368+
From v0.9.22, PythonCall supports multi-threading in Julia and/or Python, with some
369+
caveats.
370+
371+
Most importantly, you can only call Python code while Python's
372+
[Global Interpreter Lock (GIL)](https://docs.python.org/3/glossary.html#term-global-interpreter-lock)
373+
is locked by the current thread. Ordinarily, the GIL is locked by the main thread in Julia,
374+
so if you want to run Python code on any other thread, you must unlock the GIL from the
375+
main thread and then re-lock it while running any Python code on other threads.
376+
377+
This is made possible by the macros [`PythonCall.GIL.@unlock`](@ref) and
378+
[`PythonCall.GIL.@lock`](@ref) or the functions [`PythonCall.GIL.unlock`](@ref) and
379+
[`PythonCall.GIL.lock`](@ref) with this pattern:
380+
381+
```julia
382+
PythonCall.GIL.@unlock Threads.@threads for i in 1:4
383+
PythonCall.GIL.@lock pyimport("time").sleep(5)
384+
end
385+
```
386+
387+
In the above example, we call `time.sleep(5)` four times in parallel. If Julia was
388+
started with at least four threads (`julia -t4`) then the above code will take about
389+
5 seconds.
390+
391+
Both `@unlock` and `@lock` are important. If the GIL were not unlocked, then a deadlock
392+
would occur when attempting to lock the already-locked GIL from the threads. If the GIL
393+
were not re-locked, then Python would crash when interacting with it.
394+
395+
You can also use [multi-threading from Python](@ref py-multi-threading).
396+
397+
### Caveat: Garbage collection
398+
399+
If Julia's GC collects any Python objects from a thread where the GIL is not currently
400+
locked, then those Python objects will not immediately be deleted. Instead they will be
401+
queued to be deleted in a later GC pass.
402+
403+
If you find you have many Python objects not being deleted, you can call
404+
[`PythonCall.GC.gc()`](@ref) or `GC.gc()` while the GIL is locked to clear the queue.

‎docs/src/releasenotes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
* `GC.disable()` and `GC.enable()` are now a no-op and deprecated since they are no
88
longer required for thread-safety. These will be removed in v1.
99
* Adds `GC.gc()`.
10+
* Adds module `GIL` with `lock()`, `unlock()`, `@lock` and `@unlock` for handling the
11+
Python Global Interpreter Lock. In combination with the above improvements, these
12+
allow Julia and Python to co-operate on multiple threads.
13+
* Adds method `_jl_call_nogil` to `juliacall.AnyValue` and `juliacall.RawValue` to call
14+
Julia functions with the GIL unlocked.
1015

1116
## 0.9.21 (2024-07-20)
1217
* `Serialization.serialize` can use `dill` instead of `pickle` by setting the env var `JULIA_PYTHONCALL_PICKLE=dill`.

‎pytest/test_all.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,40 @@
1+
import pytest
2+
3+
14
def test_import():
25
import juliacall
36

7+
48
def test_newmodule():
59
import juliacall
10+
611
jl = juliacall.Main
712
m = juliacall.newmodule("TestModule")
813
assert isinstance(m, juliacall.ModuleValue)
914
assert jl.isa(m, jl.Module)
1015
assert str(jl.nameof(m)) == "TestModule"
1116

17+
1218
def test_convert():
1319
import juliacall
20+
1421
jl = juliacall.Main
15-
for (x, t) in [(None, jl.Nothing), (True, jl.Bool), ([1,2,3], jl.Vector)]:
22+
for x, t in [(None, jl.Nothing), (True, jl.Bool), ([1, 2, 3], jl.Vector)]:
1623
y = juliacall.convert(t, x)
1724
assert isinstance(y, juliacall.AnyValue)
1825
assert jl.isa(y, t)
1926

27+
2028
def test_interactive():
2129
import juliacall
30+
2231
juliacall.interactive(True)
2332
juliacall.interactive(False)
2433

34+
2535
def test_JuliaError():
2636
import juliacall
37+
2738
jl = juliacall.Main
2839
assert isinstance(juliacall.JuliaError, type)
2940
assert issubclass(juliacall.JuliaError, Exception)
@@ -40,11 +51,13 @@ def test_JuliaError():
4051
bt = err.backtrace
4152
assert bt is not None
4253

54+
4355
def test_issue_394():
4456
"https://github.com/JuliaPy/PythonCall.jl/issues/394"
4557
from juliacall import Main as jl
58+
4659
x = 3
47-
f = lambda x: x+1
60+
f = lambda x: x + 1
4861
y = 5
4962
jl.x = x
5063
assert jl.x is x
@@ -57,6 +70,7 @@ def test_issue_394():
5770
assert jl.y is y
5871
assert jl.seval("f(x)") == 4
5972

73+
6074
def test_issue_433():
6175
"https://github.com/JuliaPy/PythonCall.jl/issues/433"
6276
from juliacall import Main as jl
@@ -76,8 +90,10 @@ def test_issue_433():
7690
)
7791
assert out == 25
7892

93+
7994
def test_julia_gc():
8095
from juliacall import Main as jl
96+
8197
# We make a bunch of python objects with no reference to them,
8298
# then call GC to try to finalize them.
8399
# We want to make sure we don't segfault.
@@ -98,3 +114,53 @@ def test_julia_gc():
98114
@test isempty(PythonCall.GC.QUEUE.items)
99115
"""
100116
)
117+
118+
119+
@pytest.mark.parametrize(
120+
["yld", "raw"], [(yld, raw) for yld in [False, True] for raw in [False, True]]
121+
)
122+
def test_call_nogil(yld, raw):
123+
"""Tests that we can execute Julia code in parallel by releasing the GIL."""
124+
from concurrent.futures import ThreadPoolExecutor, wait
125+
from time import time
126+
from juliacall import Main as jl
127+
128+
# julia implementation of sleep which unlocks the GIL
129+
if yld:
130+
# use sleep, which yields
131+
jsleep = jl.sleep
132+
else:
133+
# use Libc.systemsleep which does not yield
134+
jsleep = jl.Libc.systemsleep
135+
if raw:
136+
# test RawValue instead of AnyValue
137+
jsleep = jsleep._jl_raw()
138+
jsleep = jsleep._jl_call_nogil
139+
jyield = getattr(jl, "yield")
140+
# precompile
141+
jsleep(0.01)
142+
jyield()
143+
# use two threads
144+
pool = ThreadPoolExecutor(2)
145+
# run jsleep(1) twice concurrently
146+
t0 = time()
147+
fs = [pool.submit(jsleep, 1) for _ in range(2)]
148+
# submitting tasks should be very fast
149+
t1 = time() - t0
150+
assert t1 < 0.1
151+
# wait for the tasks to finish
152+
if yld:
153+
# we need to explicitly yield back to give the Julia scheduler a chance to
154+
# finish the sleep calls, so we yield every 0.1 seconds
155+
status = wait(fs, timeout=0.1)
156+
t2 = time() - t0
157+
while status.not_done:
158+
jyield()
159+
status = wait(fs, timeout=0.1)
160+
t2 = time() - t0
161+
assert t2 < 2.0
162+
else:
163+
wait(fs)
164+
t2 = time() - t0
165+
# executing the tasks should take about 1 second because they happen in parallel
166+
assert 0.9 < t2 < 1.5

‎src/GIL/GIL.jl

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""
2+
module PythonCall.GIL
3+
4+
Handling the Python Global Interpreter Lock.
5+
6+
See [`lock`](@ref), [`@lock`](@ref), [`unlock`](@ref) and [`@unlock`](@ref).
7+
"""
8+
module GIL
9+
10+
using ..C: C
11+
12+
"""
13+
lock(f)
14+
15+
Unlock the GIL, compute `f()`, unlock the GIL, then return the result of `f()`.
16+
17+
Use this to run Python code from threads that do not currently hold the GIL, such as new
18+
threads. Since the main Julia thread holds the GIL by default, you will need to
19+
[`unlock`](@ref) the GIL before using this function.
20+
21+
See [`@lock`](@ref) for the macro form.
22+
"""
23+
function lock(f)
24+
state = C.PyGILState_Ensure()
25+
try
26+
f()
27+
finally
28+
C.PyGILState_Release(state)
29+
end
30+
end
31+
32+
"""
33+
@lock expr
34+
35+
Unlock the GIL, compute `expr`, unlock the GIL, then return the result of `expr`.
36+
37+
Use this to run Python code from threads that do not currently hold the GIL, such as new
38+
threads. Since the main Julia thread holds the GIL by default, you will need to
39+
[`@unlock`](@ref) the GIL before using this function.
40+
41+
The macro equivalent of [`lock`](@ref).
42+
"""
43+
macro lock(expr)
44+
quote
45+
state = C.PyGILState_Ensure()
46+
try
47+
$(esc(expr))
48+
finally
49+
C.PyGILState_Release(state)
50+
end
51+
end
52+
end
53+
54+
"""
55+
unlock(f)
56+
57+
Unlock the GIL, compute `f()`, re-lock the GIL, then return the result of `f()`.
58+
59+
Use this to run non-Python code with the GIL unlocked, so allowing another thread to run
60+
Python code. That other thread can be a Julia thread, which must lock the GIL using
61+
[`lock`](@ref).
62+
63+
See [`@unlock`](@ref) for the macro form.
64+
"""
65+
function unlock(f)
66+
state = C.PyEval_SaveThread()
67+
try
68+
f()
69+
finally
70+
C.PyEval_RestoreThread(state)
71+
end
72+
end
73+
74+
"""
75+
@unlock expr
76+
77+
Unlock the GIL, compute `expr`, re-lock the GIL, then return the result of `expr`.
78+
79+
Use this to run non-Python code with the GIL unlocked, so allowing another thread to run
80+
Python code. That other thread can be a Julia thread, which must lock the GIL using
81+
[`@lock`](@ref).
82+
83+
The macro equivalent of [`unlock`](@ref).
84+
"""
85+
macro unlock(expr)
86+
quote
87+
state = C.PyEval_SaveThread()
88+
try
89+
$(esc(expr))
90+
finally
91+
C.PyEval_RestoreThread(state)
92+
end
93+
end
94+
end
95+
96+
end

‎src/JlWrap/JlWrap.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ using ..Convert:
4242
pyconvertarg,
4343
pyconvert_result
4444
using ..GC: GC
45+
using ..GIL: GIL
4546

4647
using Pkg: Pkg
4748
using Base: @propagate_inbounds, allocatedinline

‎src/JlWrap/any.jl

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,24 @@ end
5151
pyjl_handle_error_type(::typeof(pyjlany_call), self, exc) =
5252
exc isa MethodError && exc.f === self ? pybuiltins.TypeError : PyNULL
5353

54+
function pyjlany_call_nogil(self, args_::Py, kwargs_::Py)
55+
if pylen(kwargs_) > 0
56+
args = pyconvert(Vector{Any}, args_)
57+
kwargs = pyconvert(Dict{Symbol,Any}, kwargs_)
58+
ans = Py(GIL.@unlock self(args...; kwargs...))
59+
elseif pylen(args_) > 0
60+
args = pyconvert(Vector{Any}, args_)
61+
ans = Py(GIL.@unlock self(args...))
62+
else
63+
ans = Py(GIL.@unlock self())
64+
end
65+
pydel!(args_)
66+
pydel!(kwargs_)
67+
ans
68+
end
69+
pyjl_handle_error_type(::typeof(pyjlany_call_nogil), self, exc) =
70+
exc isa MethodError && exc.f === self ? pybuiltins.TypeError : PyNULL
71+
5472
function pyjlany_getitem(self, k_::Py)
5573
if pyistuple(k_)
5674
k = pyconvert(Vector{Any}, k_)
@@ -334,11 +352,21 @@ class AnyValue(ValueBase):
334352
def __name__(self):
335353
return self._jl_callmethod($(pyjl_methodnum(pyjlany_name)))
336354
def _jl_raw(self):
355+
'''Convert this to a juliacall.RawValue.'''
337356
return self._jl_callmethod($(pyjl_methodnum(pyjlraw)))
338357
def _jl_display(self, mime=None):
358+
'''Display this, optionally specifying the MIME type.'''
339359
return self._jl_callmethod($(pyjl_methodnum(pyjlany_display)), mime)
340360
def _jl_help(self, mime=None):
361+
'''Show help for this Julia object.'''
341362
return self._jl_callmethod($(pyjl_methodnum(pyjlany_help)), mime)
363+
def _jl_call_nogil(self, *args, **kwargs):
364+
'''Call this with the given arguments but with the GIL disabled.
365+
366+
WARNING: This function must not interact with Python at all without re-acquiring
367+
the GIL.
368+
'''
369+
return self._jl_callmethod($(pyjl_methodnum(pyjlany_call_nogil)), args, kwargs)
342370
def _repr_mimebundle_(self, include=None, exclude=None):
343371
return self._jl_callmethod($(pyjl_methodnum(pyjlany_mimebundle)), include, exclude)
344372
""",

‎src/JlWrap/raw.jl

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ function pyjlraw_call(self, args_::Py, kwargs_::Py)
4040
ans
4141
end
4242

43+
function pyjlraw_call_nogil(self, args_::Py, kwargs_::Py)
44+
if pylen(kwargs_) > 0
45+
args = pyconvert(Vector{Any}, args_)
46+
kwargs = pyconvert(Dict{Symbol,Any}, kwargs_)
47+
ans = pyjlraw(GIL.@unlock self(args...; kwargs...))
48+
elseif pylen(args_) > 0
49+
args = pyconvert(Vector{Any}, args_)
50+
ans = pyjlraw(GIL.@unlock self(args...))
51+
else
52+
ans = pyjlraw(GIL.@unlock self())
53+
end
54+
pydel!(args_)
55+
pydel!(kwargs_)
56+
ans
57+
end
58+
4359
pyjlraw_len(self) = Py(length(self))
4460

4561
function pyjlraw_getitem(self, k_::Py)
@@ -129,7 +145,15 @@ class RawValue(ValueBase):
129145
def __bool__(self):
130146
return self._jl_callmethod($(pyjl_methodnum(pyjlraw_bool)))
131147
def _jl_any(self):
148+
'''Convert this to a juliacall.AnyValue.'''
132149
return self._jl_callmethod($(pyjl_methodnum(pyjl)))
150+
def _jl_call_nogil(self, *args, **kwargs):
151+
'''Call this with the given arguments but with the GIL disabled.
152+
153+
WARNING: This function must not interact with Python at all without re-acquiring
154+
the GIL.
155+
'''
156+
return self._jl_callmethod($(pyjl_methodnum(pyjlraw_call_nogil)), args, kwargs)
133157
""",
134158
@__FILE__(),
135159
"exec",

‎src/PythonCall.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const ROOT_DIR = dirname(@__DIR__)
55

66
include("Utils/Utils.jl")
77
include("C/C.jl")
8+
include("GIL/GIL.jl")
89
include("GC/GC.jl")
910
include("Core/Core.jl")
1011
include("Convert/Convert.jl")

‎test/GC.jl

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
11
@testitem "GC.gc()" begin
22
let
33
pyobjs = map(pylist, 1:100)
4-
Threads.@threads for obj in pyobjs
4+
PythonCall.GIL.@unlock Threads.@threads for obj in pyobjs
55
finalize(obj)
66
end
77
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)
8+
Threads.nthreads() > 1 && @test !isempty(PythonCall.GC.QUEUE.items)
119
PythonCall.GC.gc()
1210
@test isempty(PythonCall.GC.QUEUE.items)
1311
end
1412

1513
@testitem "GC.GCHook" begin
1614
let
1715
pyobjs = map(pylist, 1:100)
18-
Threads.@threads for obj in pyobjs
16+
PythonCall.GIL.@unlock Threads.@threads for obj in pyobjs
1917
finalize(obj)
2018
end
2119
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)
20+
Threads.nthreads() > 1 && @test !isempty(PythonCall.GC.QUEUE.items)
2521
GC.gc()
2622
@test isempty(PythonCall.GC.QUEUE.items)
2723
end

‎test/GIL.jl

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
@testitem "unlock and lock" begin
2+
# This calls Python's time.sleep(1) twice concurrently. Since sleep() unlocks the
3+
# GIL, these can happen in parallel if Julia has at least 2 threads.
4+
function threaded_sleep()
5+
PythonCall.GIL.unlock() do
6+
Threads.@threads for i = 1:2
7+
PythonCall.GIL.lock() do
8+
pyimport("time").sleep(1)
9+
end
10+
end
11+
end
12+
end
13+
# one run to ensure it's compiled
14+
threaded_sleep()
15+
# now time it
16+
t = @timed threaded_sleep()
17+
# if we have at least 2 threads, the sleeps run in parallel and take about a second
18+
if Threads.nthreads() 2
19+
@test 0.9 < t.time < 1.2
20+
end
21+
end
22+
23+
@testitem "@unlock and @lock" begin
24+
# This calls Python's time.sleep(1) twice concurrently. Since sleep() unlocks the
25+
# GIL, these can happen in parallel if Julia has at least 2 threads.
26+
function threaded_sleep()
27+
PythonCall.GIL.@unlock Threads.@threads for i = 1:2
28+
PythonCall.GIL.@lock pyimport("time").sleep(1)
29+
end
30+
end
31+
# one run to ensure it's compiled
32+
threaded_sleep()
33+
# now time it
34+
t = @timed threaded_sleep()
35+
# if we have at least 2 threads, the sleeps run in parallel and take about a second
36+
if Threads.nthreads() 2
37+
@test 0.9 < t.time < 1.2
38+
end
39+
end

0 commit comments

Comments
 (0)
Please sign in to comment.