Нужен ли класс threading.Lock?
Скопируй и запусти две разных версии кода.

Без Lock
from threading import *

def work(i):
    for _ in range(100):
        print(f"hello i'm a thread #{i}")

t1 = Thread(target=work, args=(1,))
t2 = Thread(target=work, args=(2,))
t1.start()
t2.start()
t1.join()
t2.join()


С Lock
from threading import *

lock = Lock()

def work(i):
    for _ in range(100):
        with lock:
            print(f"hello i'm a thread #{i}")

t1 = Thread(target=work, args=(1,))
t2 = Thread(target=work, args=(2,))
t1.start()
t2.start()
t1.join()
t2.join()


Как можешь заметить, первый вариант иногда печатает две строки на одной, а иногда печатает пустые строки

Ещё примеры

Без Lock
from threading import *
from time import sleep


class GlobalState:
    def __init__(self, x):
        self.x = x
        
    def set_x(self, x):
        self.x = x

def reader(state: GlobalState):
    if state.x % 2 == 0:
        sleep(0.01)  # simulate OS context switch
        print(f"{state.x=} is even")
    else:
        print(f"{state.x=} is odd")
        

def changer(state: GlobalState):
    state.set_x(state.x + 1)

state = GlobalState(2)
t1 = Thread(target=reader, args=(state,))
t2 = Thread(target=changer, args=(state,))
t1.start()
t2.start()
t1.join()
t2.join()

С Lock
from threading import *
from time import sleep


class GlobalState:
    def __init__(self, x):
        self.x = x
        self.lock = Lock()
        
    def set_x(self, x):
        self.x = x

def reader(state: GlobalState):
    with state.lock:
        if state.x % 2 == 0:
            sleep(0.01)  # simulate OS context switch
            print(f"{state.x=} is even")
        else:
            print(f"{state.x=} is odd")
        

def changer(state: GlobalState):
    with state.lock:
        state.set_x(state.x + 1)

state = GlobalState(2)
t1 = Thread(target=reader, args=(state,))
t2 = Thread(target=changer, args=(state,))
t1.start()
t2.start()
t1.join()
t2.join()

Ну и совсем упоротый пример для тех, кто говорит, что list — threadsafe (что фактически является истиной, но логически не всегда) и не нужно использовать Lock:
Открыть
from threading import *
from random import *

class GlobalState:
    def __init__(self):
        self.x = []
        
    def do_something_changing(self):
        if random() < 0.5:
            self.x.append(1)
        elif self.x:
            self.x.pop()

def reader(state: GlobalState):
    for _ in range(10000000):
        if len(state.x) % 2 == 0:
            if len(state.x) % 2 != 0:  # wtf how it's possible?
                print(f"length {len(state.x)} was even before context switch")

def changer(state: GlobalState):
    for _ in range(10000000):
        state.do_something_changing()

state = GlobalState()
t1 = Thread(target=reader, args=(state,))
t2 = Thread(target=changer, args=(state,))
t1.start()
t2.start()
t1.join()
t2.join()


И напоследок. Хватит программировать (или пытаться) на тредах. Это сложно и никому не нужно. Давно существуют куда более удачные реализации использования всех ядер процессора (csp например в golang). А если треды используются для IO (а в питоне они в 99.9% используются именно для IO), то давно есть и довольно юзабельный asyncio.
GIL который блокирует доступ разных потоков к одному и тому же участку памяти Нет, GIL гарантирует, что в каждый момент времени работает только один поток. При этом каждые несколько десятков/сотен тактов процессора работающие потоки сменяют друг друга (если их больше одного).

что собственно является одним из якорей в производетельности Спорное и корявое утверждение.

зачем нужен класс threading.Lock Чтобы дать работать одному потоку, а всем остальным запретить. Потому что иначе переключение между потоками произойдет в общем-то в случайный момент времени, а именно тогда, когда захочет интерпретатор. Он, конечно же, не парится по поводу зашитой в код логики и не будет ждать завершения каких-то конкретных вычислений/чтения/записи.