Reentrant lock acquires (locks) by more than one thread

Viewed 40

Python v3.10

  • the RLock (reentral) lock in Python is a thread aware lock that can be unlocked by only one thread at the time (has other benefits, but that's off topic in this test)
  • the expected behavior in the below example: we have 3 threads, only one of them should be able to acquire (unlock) the RLock, but more than one acquires the same RLock when there's no work in the thread

Unexpected behavior:

import threading 

lock = threading.RLock()

def th(name):
    print( f"{name} tread started")
    
    lock.acquire()
    
    print( f"{name} tread end")

th1 = threading.Thread(target=th, args=[1])
th2 = threading.Thread(target=th, args=[2])
th3 = threading.Thread(target=th, args=[3])

th1.start()
th2.start()
th3.start()

Output ->

1 tread started
1 tread end
2 tread started
2 tread end
3 tread started
3 tread end

We can clearly see that all 3 threads unlocks the RLock (sometimes 2 sometimes 3)

Expected behavior:

import threading 
import time

lock = threading.RLock()

def th(name):
    print( f"{name} tread started")
    
    lock.acquire()
    time.sleep(0.1)       # simulating some work

    print( f"{name} tread end")

th1 = threading.Thread(target=th, args=[1])
th2 = threading.Thread(target=th, args=[2])
th3 = threading.Thread(target=th, args=[3])

th1.start()
th2.start()
th3.start()

Output ->

1 tread started
2 tread started
3 tread started
1 tread end

When there's some work the RLock does its thing (acquired by thread1 and block thread2 and thread3 untill thread1 releases the RLock) I tired this with loops too, but it seems when there's no or very little work in threads the RLock acquired by multiple threads

  • Is this a bug? or am I doing wrong something?
1 Answers

It's not a bug, because you missed one thing - lock is released when thread is destroyed, i.e. not alive. On my machine I typically get something like

1 tread started
1 tread end
2 tread started
3 tread started
3 tread end

... it can vary, since it's inherently racy, but why thread 3 is reached towards the end? I added prints to your example:

th1.start()
print( f"th1.is_alive={th1.is_alive()}, th2.is_alive={th2.is_alive()}, th3.is_alive={th3.is_alive()}")
th2.start()
print( f"th1.is_alive={th1.is_alive()}, th2.is_alive={th2.is_alive()}, th3.is_alive={th3.is_alive()}")
th3.start()

and here is the output

1 tread started
th1.is_alive=True, th2.is_alive=False, th3.is_alive=False
2 tread started
th1.is_alive=False, th2.is_alive=True, th3.is_alive=False
3 tread started
3 tread activated
2 tread activated
3 tread end
1 tread activated

So, when thread 3 starts - thread 1 is already dead and rlock is released (your output example looks correspond to the case when previous thread is not alive when next starts).

To make this system more deterministic, let's make sure that all threads are started before trying to acquiring the rlock with e.g. barrier object:

import threading 

lock = threading.RLock()
bStart = threading.Barrier(3)
bEnd = threading.Barrier(3)


def th(name):
    print( f"{name} tread started")

    bStart.wait()
    print( f"{name} tread activated")

    lock.acquire()
    ## some job
    # lock.release()

    print( f"{name} tread end")    
    bEnd.wait()

th1 = threading.Thread(target=th, args=[1])
th2 = threading.Thread(target=th, args=[2])
th3 = threading.Thread(target=th, args=[3])

th1.start()
th2.start()
th3.start()

And now we have expected and more deterministic output (the order of "activation" cannot be guaranteed, of course):

1 tread started
2 tread started
3 tread started
3 tread activated
3 tread end
2 tread activated
1 tread activated

(I used barrier before thread exit to make sure rlock has no chance to be released by thread destruction, so threads are destroyed all "at once")

So, now we see that only one thread has acquired the rlock and other threads are blocked on it.

Related