Глава 2. Вся правда о потоках

Давайте на мгновенье будем откровенными - вы на самом деле не хотите использовать Curio. Все одинаковые вещи вам вероятно следует программировать в потоках. Да, потоках. ТЕХ САМЫХ потоках, Серьёзно. Я не шучу. [1].

-- Дейв,Бизли, Разработка с помощью Curio

Это именно та часть, в которой я рассказываю вам, "Потоки ужасны, и вам никогда не следует их применять", так? К сожалению ситуация не настолько простая. На м требуется взвесить все преимущества и риски при использовании потоков, впрочем, как и при прочем выборе технологии.

Данная книга совсем не предполагает быть посвящённой потокам. Тем не менее главная проблема в том что (а), Asyncio предлагается в качестве альтернативы потокам, поэтому трудно понимать предлагаемую пользу без какого бы то ни было сравнения; и (б), даже когда вы применяете Asyncio, вы, скорее всего, будете всё равно будете иметь дело с потоками и процессами, поэтому вам по- любому необходимо знать что- нибудь о потоках.

[Замечание]Важно

Основным контекстом данного обсуждения является исключительно параллельность в приложениях сетевого программирования. Превентивная многопоточность также используется и в прочих областях, и при этом необходимые компромиссы совершенно другие.

Преимущества потоков

Вот самые главные преимущества применения потоков:

  • Простота чтения кода

    Ваш код исполняется параллельно, но всё ещё представляется очень просто, сверху вниз линейная последовательность команд до того пункта - и именно это ключевой момент - вы можете притвориться внутри данного тела ваших функций, что никакой одновременности и не происходит.

  • Одновременность с совместно используемой памятью

    Ваш код может эксплуатировать множество ЦПУ и в то же самое время всё ещё иметь совместную память потоков. Это важно во многих рабочих потоках, в которых, например, было бы слишком затратным перемещать большие объёмы данных между отдельными пространствами памяти различных процессов.

  • Знание дела и существующий код

    Имеется большая отрасль знаний и варианты практического опыта по написанию приложений с потоками. Также имеется гигантское количество существующих "блокирующих" кодов, которые зависят от многопоточности для одновременных операций.

Теперь, что касается Python, этот момент достаточно спорный, поскольку интерпретатор Python применяет некую глобальную блокировку, называемую Global Interpreter Lock, для защиты своего внутреннего состояния интерпретатора самого по себе - защита от возможного катастрофического эффекта конкуренции между множеством потоков. Неким побочным эффектом такой блокировки является то, что заканчивается пришпиливанием всех потоков из вашей программы к некому единственному ЦПУ. Как вы можете себе представить, это отвергает любые преимущества одновременной производительности (только если вы не используете такие инструменты как Cython или Numba для манёвра вокруг данного ограничения).

Тем не менее, самый первый пункт в отношении простоты является существенным: потоки в Python кажутся чрезвычайно простыми и, если вы не сгорите в огне невероятно жёстких ошибок конкуренции, работа с потоками предлагает очень привлекательную модель параллелизма. И даже если вы были сожжены в прошлом, работа с потоками остаётся неотразимым вариантом, так как скорее всего, вы прошли обучение (это трудный путь) как одновременно сохранять свой код простым и безопасным.

У меня нет достаточного пространства входить в детали безопасного программирования здесь, однако говоря в целом, наилучшей практикой применения потоков является применение класса ThreadPoolExecutor() из имеющегося модуля concurrent.future, передавая все необходимые данные через его метод submit().

Вот некий базовый пример:

 

Пример 2-1. Наилучшая практика использования потоков


from concurrent.future import ThreadPoolExecutor as Executor

def worker(data):
    <process the data>

with Executor(max_workers=10) as exe:
    future = exe.submit(worker, data)
 	   

Этот ThreadPoolExecutor предлагает чрезвычайно простой интерфейс для запуска функций в потоках - и самая лучшая часть состоит в том, если необходимо, что вы можете конвертировать имеющийся пул потоков в некий пул сопроцессов просто применяя вместо этого ProcessPoolExecutor. Он имеет тот же самый API, что и ThreadPoolExecutor, что означает что ваш код будет подвергнут небольшому воздействию данным изменением. Данный исполнитель API также применяется в asyncio и будет разъяснён в одном из последующих разделов.

