Про нити в Python написано не мало, но, мне захотелось написать свой обзор. Моя цель — рассмотреть нити в чисто прикладном аспекте. С полноценными примерами, без чрезмерных теоретизирований.
Без упоминания про GIL говорить про нити в Python нельзя. Исчерпывающее описание можно найти тут, а перевод части материалов тут. Отличное глубокое погружение GIL на русском языке можно найти тут. Но для начального понимания, что такое GIL достаточно первой фразы из соответствующей wiki:
«Global Interpreter Lock — это механизм предотвращающий исполнение нескольких нитей одновременно.»
То есть, в каждый момент времени исполняется только одна нить. Переключение
происходит, когда работающая нить начинает ожидать чего-либо (ввода/вывода, таймера),
или, когда проходит условные 100 тиков. (Начиная с Python 3.2 появилось переключение
по тайм-ауту. Всеми аспектами переключения нитей можно управлять из sys
. Но это
не столь существенно).
Отдельного упоминания заслуживают вопросы производительности. Обратите внимание, что благодаря GIL производительность приложений в многопоточном дизайне чаще всего уменьшается. Многопоточность в Python оправдана в приложениях, проводящих много времени в состоянии ожидания ввода/вывода.
Если для вас это звучит странно, то очень советую вышеперечисленный источники, а я продолжу.
Переключение нитей происходит только между отдельными байт-код операциями.
Сами же операции неделимы. Посмотреть, как выглядит байт-код можно с помощью
модуля dis
:
import dis
def incr():
global x
x += 1
dis.dis(incr)
Вывод будет выглядеть так:
5 0 LOAD_GLOBAL 0 (x)
3 LOAD_CONST 1 (1)
6 INPLACE_ADD
7 STORE_GLOBAL 0 (x)
10 LOAD_CONST 0 (None)
13 RETURN_VALUE
Для нас тут принципиально то, что Python сперва загружает значение x
, потом
увеличивает его, потом сохраняет. Между любыми из этих операций может произойти
переключение нитей. Тут возможна гонка. (Две нити сперва обе считали x=1
, потом
обе увеличили его, потом обе записали x=2
. Было два сложения, но x
увеличился на 1.)
Многие операции в Python оказываются атомарными. Это, например, доступ по индексу, добавление элементов в массив… Кстати, чтение и запись переменных, даже глобальных, тоже атомарно (именно по-отдельности; отдельно чтение, отдельно запись).
Убедиться в атомарности операций можно так:
import dis
def incr():
global x
x['name'] = 'ok'
dis.dis(incr)
Как видите, STORE_SUBSCR
— атомарная операция.
5 0 LOAD_CONST 1 ('ok')
3 LOAD_GLOBAL 0 (x)
6 LOAD_CONST 2 ('name')
9 STORE_SUBSCR
10 LOAD_CONST 0 (None)
13 RETURN_VALUE
Lock
)В этом примере происходит гонка, описанная выше:
# coding: utf-8
import threading
class Counter:
def __init__(self):
self.x = 0
def incr(self):
self.x += 1 # гонка будет тут
def __str__(self):
return str(self.x)
x = Counter() # глобальная переменная
class T(threading.Thread):
def run(self):
for i in xrange(100000):
x.incr()
# создаём нити
t1 = T()
t2 = T()
# запускаем
t1.start()
t2.start()
# ждём завершения
t1.join()
t2.join()
# результат
print x
Результат скорее всего будет меньше, чем 200000.
Проблема решается блокировкой:
# coding: utf-8
import threading
class Counter:
def __init__(self):
self.lock = threading.Lock()
self.x = 0
def incr(self):
# теперь с блокировками порядок
self.lock.acquire()
self.x += 1
self.lock.release()
def __str__(self):
return str(self.x)
x = Counter() # глобальная переменная
class T(threading.Thread):
def run(self):
for i in xrange(100000):
x.incr()
# создаём нити
t1 = T()
t2 = T()
# запускаем
t1.start()
t2.start()
# ждём завершения
t1.join()
t2.join()
# результат
print x
Теперь в момент выполнения инкремента переключение между нитями будет невозможно. Результат будет строго 200000.
Обратите внимание, что, если бы мы использовали атомарную операцию, то гонки бы не было.
class Counter:
def __init__(self):
self.x = [] # вместо числа используем массив
def incr(self):
self.x.append(None) # добавление в массив атомарно и гонки не будет
def __str__(self):
return str(len(self.x)) # возвращаем длину
Этот код будет насчитывать 200000 без всяких дополнительных блокировок. За нас всё сделает GIL.
RLock
)Обычный Lock
обладает одним недостатком, он ничего не знает
о нити, которая его захватила. Отсюда проистекает два важных
эффекта:
Всего этого можно избежать, используя самодельные флажки, но Python
предоставляет готовый механизм. RLock
нельзя снять из другой нити
и повторный захват блокировки в той же нити не блокирует исполнение.
Semaphore
)Работает аналогично семафорам в других языках. Если Lock
позволяет
только одной нити захватывать ресурс, то Semaphore
позволяет
разделять ресурс заданному числу нитей.
Event
)Если нитям приходится активно обмениваться данными, то часто используется схема поставщик-потребитель. События используются, чтобы поставщик мог сообщить потребителю, что данные готовы.
Важным моментом является то, что нотификацию о событии получают
все потоки, которые выполняют wait
.
Если ни один поток не ждал события, а set
случился, то первый
поток, вызвавший wait
не будет заблокирован. Второй и последующие
потоки — уже будут блокироваться на wait
до следующего set
.
Действием обратным set
является clear
.
Condition
)Это более совершенный вариант Event
.
Компактного и выразительно примера я пока не придумал. Добавлю лишь,
что если вам действительно нужны очереди, то можно посмотреть
в сторону queue.Queue
.
На псевдокоде работа очереди выглядит так:
# поставщик данных:
while True:
# получаем данные (формируем их локально в этой нитке)
condition.acquire()
# добавляем данные к общему ресурсу (к очереди)
condition.notify() # сообщаем одному потоку(!), что данные доступны
condition.release()
# приёмник данных
condition.acquire() # обязательно захватываем блокировку
while True:
# получаем данные из общей очереди
# по какому-то условию можем завершить работу
condition.wait()
# wait отдаёт блокировку и блокирует нашу нить, пока
# не придёт нотификация, что новые данные готовы
condition.release()
Кроме notify
есть ещё notifyAll
, который доставляется всем
ждущим нитям.
GIL никогда не пропадёт. Без него придётся сильно усложнить работу сборщика мусора, а это один из ключевых механизмов Python.
В этих примерах я нигде не обрабатывал исключения, а это очень важно.
При возникновении исключения может не сработать разблокировка. Так
же очень полезной является конструкция with
.