Skip to content

Commit 3369142

Browse files
committed
Manage Python thread states per Julia task
1 parent 0ec0320 commit 3369142

24 files changed

Lines changed: 493 additions & 166 deletions

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
- **Python tests**:
66
- Copy `pysrc/juliacall/juliapkg-dev.json` to `pysrc/juliacall/juliapkg.json` before running (do **not** commit this copy).
77
- Execute with `uv run pytest -s --nbval ./pytest` (add `--cov=pysrc` when coverage is needed).
8-
- Sometimes `juliapkg` requires Julia 1.10–1.11; `juliaup` already provides 1.11.7 in this environment.
8+
- Sometimes `juliapkg` requires Julia 1.10–1.11; `juliaup` already provides 1.11.7 in this environment.
9+
- With Python 3.14, juliapkg's OpenSSL compatibility currently constrains Julia to 1.11 or
10+
older. Packages requiring Julia 1.12+ therefore cannot run the Python suite under that
11+
Python; use an older Python whose OpenSSL constraint permits Julia 1.12+.
912

1013
The majority of tests live in the Julia package; Python tests cover functionality that cannot be exercised from Julia (e.g., JuliaCall-specific behavior). Run both suites—typically Julia first—in whichever order makes sense.
1114

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ PyCall = "1"
3434
Serialization = "1"
3535
Tables = "1"
3636
UnsafePointers = "1"
37-
julia = "1.10"
37+
julia = "1.12"
3838

3939
[extras]
4040
CategoricalArrays = "324d7699-5711-5eae-9e2f-1d82baa6b597"

docs/src/juliacall.md

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,11 @@ caveats.
157157