В целом вы бы предпочли чтобы ваши задачи были чем- то с коротким временем жизни, с тем, чтобы когда вашей программе понадобится останов, вы могли бы просто вызвать Executor.shutdown(wait=True) и подождать секунду или две чтобы позволить данному исполнителю завершиться.

Что более важно: постарайтесь предотвратить в вашем коде потока (в приведённом выше примере, в функции worker()), доступ или запись в каких бы то ни было глобальных переменных!

Некоторые великолепные руководящие правила для более безопасного кодирования потоков были представлены Рэймондом Хеттингером на PyCon Russia 2016 и снова на PyBay 2017 и я настоятельно подгоняю вас добавить эти видео в ваш перечень просмотров.

Изнанка потоков

... нетривиальные многопоточные программы непонятны людям. Это верно, что имеющуюся модель программирования можно улучшить путём применения шаблонов проектирования, лучшей детализации атомарности (например, транзакций), улучшенных языков программирования и методов формализации. Однако, эти техники просто отсекаются при имеющемся ненужном огромном недетерминизме существующей модели потоков. Данная модель остаётся в сущности не поддающейся решению. [2].

-- Эдвард А Ли, Основная проблема потоков - Technical Report No. UCB/EECS­2006­1 Electrical Engineering and Computer Sciences ­ University of California at Berkeley

Это не является новым, и мы уже упоминали об этом в некоторых иных местах, однако для завершённости давайте соберём им в кучку, тем не менее:

  • Ошибки при написании потоков и конкуренция в программах с потоками являются самыми сложными видами ошибок для исправления. По опыту, можно будет разработать новое программное обеспечение, которое менее подвержено данным проблемам, но в нетривиальном, разработанном естественным образом программном обеспечении почти невозможно исправить их, даже экспертам! На самом деле!

  • Потоки являются ресурсоёмкими и требуют создания дополнительных ресурсов операционной системы, таких как предварительное выделение, пространство стека для каждого потока, которое потребляет виртуальную память процесса авансом. Это большая проблема для 32- разрядных операционных систем, потому что адресное пространство на процесс ограничено 3 ГБ [В теории адресное пространство для некоторого 32- разрядного процесса составляет 4 ГБ, однако имеющиеся операционные системы резервируют его часть. Зачастую только 3 ГБ остаётся адресуемым только 3 ГБ виртуальной памяти, а некоторые операционные системы могут уменьшать его до 2 ГБ. Будьте любезны рассматривать приводимые численные значения в этом разделе как обобщённые, а не абсолютные. Имеется слишком много специфичных для платформ (и исторически чувствительных) подробностей чтобы углубляться в них здесь.]

    В наши дни, при широкой доступности 64- разрядных операционных систем, виртуальная память не так ценна, как ранее (адресуемое пространство для виртуальной памяти обычно составляет 48 бит, то есть 256 TiB), и в современных операционных системах рабочих мест физическая память, требуемая для пространства стека каждого потока даже не выделяется операционной системой пока она не требуется, включая пространство стека для каждого потока. Например, в современном 64­битном Fedora 27 Linux с 8 ГБ памяти, создание 10 000 ничего не делающих при этом потоков короткий фрагмент кода...

    
    # threadmem.py
    import os
    from time import sleep
    from threading import Thread
    threads = [
        Thread(target=lambda: sleep(60)) for i in range(10000)
    ]
    [t.start() for t in threads]
    print(f'PID = {os.getpid()}')
    [t.join() for t in threads]
     	   

    ... приводящий к следующей информации в top:

    
    MiB Mem : 7858.199 total, 1063.844 free, 4900.477 used
    MiB Swap: 7935.996 total, 4780.934 free, 3155.062 used
    
      PID USER      PR  NI    VIRT    RES    SHR COMMAND
    15166 caleb     20   0 80.291g 131.1m   4.8m python3
     	   

    Предварительно выделяемая виртуальная память ошеломляет ~80 ГБ (из- за 8 МБ пространства стека на поток!), однако постоянная память составляет всего ~130 МБ. В некотором 32- разрядном Linux мне было бы невозможно создать настолько много потоков из- за предела на адресное пространство пользователя в 3 ГБ, независимо от реального потребления физической памяти. Для обхода этой проблемы (при 32- битах) иногда требуется снизить предварительно настроенный размер стека, что вы всё ещё можете делать сегодня в Python, с помощью threading.stack_size([size]). Очевидно, снижение размера стека оказывает воздействие на безопасность времени исполнения в отношении той степени, до которой может выполняться вложение вызовов, включая рекурсивные. Однопотоковые сопрограммы не имеют никаких из этих проблем и являются превосходящей альтернативой для одновременного ввода/ вывода.

  • При очень высокой степени распараллеливания (скажем >5 000 потоков), также может иметься воздействие на пропускную способность из- за стоимости переключения контекста [4, 5], допустим, вы можете отыскать способ как настроить вашу операционную систему даже чтобы допустить ещё больше потоков для вас! Сейчас это стало достаточно скучным, когда в последних версиях macOS, к примеру, для проверки моих ничего- не- делающих примеров, приведённых выше, я отказался от попыток поднимать значение параметра вовсе.

  • Потоки являются негибкими: ваша операционная система будет непрерывно разделять время ЦПУ со всеми потоками вне зависимости от того готов этот поток к работе или нет. Например, некий поток может ожидать данных в некотором сокете, однако планировщик ОС может всё- таки переключатся на него тысячи раз прежде чем необходимо сделать реальную работу. (В нашем асинхронном мире применяется системный вызов select() для проверки имеется ли ожидающая в сокете сопрограмма, на которую нужно переключиться или нет, причём эта сопрограмма даже не успеет прийти в себя, избегая стоимость переключения вовсе {Прим. пер.: ещё более впечатляет epoll}).

