Это объясняется тем, что в базовом питоне потоки не вполне честные - они конкурируют за global interpreter lock, так что код выполняется всё равно поочерёдно. Так что многопоточность в питоне полезна с точки зрения распараллеливания, но не ускорения. ЕМНИП, есть реализации питона, в которых нет этой GIL problem.
Но нужно иметь ввиду, что этот GIL блокирует только элементарные операции (как в твоём примере), тогда как явное использование lock может накрывать целые блоки кода, состоящие из нескольких операций с защищаемым ресурсом.
Вот тебе пример:
import threading
import time
class Data:
def __init__(self):
self.x: int = 0
self.y: int = 0
do_sleep = False
run = True
def reader(d: Data):
while run:
x, y = d.x, d.y
# по идее это условие не должно выполниться никогда
if (x != 0) != (y != 0):
print(f'Got x={x} and y={y}')
else:
print(f'OK {x}', end='\x08\x08\x08\x08')
def writer(d: Data):
while run:
if d.x == 0:
d.x = 1
if do_sleep: pass
d.y = 1
else:
d.x = 0
if do_sleep: pass
d.y = 0
do_sleep = False
instance = Data()
reader_thread = threading.Thread(target=reader, args=(instance,), daemon=True)
writer_thread = threading.Thread(target=writer, args=(instance,), daemon=True)
reader_thread.start()
writer_thread.start()
try:
input()
finally:
run = False
reader_thread.join()
writer_thread.join()
На моей машине, если
if do_sleep: pass
закомментировать, то в консоли высвечивается только OK - иными словами, присваивание двух полей выполняется достаточно быстро, чтобы поток не успел переключиться в промежутке. Как следствие, reader() всегда видит либо x=0 y=0, либо x=1 y=1.
Но если
if do_sleep: pass
оставить, то выполнение тела цикла замедляется достаточно, чтобы поток успел переключиться - и, как следствие, reader() начинает видеть структуру данных Data в неконсистентном состоянии, когда x=0 y=1 или когда x=1 y=0.
И вот чтобы не гадать "успеет - не успеет", нужно в таких случаях защищать связные серии обращений к структуре с помощью мьютекса, ну или в питоновских терминах - Lock.