11const PyThreadStatePtr = Ptr{Cvoid}
22
3- mutable struct ThreadState
4- sem:: Base.Semaphore
5- tstate:: PyThreadStatePtr
6- end
7- ThreadState () = ThreadState (Base. Semaphore (1 ), C_NULL )
8-
93mutable struct TaskState
4+ # CPython state used by this task for the duration of its outermost region.
105 tstate:: PyThreadStatePtr
6+ # Semaphore protecting `tstate`; `nothing` when the task is outside a session.
117 sem:: Union{Nothing,Base.Semaphore}
8+ # Whether `tstate` is currently attached to the task's pinned OS thread.
129 attached:: Bool
10+ # Julia thread to which the task is pinned while its session is active.
1311 tid:: Int
12+ # Stickiness to restore when the task's outermost region or break finishes.
1413 oldsticky:: Bool
1514end
1615TaskState () = TaskState (C_NULL , nothing , false , 0 , false )
1716
17+ mutable struct ThreadState
18+ # Serializes tasks which use the persistent CPython state on this Julia thread.
19+ sem:: Base.Semaphore
20+ # PythonCall-owned CPython state for this Julia thread, allocated lazily.
21+ tstate:: PyThreadStatePtr
22+ # Task which currently holds `sem` with a CPython state attached, if any.
23+ task:: Union{Nothing,Task}
24+ # State belonging to `task`; cached so nested regions need no task-local lookup.
25+ task_state:: Union{Nothing,TaskState}
26+ end
27+ ThreadState () = ThreadState (Base. Semaphore (1 ), C_NULL , nothing , nothing )
28+
1829const THREAD_STATE = Utils. OncePerThread {ThreadState} (ThreadState)
1930const TASK_STATE = Utils. OncePerTask {TaskState} (TaskState)
2031
32+ # A region session starts at the outermost region entered by a Julia task and ends
33+ # when that region exits. The task is made sticky for the whole session: CPython
34+ # thread states must be restored on the same OS thread on which they were saved.
35+ # The corresponding ThreadState semaphore gives the task exclusive use of that
36+ # thread's persistent PyThreadState. A Python-originated call already has a CPython
37+ # state attached; that state is borrowed instead, but is protected by the same
38+ # semaphore so PythonCall's accounting remains identical.
39+ #
40+ # Nested regions merely return `:noop`. A break saves/detaches the CPython state,
41+ # clears the thread cache, and releases the semaphore, allowing other sticky tasks
42+ # to use this Julia thread while the original task yields. On return it reacquires
43+ # the semaphore before restoring both the CPython state and cache. Consequently the
44+ # cache is populated exactly while a task owns the semaphore with Python attached.
45+ # This invariant is what makes its lock-free fast path safe.
46+ #
47+ # An attached CPython state is also a still cheaper fast path for @pyregion itself.
48+ # Given that PythonCall is the only Julia-side entry to Python, an attached state
49+ # means either that an enclosing region already did the bookkeeping or that Python
50+ # called into Julia and lent us its state. In either case a nested region has no
51+ # transition to perform. Conversely, a region inside a break observes no attached
52+ # state and takes the full path below, reacquiring and restoring its task state.
53+
2154current_tstate () = PyThreadState_GetUnchecked ()
2255has_tstate () = CTX. is_initialized && current_tstate () != C_NULL
2356
57+ # An attached task is pinned to this Julia thread and owns its semaphore, so the
58+ # thread-local cache cannot change underneath it. This makes nested regions avoid
59+ # the considerably more expensive OncePerTask lookup. The cache is cleared before
60+ # releasing the semaphore and restored after reacquiring it around a region break.
61+ function current_task_state (task:: Task )
62+ ts = THREAD_STATE ()
63+ return ts. task === task ? ts. task_state:: TaskState : TASK_STATE ()
64+ end
65+
66+ function set_task! (ts:: ThreadState , task:: Task , s:: TaskState )
67+ ts. task = task
68+ ts. task_state = s
69+ return
70+ end
71+
72+ function clear_task! (ts:: ThreadState )
73+ ts. task = nothing
74+ ts. task_state = nothing
75+ return
76+ end
77+
2478function reset! (s:: TaskState )
79+ # `oldsticky` is deliberately left alone: start_session! overwrites it before
80+ # the TaskState becomes observable as an active session again.
2581 s. tstate = C_NULL
2682 s. sem = nothing
2783 s. attached = false
@@ -30,6 +86,8 @@ function reset!(s::TaskState)
3086end
3187
3288function start_session! (task:: Task , s:: TaskState )
89+ # Pin before recording the thread and looking up its ThreadState. Once sticky,
90+ # cooperative scheduling cannot resume this task on another Julia thread.
3391 s. oldsticky = task. sticky
3492 task. sticky = true
3593 s. tid = Threads. threadid ()
45103
46104function enter_region (task:: Task , s:: TaskState )
47105 if s. tstate != C_NULL
106+ # An existing session is either already attached (ordinary nesting), or
107+ # temporarily detached because this region is nested inside a break.
48108 check_thread (task, s)
49109 if s. attached
50110 return :noop
@@ -53,6 +113,7 @@ function enter_region(task::Task, s::TaskState)
53113 try
54114 PyEval_RestoreThread (s. tstate)
55115 s. attached = true
116+ set_task! (THREAD_STATE (), task, s)
56117 catch
57118 Base. release (s. sem:: Base.Semaphore )
58119 rethrow ()
@@ -73,8 +134,10 @@ function enter_region(task::Task, s::TaskState)
73134 acquired = true
74135 current = current_tstate ()
75136 if current != C_NULL
137+ # Calls originating in Python must return with its state still attached.
76138 s. tstate = current
77139 s. attached = true
140+ set_task! (ts, task, s)
78141 return :root_borrowed
79142 end
80143 if ts. tstate == C_NULL
@@ -84,6 +147,7 @@ function enter_region(task::Task, s::TaskState)
84147 s. tstate = ts. tstate
85148 PyEval_RestoreThread (s. tstate)
86149 s. attached = true
150+ set_task! (ts, task, s)
87151 return :root_owned
88152 catch
89153 acquired && Base. release (ts. sem)
@@ -94,23 +158,28 @@ function enter_region(task::Task, s::TaskState)
94158end
95159
96160function exit_region (task:: Task , s:: TaskState , token)
161+ # Tokens encode precisely which work enter_region performed, so every path
162+ # reverses only its own transition and nested :noop regions do no attach work.
97163 token === :noop && return
98164 check_thread (task, s)
99165 if token === :detach
100166 saved = PyEval_SaveThread ()
101167 @assert saved == s. tstate
102168 s. attached = false
169+ clear_task! (THREAD_STATE ())
103170 Base. release (s. sem:: Base.Semaphore )
104171 elseif token === :root_owned
105172 saved = PyEval_SaveThread ()
106173 @assert saved == s. tstate
107174 sem, oldsticky = s. sem:: Base.Semaphore , s. oldsticky
175+ clear_task! (THREAD_STATE ())
108176 reset! (s)
109177 Base. release (sem)
110178 task. sticky = oldsticky
111179 elseif token === :root_borrowed
112180 @assert current_tstate () == s. tstate
113181 sem, oldsticky = s. sem:: Base.Semaphore , s. oldsticky
182+ clear_task! (THREAD_STATE ())
114183 reset! (s)
115184 Base. release (sem)
116185 task. sticky = oldsticky
@@ -122,14 +191,18 @@ end
122191
123192function enter_break (task:: Task , s:: TaskState )
124193 if s. tstate != C_NULL
194+ # Only the outermost break detaches. Further breaks while detached are no-ops.
125195 check_thread (task, s)
126196 ! s. attached && return :noop
127197 saved = PyEval_SaveThread ()
128198 @assert saved == s. tstate
129199 s. attached = false
200+ clear_task! (THREAD_STATE ())
130201 Base. release (s. sem:: Base.Semaphore )
131202 return :restore
132203 end
204+ # Outside a region, a break matters only in a Python-originated callback. It
205+ # borrows and detaches that state so yielding Julia code cannot retain Python.
133206 current_tstate () == C_NULL && return :noop
134207
135208 ts = try
@@ -170,8 +243,12 @@ function exit_break(task::Task, s::TaskState, token)
170243 Base. acquire (s. sem:: Base.Semaphore )
171244 PyEval_RestoreThread (s. tstate)
172245 s. attached = true
246+ set_task! (THREAD_STATE (), task, s)
173247 if token === :root_restore
248+ # The borrowed Python state stays attached for the caller, but the temporary
249+ # Julia session and its cache ownership end here.
174250 sem, oldsticky = s. sem:: Base.Semaphore , s. oldsticky
251+ clear_task! (THREAD_STATE ())
175252 reset! (s)
176253 Base. release (sem)
177254 task. sticky = oldsticky
@@ -191,13 +268,19 @@ blocks cooperatively in [`@pyregionbreak`](@ref).
191268"""
192269macro pyregion (ex)
193270 quote
194- local task = current_task ()
195- local state = $ TASK_STATE ()
196- local token = $ enter_region (task, state)
197- try
271+ if $ current_tstate () != C_NULL
272+ # The CPython TLS lookup is enough to prove that this region is nested
273+ # or Python-originated; avoid both Julia task- and thread-local lookups.
198274 $ (esc (ex))
199- finally
200- $ exit_region (task, state, token)
275+ else
276+ local task = current_task ()
277+ local state = $ current_task_state (task)
278+ local token = $ enter_region (task, state)
279+ try
280+ $ (esc (ex))
281+ finally
282+ $ exit_region (task, state, token)
283+ end
201284 end
202285 end
203286end
@@ -212,7 +295,7 @@ PythonCall operations still work automatically, and both kinds of region nest fr
212295macro pyregionbreak (ex)
213296 quote
214297 local task = current_task ()
215- local state = $ TASK_STATE ( )
298+ local state = $ current_task_state (task )
216299 local token = $ enter_break (task, state)
217300 try
218301 $ (esc (ex))
0 commit comments