Ни одно из этих сведений не является новым, а все проблемы с потоками в виде модели программирования не являются даже особенными для платформы. Например, именно это говорит документация по руководству программированию MSDN Windows относительно построения потоков:

Самыми стержневым механизмом распараллеливания в существующем API Windows являются именно потоки. Обычно для создания потоков вы применяете функцию CreateThread. Хотя потоки относительно просты для создания и применения, сама операционная система уделяет значительное время и прочие ресурсы для управления ими. Кроме того, хотя каждому потоку гарантируется получение точно такого же времени исполнения что и для для остальных потоков с с тем же самым уровнем приоритета, а связанные с ними накладные расходы требуют того, чтобы создавала достаточно большие задачи. Для задач меньшего размера или с большей степенью детализации, те накладные расходы, которые связаны с распараллеливанием, могут перевешивать те преимущества, которые получают одновременно исполняемые задачи. [6].

-- Руководство программирования MSDN, Сопоставление Распараллеливание времени исполнения с прочими моделями одновременного исполнения

Но я слышу ваши протесты - это Windows, так? Несомненно, система Unix не имеет этих проблем? Вот приводим аналогичные рекомендации от Разработчиков библиотеки Mac:

Потоки имеют действительную стоимость для ваших программ (и всей системы) в терминах использования памяти и производительности. Каждый поток требует соответствующего выделения памяти как в пространстве самого ядра, так и в пространстве памяти вашей программы. Имеющиеся структуры ядра требуют управления вашим потоком и координации их планирования запоминаются в самом ядре при помощи зашитой памяти. Ваши пространство стека потока и данных каждого потока сохраняются в пространстве памяти вашей программы. Большая часть этих структур создаются и инициализируются при создании самом первом вашего потока - некого процесса, который может быть относительно затратным, так как он требует взаимодействия с сами ядром [7].

-- Библиотека разработчиков Mac, Руководство по программированию потоков

В своём Руководстве по параллельному программированию (выделено мною) они идут ещё дальше:

В прошлом введение распараллеливания в некое приложение требует определённого создания одного или более дополнительных потоков. К сожалению, написание кода с потоками является сложным. Потоки являются неким инструментом нижнего уровня, который должен управляться вручную. Принимая во внимание, что оптимальное число потоков для некоторого приложения может изменяться динамически на основе текущей загруженности системы и лежащего в её основе оборудования, реализация правильного решения с потоками становится чрезвычайно сложным, если даже невозможным для достижения. Кроме того, имеющиеся механизмы синхронизации обычно используются при добавлении сложности и рисков разработки потоков без каких бы то ни было гарантий улучшения производительности.[8].

