☰ Оглавление

Нити в Python

Про нити в Python написано не мало, но, мне захотелось написать свой обзор. Моя цель — рассмотреть нити в чисто прикладном аспекте. С полноценными примерами, без чрезмерных теоретизирований.

GIL

Без упоминания про 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.