158158
Most importantly, you can only call Python code while Python's
159159
[Global Interpreter Lock (GIL)](https://docs.python.org/3/glossary.html#term-global-interpreter-lock)
160-
is locked by the current thread. You can use JuliaCall from any Python thread, and the GIL
161-
will be locked whenever any JuliaCall function is used. However, to leverage the benefits
162-
of multi-threading, you can unlock the GIL while executing any Julia code that does not
163-
interact with Python.
160+
JuliaCall borrows the Python thread state which entered Julia and automatically detaches it
161+
while arbitrary Julia code runs. Nested Python interaction from that Julia code temporarily
162+
reattaches the same state, and the borrowed state is restored before returning to Python.
164163

165-
The simplest way to do this is using the `_jl_call_nogil` method on Julia functions to
166-
call the function with the GIL unlocked.
164+
The historical `_jl_call_nogil` spelling remains available as a compatibility alias:
167165

168166
```python
169167
from concurrent.futures import ThreadPoolExecutor, wait
@@ -173,16 +171,9 @@ fs = [pool.submit(jl.Libc.systemsleep._jl_call_nogil, 5) for _ in range(4)]
173171
wait(fs)
174172
```
175173

176-
In the above example, we call `Libc.systemsleep(5)` on four threads. Because we
177-
called it with `_jl_call_nogil`, the GIL was unlocked, allowing the threads to run in
178-
parallel, taking about 5 seconds in total.
179-
180-
If we did not use `_jl_call_nogil` (i.e. if we did `pool.submit(jl.Libc.systemsleep, 5)`)
181-
then the above code will take 20 seconds because the sleeps run one after another.
182-
183-
It is very important that any function called with `_jl_call_nogil` does not interact
184-
with Python at all unless it re-locks the GIL first, such as by using
185-
[PythonCall.GIL.@lock](@ref).
174+
Ordinary calls provide the same automatic resource management, so
175+
`pool.submit(jl.Libc.systemsleep, 5)` is preferred. PythonCall operations nested inside
176+
Julia callbacks are safe without explicit region or lock calls.
186177

187178
You can also use [multi-threading from Julia](@ref jl-multi-threading).
188179

docs/src/pythoncall-reference.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,15 @@ Py(x::MyType) = x.py
222222

223223
## Multi-threading
224224

225-
These functions are not exported. They support multi-threading of Python and/or Julia.
226-
See also [`juliacall.AnyValue._jl_call_nogil`](@ref julia-wrappers).
225+
PythonCall manages Python thread state automatically. These exported macros are optional
226+
performance and concurrency hints; users normally do not need them for correctness.
227+
228+
```@docs
229+
@pyregion
230+
@pyregionbreak
231+
```
232+
233+
The older `PythonCall.GIL` names remain as compatibility aliases.
227234

228235
```@docs
229236
PythonCall.GIL.lock

docs/src/pythoncall.md

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -471,42 +471,35 @@ See [Installing Python packages](@ref python-deps).
471471

472472
Multi-threading support is experimental and can change without notice.
473473

474-
From v0.9.22, PythonCall supports multi-threading in Julia and/or Python, with some
475-
caveats.
476-
477-
Most importantly, you can only call Python code while Python's
478-
[Global Interpreter Lock (GIL)](https://docs.python.org/3/glossary.html#term-global-interpreter-lock)
479-
is locked by the current thread. Ordinarily, the GIL is locked by the main thread in Julia,
480-
so if you want to run Python code on any other thread, you must unlock the GIL from the
481-
main thread and then re-lock it while running any Python code on other threads.
482-
483-
This is made possible by the macros [`PythonCall.GIL.@unlock`](@ref) and
484-
[`PythonCall.GIL.@lock`](@ref) or the functions [`PythonCall.GIL.unlock`](@ref) and
485-
[`PythonCall.GIL.lock`](@ref) with this pattern:
474+
PythonCall APIs automatically establish the Python thread state they need, so ordinary
475+
operations can be called from any Julia task or thread without explicit locking. The
476+
optional [`@pyregion`](@ref) macro amortizes those transitions across straight-line,
477+
Python-heavy work. Use [`@pyregionbreak`](@ref) around Julia-heavy code which deliberately
478+
yields, waits, or blocks cooperatively:
486479

487480
```julia
488-
PythonCall.GIL.@unlock Threads.@threads for i in 1:4
489-
PythonCall.GIL.@lock pyimport("time").sleep(5)
481+
Threads.@threads for i in 1:4
482+
@pyregion pyimport("time").sleep(5)
490483
end
491484
```
492485

493486
In the above example, we call `time.sleep(5)` four times in parallel. If Julia was
494487
started with at least four threads (`julia -t4`) then the above code will take about
495488
5 seconds.
496489

497-
Both `@unlock` and `@lock` are important. If the GIL were not unlocked, then a deadlock
498-
would occur when attempting to lock the already-locked GIL from the threads. If the GIL
499-
were not re-locked, then Python would crash when interacting with it.
490+
Both region macros nest arbitrarily, and neither is required for correctness. A nested
491+
PythonCall operation inside `@pyregionbreak` temporarily re-enters Python automatically.
492+
On a GIL-enabled Python, attaching a state can block that Julia worker while CPython
493+
arbitrates access; free-threaded Python uses the same state-management machinery.
500494

501495
With multiple Julia threads you need exactly one interactive thread, see the [FAQ](@ref faq-multi-threading).
502496

503497
You can also use [multi-threading from Python](@ref py-multi-threading).
504498

505499
### Caveat: Garbage collection
506500

507-
If Julia's GC collects any Python objects from a thread where the GIL is not currently
508-
locked, then those Python objects will not immediately be deleted. Instead they will be
509-
queued to be deleted in a later GC pass.
501+
If Julia's GC collects Python objects while no Python thread state is already attached,
502+
those objects are queued rather than making a finalizer block while attaching a state.
510503

511504
If you find you have many Python objects not being deleted, you can call
512-
[`PythonCall.GC.gc()`](@ref) or `GC.gc()` while the GIL is locked to clear the queue.
505+
[`PythonCall.GC.gc()`](@ref) or `GC.gc()` to clear the queue.

pysrc/juliacall/juliapkg-dev.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"julia": "^1.10.3",
2+
"julia": "^1.12",
33
"packages": {
44
"PythonCall": {
55
"uuid": "6099a3de-0909-46bc-b1f4-468b9a2dfc0d",

pysrc/juliacall/juliapkg.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"julia": "^1.10.3",
2+
"julia": "^1.12",
33
"packages": {
44
"PythonCall": {
55
"uuid": "6099a3de-0909-46bc-b1f4-468b9a2dfc0d",

src/API/exports.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export @py
33
export @pyconst
44
export @pyeval
55
export @pyexec
6+
export @pyregion
7+
export @pyregionbreak
68
export ispy
79
export Py
810
export pyabs

src/API/macros.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
macro pyconst end
33
macro pyeval end
44
macro pyexec end
5+
macro pyregion end
6+
macro pyregionbreak end
57

68
# Convert
79
macro pyconvert end

src/C/context.jl

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ A handle to a loaded instance of libpython, its interpreter, function pointers,
2121
end
2222

2323
const CTX = Context()
24+
const FINALIZE_HOOK = Ref{Function}(() -> begin
25+
if Py_FinalizeEx() == -1
26+
@warn "Py_FinalizeEx() error"
27+
end
28+
end)
2429

2530
function _atpyexit()
2631
if CTX.is_initialized && !CTX.is_preinitialized
@@ -282,9 +287,7 @@ function init_context()
282287
Py_InitializeEx(0)
283288
atexit() do
284289
CTX.is_initialized = false
285-
if Py_FinalizeEx() == -1
286-
@warn "Py_FinalizeEx() error"
287-
end
290+
FINALIZE_HOOK[]()
288291
end
289292
end
290293
CTX.is_initialized = true

0 commit comments

Comments
 (0)