-- Библиотека разработчиков Mac, Руководство по программированию потоков

Вот темы, которые постоянно повторяются:

  • потокии делают сложным обсуждение кода.

  • потоки являются неэффективной моделью для крупномасштабного распараллеливания (тысяч одновременных задач).

Следующим мы рассмотрим некий пример вовлечения потоков в котором я попытаюсь высветить самый первый и самый важный момент.

Пример: Роботы и Столовые приборы

Второе, и даже ещё более важное, мы не верили (и продолжаем сомневаться в ней) в стандартную модель со множеством потоков, которая является распараллеливанием с приоритетами при помощи совместно используемой памяти: мы всё ещё полагаем, что никто не может писать правильные программы на некотором языке программирования, в котором "a - a + 1" не детерминировано.[9].

-- Роберту Иерузалимски, Луиш Энрике ди Фигейреду, Валдемар Селиш, Эволюция Lua

В самом начале этой книге мы прослушали историю о ресторане, в котором роботы- гуманоиды - Серийные боты - выполняют всю работу. В соответствии с этой аналогией всякий исполнитель (worker) является каким- то потоком. В данном примере мы собираемся рассмотреть почему потоки рассматриваются как не безопасные.

 

Пример 2-2. Программирование Серийного бота для обслуживания стола


import threading
from queue import Queue

