Skip to content

Commit 58dba62

Browse files
committed
First GlobalInterpreterLock that works
1 parent 53867da commit 58dba62

File tree

1 file changed

+74
-18
lines changed

1 file changed

+74
-18
lines changed

src/GIL/GlobalInterpreterLock.jl

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1+
"""
2+
TaskState
3+
4+
When a `Task` acquires the GIL, save the GIL state and the stickiness of the
5+
`Task` since we will force the `Task` to be sticky. We need to restore the GIL
6+
state on release of the GIL via `C.PyGILState_Release`.
7+
"""
18
struct TaskState
29
task::Task
310
sticky::Bool # original stickiness of the task
411
state::C.PyGILState_STATE
512
end
613

14+
"""
15+
TaskStack
16+
17+
For each thread the `TaskStack` maintains a first-in-last-out list of tasks
18+
as well as the GIL state and their stickiness upon entering the stack. This
19+
forces tasks to unlock the GIL in the reverse order of which they locked it.
20+
"""
721
struct TaskStack
822
stack::Vector{TaskState}
923
count::IdDict{Task,Int}
@@ -43,7 +57,7 @@ function Base.pop!(task_stack::TaskStack)::Task
4357
# If 0, remove it from the key set
4458
pop!(task_stack.count, task)
4559
else
46-
task_stack[task] = count
60+
task_stack.count[task] = count
4761
end
4862

4963
C.PyGILState_Release(gil_state)
@@ -57,42 +71,56 @@ function Base.pop!(task_stack::TaskStack)::Task
5771

5872
return task
5973
end
74+
Base.in(task::Task, task_stack::TaskStack) = haskey(task_stack.count)
6075
Base.isempty(task_stack::TaskStack) = isempty(task_stack.stack)
6176

6277
if !isdefined(Base, :OncePerThread)
6378

79+
const PerThreadLock = Base.ThreadSynchronizer()
80+
6481
# OncePerThread is implemented in full in Julia 1.12
65-
# This implementation is meant for compatibility with Julia 1.10 and 1.11
66-
# and only supports a static number of threads. Use Julia 1.12 for dynamic
67-
# thread usage.
82+
# This implementation is meant for compatibility with Julia 1.10 and 1.11.
83+
# Using Julia 1.12 is recommended.
6884
mutable struct OncePerThread{T,F} <: Function
69-
@atomic xs::Vector{T} # values
70-
@atomic ss::Vector{UInt8} # states: 0=initial, 1=hasrun, 2=error, 3==concurrent
85+
@atomic xs::Dict{Int, T} # values
86+
@atomic ss::Dict{Int, UInt8} # states: 0=initial, 1=hasrun, 2=error, 3==concurrent
7187
const initializer::F
7288
function OncePerThread{T,F}(initializer::F) where {T,F}
7389
nt = Threads.maxthreadid()
74-
return new{T,F}(Vector{T}(undef, nt), zeros(UInt8, nt), initializer)
90+
return new{T,F}(Dict{Int,T}(), Dict{Int,UInt8}(), initializer)
7591
end
7692
end
7793
OncePerThread{T}(initializer::Type{U}) where {T, U} = OncePerThread{T,Type{U}}(initializer)
7894
(once::OncePerThread{T,F})() where {T,F} = once[Threads.threadid()]
7995
function Base.getindex(once::OncePerThread, tid::Integer)
80-
tid = Threads.threadid()
96+
tid = Int(tid)
8197
ss = @atomic :acquire once.ss
8298
xs = @atomic :monotonic once.xs
83-
if checkbounds(Bool, xs, tid)
84-
if ss[tid] == 0
99+
100+
if haskey(ss, tid) && ss[tid] == 1
101+
return xs[tid]
102+
end
103+
104+
Base.lock(PerThreadLock)
105+
try
106+
state = get(ss, tid, 0)
107+
if state == 0
85108
xs[tid] = once.initializer()
86109
ss[tid] = 1
87110
end
88-
return xs[tid]
89-
else
90-
throw(ErrorException("Thread id $tid is out of bounds as initially allocated. Use Julia 1.12 for dynamic thread usage."))
111+
finally
112+
Base.unlock(PerThreadLock)
91113
end
114+
return xs[tid]
92115
end
93-
94116
end
95117

118+
"""
119+
GlobalInterpreterLock
120+
121+
Provides a thread aware reentrant lock around Python's interpreter lock that
122+
ensures that `Task`s acquiring the lock stay on the same thread.
123+
"""
96124
struct GlobalInterpreterLock <: Base.AbstractLock
97125
lock_owners::OncePerThread{TaskStack}
98126
function GlobalInterpreterLock()
@@ -105,16 +133,44 @@ function Base.lock(gil::GlobalInterpreterLock)
105133
end
106134
function Base.unlock(gil::GlobalInterpreterLock)
107135
lock_owner::TaskStack = gil.lock_owners()
108-
while last(lock_owner) != current_task()
109-
wait(lock_owner.condvar)
136+
last_owner::Task = if isempty(lock_owner)
137+
current_task()
138+
else
139+
last(lock_owner)
140+
end
141+
while last_owner != current_task()
142+
if istaskdone(last_owner) && !isempty(lock_owner)
143+
# Last owner is done and unable to unlock the GIL
144+
pop!(lock_owner)
145+
error("Unlock from the wrong task. The Task that owned the GIL is done and did not unlock the GIL: $(last_owner)")
146+
else
147+
# This task does not own the GIL. Wait to unlock the GIL until
148+
# another task successfully unlocks the GIL.
149+
wait(lock_owner.condvar)
150+
end
151+
last_owner = if isempty(lock_owner)
152+
current_task()
153+
else
154+
last(lock_owner)
155+
end
156+
end
157+
if isempty(lock_owner)
158+
error("Unlock from wrong task: $(current_task). No tasks on this thread own the lock.")
159+
else
160+
task = pop!(lock_owner)
110161
end
111-
task = pop!(lock_owner)
112162
@assert task == current_task()
113163
return nothing
114164
end
115165
function Base.islocked(gil::GlobalInterpreterLock)
116-
# TODO: handle Julia 1.10 and 1.11 case when have not allocated up to maxthreadid
117166
return any(!isempty(gil.lock_owners[thread_index]) for thread_index in 1:Threads.maxthreadid())
118167
end
168+
function haslock(gil::GlobalInterpreterLock, task::Task)
169+
lock_owner::TaskStack = gil.lock_owners()
170+
if isempty(lock_owner)
171+
return false
172+
end
173+
return last(lock_owner)::Task == task
174+
end
119175

120176
const _GIL = GlobalInterpreterLock()

0 commit comments

Comments
 (0)