class ThreadBot(threading.Thread):(1)
  def __init__(self):
    super().__init__(target=self.manage_table)(2)
    self.cutlery = Cutlery(knives=0, forks=0)(3)
    self.tasks = Queue()(4)

  def manage_table(self):
    while True:(5)
    task = self.tasks.get()
    if task == 'prepare table':
      kitchen.give(to=self.cutlery, knives=4, forks=4)(6)
    elif task == 'clear table':
      self.cutlery.give(to=kitchen, knives=4, forks=4)
    elif task == 'shutdown':
      return
 	   
  • (1) ThreadBot является подклассом потока.

  • (2) Целевой функцией данного потока является метод manage_table(), определяемый ниже.

  • (3) Этот бот собирается быть официантом и будет отвечать за некие столовые приборы. Здесь все боты отслеживают те столовые приборы, которые они взяли со своей кухни. (Соответствующий класс столовых приборов, Cutlery, будет определён позднее.

  • (4) Конкретному боту также назначаются задачи. Они будут добавляться в данную очередь задач, а сам бот будет исполнять их далее в своём основном цикле обработки.

  • (5) Самой основной процедурой данного бота является данный бесконечный цикл. Если вам потребуется остановить некоего бота, вам придётся выдать ему задание "shutdown".

  • (6) Имеются только три задачи, определённые для данного бота. Самой первой, "prepare table", является та, которую нужно выполнить, чтобы получить новый стол готовым к обслуживанию. Для нашей проверки единственным требованием является получить набор столовых приборов с кухни и поместить их на данный стол. "clear table" служит для того чтобы указать необходимость очистить некий стол: данный бот должен вернуть использованные столовые приборы обратно на кухню. "shutdown" всего лишь останавливает данного бота.

А теперь определим объект Столового прибора:

 

Пример 2-3. Определение Столовых приборов


from attr import attrs, attrib

@attrs(1)
class Cutlery:
    knives = attrib(default=0)(2)
    forks = attrib(default=0)
	
    def give(self, to: 'Cutlery', knives=0, forks=0):(3)
    self.change(­knives, ­forks)
    to.change(knives, forks)

    def change(self, knives, forks):(4)
            self.knives += knives
            self.forks += forks

kitchen = Cutlery(knives=100, forks=100)(5)
bots = [ThreadBot() for i in range(10)](6)

import sys
for bot in bots:
    for i in range(int(sys.argv[1])):(7)
        bot.tasks.put('prepare table')
        bot.tasks.put('clear table')
    bot.tasks.put('shutdown')(8)

print('Kitchen inventory before service:', kitchen)
for bot in bots:
    bot.start()

for bot in bots:
    bot.join()
print('Kitchen inventory after service:', kitchen)
 	   
  • (1) attrs, которая является библиотекой Python с открытым исходным кодом, которая ничего не предпринимает по отношению к потокам или asyncio, и является на самом деле восхитительной библиотекой для того чтобы сделать создания класса простым. В нашем случае соответствующий декоратор @attrs гарантирует что данный класс Cutlery получит весь обычный стереотипный код (подобный __init__()) автоматически установленным.

  • (2) Функция attrib предоставляет некий простой способ создания атрибутов, в том числе значений по умолчанию, которые вы можете обычно обрабатывать как аргументы с ключевыми словами в своём методе __init__().

  • (3) Этот метод применяется для передачи ножей и вилок из одного объекта Cutlery в другой. Обычно он применяется ботами для получения столовых приборов со своеё кухни для новых столов, а также для возврата этих столовых приборов обратно на кухню после уборки данного стола.

  • (4) Это очень простая функция утилиты для для замены имеющихся инвентарных данных в данном экземпляре объекта.

  • (5) Мы определили kitchen в виде определённого идентификатора для конкретной кухонной инвентаризации столовых приборов. Обычно каждый из ботов будет получать столовые приборы в этом месте. Также необходимо чтобы они возвращали столовые приборы в это хранилище после уборки стола.

  • (6) Этот сценарий исполняется при проверке. Для нашей проверки мы будем применять 10 Серийных ботов (threadbots).

  • (7) Мы получаем общее число столов в виде параметра командной строки, а затем задаём для кадого бота такое число задач для подготовки и уборки столов в своём ресторане.

  • (8) Задача "shutdown" заставит бота остановиться (так что bot.join() будет выполнять возврат немного дальше). Оставшийся сценарий выводит диагностические сообщения и запускает имеющихся ботов.

Ваша стратегия проверки созданного кода в основном вовлекает запуск некой группы Серийных ботов для последовательности обслуживания столов. Каждый серийный юот должен:

  • подготовить некий "стол на четверых", что означает получение четырёх наборов ножей и вилок с кухни;

  • убрать стол, что подразумевает возврат этого набора четырёх ножей и вилок с некоторого стола обратно на кухню.

Если вы запускаете пачку Серийных ботов на некое множество столов определённое число раз, вы ожидаете, что после того как вся работа будет выполнена, все ножи и вилки должны оказаться вновь на кухне и пересчитаны.

Поступая с умом, вы решаете проверить это, причём при подготовке и уборке 100 столов для каждого из Серийных ботов, и при этом все они работают одновременно, поскольку вы желаете убедиться что они могут работать совместно и всё пройдет хорошо. Вот вывод данной проверки:


$ python cutlery_test.py 100
Kitchen inventory before service: Cutlery(knives=100, forks=100)
Kitchen inventory after service: Cutlery(knives=100, forks=100)
 	   

Все ваши ножи и вилки вернулись обратно на кухню! Поэтому вы поздравляете себя с хорошо написанным кодом и успешным развёртыванием ботов. К сожалению, на практике, вы то и дело обнаруживаете что вы не завершаете полным пересчётом столовых приборов при каждом закрытии своего ресторана. Вы замечаете, что данная проблема ухудшается когда вы добавляете дополнительных ботов и/ или места становятся более загруженными. Разочаровавшись, вы проводите свою проверку вновь, ничего не изменяя за исключением установления размера данной проверки (10 000 столов!):


$ python cutlery_test.py 10000
Kitchen inventory before service: Cutlery(knives=100, forks=100)
Kitchen inventory after service: Cutlery(knives=96, forks=108)
 	   

Оп- ля- ля! Теперь вы видите, что проблема имеется на самом деле. При 10 000 обслуженных столах вы заканчиваете с неверным числом ножей и вилок, остающихся на кухне. Для воспроизведения вы проверяете эту ошибку на повторяемость:


$ python cutlery_test.py 10000
Kitchen inventory before service: Cutlery(knives=100, forks=100)
Kitchen inventory after service: Cutlery(knives=112, forks=96)
 	   

Всё ещё остаются ошибки, но с с иными численными значениями в сравнении с предыдущим исполнением. Это просто смешно! Вы ведь знаете, что эти боты исключительно хорошо сконструированы и они не ошибаются. Что же могло пойти не так?

Обсуждение

Давайте подведём итог:

  • Ваш код ThreadBot очень простой и его легко читать. Вся логика исключительная!

  • Вы даже провели пробный тест (со 100 столами), он повторяемо проходил.

  • Вы провели более длительную проверку (со 10 000 столами) и она повторяемо проваливается.

  • Более продолжительная проверка оказывает различными, не повторяющимися способами.

Именно это является несколькими типичными признаками ошибки состязательности. Умудрённый читатель уже видел такие примеры, поэтому давайте сейчас это исследуем. Всё сводится к данному методу внутри нашего класса Cutlery:


def change(self, knives, forks):
    self.knives += knives
    self.forks += forks
 	   

Данное внутреннее суммирование, +=, реализуется внутренним образом (в рамках собственно кода C для самого интерпретатора Python) как несколько отдельных этапов:

  1. Считывание текущего значения, self.knives, в некое временное местоположение.

  2. Добавление некоторого нового значения, knives к имеющемуся значений в этом временном местоположении.

  3. Копирование нового итога из временного местоположения обратно в его первоначальное место.

Основная проблема при многозадачности с приоритетами состоит в том, что любой занятой поток, находящийся на шаг впереди, может быть прерван в любой момент времени, причём различные потоки могут иметь шанс выполняться на одном и том же этапе.

В таком случае, Серийный бот A может делать этап 1, затем планировщик ОС приостанавливает A и переключается на Серийного бота B, а B тоже считывает текущее значение self.knives, затем исполнение передаётся обратно A и его приращения записываются обратно в новый итог - но затем B продолжает с того места где он был приостановлен (после шага 1), и после этого выполняет приращение и записывает обратно свой новый итог, тем самым удаляя те изменения, которые сделаны A!

[Предостережение]Предостережение

Если описание данной состязательности звучит сложно, имейте, пожалуйста, в виду, что это пример (условия конкуренции) просто один из самых простых их всех возможных случаев. Мы способны проверить весь свой код, и мы даже можем проводить проверку, которая воспроизводит нашу проблему по запросу. В нашем реальном мире, в крупных проектах, попробуйте представить себе насколько большие трудности могут появляться.

Данную проблему можно исправить помещая блокировку вокруг необходимых изменений совместно используемого состояния (допустим, мы добавили threading.Lock в свой класс Cutlery):


def change(self, knives, forks):
  with self.lock:
    self.knives += knives
    self.forks += forks
 	   

Однако это требует знания всех тех мест, вкоторых состояние будет совместно использоваться множеством потоков. Данный подход жизнеспособен когда вы контролируете весь исходный код, но он становится очень сложным когда применяется много библиотек сторонних производителей - которые скорее всего на Python, благодаря прекрасной экосистеме открытого исходного кода.

Заметим, что невозможно отследить данное условие состязательности рассматривая исключительно имеющийся исходный код. Это происходит потому, что такой исходный код не даёт никаких советов о том, где происходит переключение между потоками. В любом случае он будет бесполезен, так как ваша ОС может переключаться между потоками практически в любом месте.

Другим, намного лучшим, решением - и это пункт асинхронного программирования - заключается в том, что мы изменяем свой код чтобы мы применяли только одного Серийного бота и настраивали его на перемещение между всеми имеющимися столами по мере необходимости. Для нашего примера это означает, что значения ножей и вилок будут изменяться только одним потоком.

И что ещё лучше: в наших асинхронных программах мы удем способны видеть в точности где контекст будет переключаться между множеством конкурирующих сопрограмм, потому что имеющееся ключевое слово await, которое указывает такие места в явном виде. Я решил не показывать некую асинхронную версию здесь, потому как наша следующая глава собирается подробно пояснять как использовать asyncio. Однако, если вы испытываете неутолимое любопытство, имеется некий снабжённый комментариями пример в наших Дополнениях; который, скорее всего, имеет смысл рассматривать только после прочтения нашей следующей главы!