как писать многопоточные приложения
Учимся писать многопоточные и многопроцессные приложения на Python
Эта статья не для матёрых укротителей Python’а, для которых распутать этот клубок змей — детская забава, а скорее поверхностный обзор многопоточных возможностей для недавно подсевших на питон.
К сожалению по теме многопоточности в Python не так уж много материала на русском языке, а питонеры, которые ничего не слышали, например, про GIL, мне стали попадаться с завидной регулярностью. В этой статье я постараюсь описать самые основные возможности многопоточного питона, расскажу что же такое GIL и как с ним (или без него) жить и многое другое.
Python — очаровательный язык программирования. В нем прекрасно сочетается множество парадигм программирования. Большинство задач, с которыми может встретиться программист, решаются здесь легко, элегантно и лаконично. Но для всех этих задач зачастую достаточно однопоточного решения, а однопоточные программы обычно предсказуемы и легко поддаются отладке. Чего не скажешь о многопоточных и многопроцессных программах.
Многопоточные приложения
В Python есть модуль threading, и в нем есть все, что нужно для многопоточного программирования: тут есть и различного вида локи, и семафор, и механизм событий. Один словом — все, что нужно для подавляющего большинства многопоточных программ. Причем пользоваться всем этим инструментарием достаточно просто. Рассмотрим пример программы, которая запускает 2 потока. Один поток пишет десять “0”, другой — десять “1”, причем строго по-очереди.
Никакой магии и voodoo-кода. Код четкий и последовательный. Причем, как можно заметить, мы создали поток из функции. Для небольших задач это очень удобно. Этот код еще и достаточно гибкий. Допустим у нас появился 3-й процесс, который пишет “2”, тогда код будет выглядеть так:
Мы добавили новое событие, новый поток и слегка изменили параметры, с которыми
стартуют потоки (можно конечно написать и более общее решение с использованием, например, MapReduce, но это уже выходит за рамки этой статьи).
Как видим по-прежнему никакой магии. Все просто и понятно. Поехали дальше.
Global Interpreter Lock
Существуют две самые распространенные причины использовать потоки: во-первых, для увеличения эффективности использования многоядерной архитектуры cоврменных процессоров, а значит, и производительности программы;
во-вторых, если нам нужно разделить логику работы программы на параллельные полностью или частично асинхронные секции (например, иметь возможность пинговать несколько серверов одновременно).
В первом случае мы сталкиваемся с таким ограничением Python (а точнее основной его реализации CPython), как Global Interpreter Lock (или сокращенно GIL). Концепция GIL заключается в том, что в каждый момент времени только один поток может исполняться процессором. Это сделано для того, чтобы между потоками не было борьбы за отдельные переменные. Исполняемый поток получает доступ по всему окружению. Такая особенность реализации потоков в Python значительно упрощает работу с потоками и дает определенную потокобезопасность (thread safety).
Но тут есть тонкий момент: может показаться, что многопоточное приложение будет работать ровно столько же времени, сколько и однопоточное, делающее то же самое, или за сумму времени исполнения каждого потока на CPU. Но тут нас поджидает один неприятный эффект. Рассмотрим программу:
Эта программа просто пишет в файл миллион строк “1” и делает это за
0.35 секунды на моем компьютере.
Рассмотрим другую программу:
Эта программа создает 2 потока. В каждом потоке она пишет в отдельный файлик по пол миллиона строк “1”. По-сути объем работы такой же, как и у предыдущей программы. А вот со временем работы тут получается интересный эффект. Программа может работать от 0.7 секунды до аж 7 секунд. Почему же так происходит?
Это происходит из-за того, что когда поток не нуждается в ресурсе CPU — он освобождает GIL, а в этот момент его может попытаться получить и он сам, и другой поток, и еще и главный поток. При этом операционная система, зная, что ядер много, может усугубить все попыткой распределить потоки между ядрами.
UPD: на данный момент в Python 3.2 существует улучшенная реализация GIL, в которой эта проблема частично решается, в частности, за счет того, что каждый поток после потери управления ждет небольшой промежуток времени до того, как сможет опять захватить GIL (на эту тему есть хорошая презентация на английском)
«Выходит на Python нельзя писать эффективные многопоточные программы?», — спросите вы. Нет, конечно, выход есть и даже несколько.
Многопроцессные приложения
Для того, чтобы в некотором смысле решить проблему, описанную в предыдущем параграфе, в Python есть модуль subprocess. Мы можем написать программу, которую хотим исполнять в параллельном потоке (на самом деле уже процессе). И запускать ее в одном или нескольких потоках в другой программе. Такой способ действительно ускорил бы работу нашей программы, потому, что потоки, созданные в запускающей программе GIL не забирают, а только ждут завершения запущенного процесса. Однако, в этом способе есть масса проблем. Основная проблема заключается в том, что передавать данные между процессами становится трудно. Пришлось бы как-то сериализовать объекты, налаживать связь через PIPE или друге инструменты, а ведь все это несет неизбежно накладные расходы и код становится сложным для понимания.
Здесь нам может помочь другой подход. В Python есть модуль multiprocessing. По функциональности этот модуль напоминает threading. Например, процессы можно создавать точно так же из обычных функций. Методы работы с процессами почти все те же самые, что и для потоков из модуля threading. А вот для синхронизации процессов и обмена данными принято использовать другие инструменты. Речь идет об очередях (Queue) и каналах (Pipe). Впрочем, аналоги локов, событий и семафоров, которые были в threading, здесь тоже есть.
Кроме того в модуле multiprocessing есть механизм работы с общей памятью. Для этого в модуле есть классы переменной (Value) и массива (Array), которые можно “обобщать” (share) между процессами. Для удобства работы с общими переменными можно использовать классы-менеджеры (Manager). Они более гибкие и удобные в обращении, однако более медленные. Нельзя не отметить приятную возможность делать общими типы из модуля ctypes с помощью модуля multiprocessing.sharedctypes.
Еще в модуле multiprocessing есть механизм создания пулов процессов. Этот механизм очень удобно использовать для реализации шаблона Master-Worker или для реализации параллельного Map (который в некотором смысле является частным случаем Master-Worker).
Из основных проблем работы с модулем multiprocessing стоит отметить относительную платформозависимость этого модуля. Поскольку в разных ОС работа с процессами организована по-разному, то на код накладываются некоторые ограничения. Например, в ОС Windows нет механизма fork, поэтому точку разделения процессов надо оборачивать в:
Впрочем, эта конструкция и так является хорошим тоном.
Для написания параллельных приложений на Python существуют и другие библиотеки и подходы. Например, можно использовать Hadoop+Python или различные реализации MPI на Python (pyMPI, mpi4py). Можно даже использовать обертки существующих библиотек на С++ или Fortran. Здесь можно было упомянуть про такие фреймфорки/библиотеки, как Pyro, Twisted, Tornado и многие другие. Но это все уже выходит за пределы этой статьи.
Если мой стиль вам понравился, то в следующей статье постараюсь рассказать, как писать простые интерпретаторы на PLY и для чего их можно применять.
Многопоточные приложения в C# для чайников
Что такое многопоточные приложения? Грубо говоря, это приложения с несколькими «рабочими», которые одновременно выполняют разные или однотипные задачи. Зачем это нужно? Ресурсы компьютера используются не всегда эффективно. Например, ваша программа скачивает страницу из интернета, потом анализирует ее, затем — качает следующую. Во время анализа простаивает интернет соединение, а во время закачки — скучает процессор. Это можно исправить. Уже во время анализа текущей страницы параллельно качать следующую.
Я попытаюсь объяснить, как оптимизировать свои программы, используя многопоточность, и приведу пару примеров с кодом. Прошу под кат.
Для начала, давайте разберемся, когда можно распараллелить программу. Например, у вас есть один поток данных. Его нужно обрабатывать в строго определенном порядке и без результатов предыдущей обработки, следующую операцию выполнять нельзя. В такой программе можно создать дополнительные потоки, но будут ли они нужны? Мой преподаватель по компьютерным системам приводил следующий пример.
Допустим, у нас есть 2 рабочих, которые хорошо копают ямы. Предположим, что один выкопает яму глубиной 2 метра, за 1 час. Тогда два рабочих, выкопают эту яму за полчаса. Это похоже на правду. Давайте возьмем 3600 таких рабочих. Теоретически, они выкопают яму глубиной 2 метра за 1 секунду. Но на практике они будут друг другу мешать, топтаться на одном месте и нервничать.
Я надеюсь, вы поняли, что многопоточность нужна не всегда, и что утверждение «чем больше потоков — тем лучше» ошибочно. Потоки не должны мешать друг другу и должны использовать эффективно ресурсы системы, в которой они работают.
Далее немного практики. Что бы начать работать с потоками, необходимо подключить пространство имен System.Threading, добавив в начало файла с кодом следующую директиву:
Любой поток в C# это функция. Функции не могут быть сами по себе, они обязательно являются методами класса. Поэтому, что бы создать отдельный поток, нам понадобится класс с необходимым методом. Самый простой вариант метода возвращает void и не принимает аргументов:
Пример запуска такого потока:
После вызова метода Start() у объекта потока, управление вернется сразу, но в этот момент уже начнет работать ваш новый поток. Новый поток выполнит тело функции MyThreadFunction и завершится. Мой друг спросил меня, а почему функция не возвращает значение? А потому, что его некуда вернуть. После вызова Start(), управление передается дальше, при этом созданный поток может работать еще длительное время. Что бы обмениваться данными между потоками, можно пользоваться переменными класса. Об этом позже.
Кроме того, существует еще один вариант метода, из которого можно сделать поток. Выглядит он вот так:
Его назначение — применение в тех случаях, когда нужно при создании потоков, передавать им какие-то данные. Любой класс в C# наследуется от Object, поэтому можно передавать внутрь потока любой объект. Подробнее позже.
Давайте напишем простой пример, что бы проиллюстрировать работу потоков. Реализуем следующее. Наша программа запустит дополнительный поток, после чего выведет три раза на экран «Это главный поток программы!», в это время созданный поток выведет три раза на экран «Это дочерний поток программы!». Вот, что получилось:
Ничего сложно. Хочу обратить ваше внимание, что у метода потока присутствует модификатор static, это нужно для того, что бы к ней можно было напрямую обратиться из главного потока приложения. Теперь запустите программу несколько раз и сравните результаты вывода в консоль. Обычно, порядок вывода сообщений в консоль при каждом запуске разный. Планировщик задач операционной системы по-разному распределяет процессорное время, поэтому порядок вывода разный. Еще одно полезное свойство потоков заключается в том, что вам не нужно беспокоиться о правильности распараллеливания их на процессоре, планировщик задач все сделает сам.
Идем дальше. Что бы создать несколько потоков, необязательно использовать несколько функций, если потоки одинаковые. Вот пример:
Как видите, для создания 10 потоков нам понадобилась всего 1 функция.
Каждый поток имеет свой стек, поэтому локальные переменные метода для каждого потока свои. Что бы это продемонстрировать, мы создадим поток для метода класса, а потом вызовем этот же метод из главного потока. Вот код:
После завершения выполнения потоков, в консоле будет выведено 10 чисел.
Если же мы хотим, что бы каждый поток вел себя по-разному, то есть два решения. Первое — создание дополнительной функции для потока. Например, вот так:
Второе решение более запутанное. Смысл в том, что бы используя один метод выполнять разные действия в разных потоках. Для этого, внутри метода потока нужно определить «кто я?». Как вариант, используем метод потока, который может принимать значения. В поток будем передавать булевый флаг. Если он равен истине — значит это первый поток, если ложь — значит второй. Метод потока сам будет определять «кто я?» и в зависимости от этого, выполнять разные действия. Вот код:
Как работает данная программа? Каждый поток использует свой экземпляр метода. Если потоку выдали флаг true, значит он выполняет один код, если false — другой. Этот пример так же наглядно демонстрирует как работать с методами потоков, который принимают параметры.
Давайте разберем, как обмениваться данными между разными потоками. Перед тем, как я приведу пример с кодом, расскажу немного теории. Во всех многопоточных приложениях существует синхронизация данных. Когда два или больше потоков, пытаются взаимодействовать с какими либо данными одновременно — может возникнуть ошибка. Для этого в C# существует несколько способов синхронизации, но мы рассмотрим самый простой с помощью оператора lock.
Теперь давайте пример. Создадим отдельный класс, который будет создавать дополнительные потоки. Каждый поток будет работать с одной переменной (свойство класса). Для обеспечения сохранности данных мы будем использовать оператор lock. Его синтаксис очень простой:
Как он работает? Когда один из потоков, доходит до оператора lock, он проверяет, не заблокирован ли object1. Если нет — выполняет указанные в скобках операторы. Если заблокирован — ждет, когда object1 будет разблокирован. Таким образом, оператор lock предотвращает одновременное обращение нескольких потоков к одним и тем же данным.
А вот и пример программы:
Я использовал отдельную переменную, для оператора lock, поскольку он не может заблокировать доступ к int переменной. А вообще, мне как то на хабре посоветовали всегда использовать «локеры» для блокировки других данных.
Теперь о программе. Каждый поток сначала выводит значение свойства value, а потом увеличивает его на единицу. Зачем же тут нужен оператор lock? Запустите программу. Она выведет по порядку «0 1 2 3 4». Потоки по очереди выводят значение value, и все хорошо. Предположим, что оператора lock нету. Тогда может получиться следующее:
… и так далее. В результате мы получим, что-то вроде «0 0 1 3 4». Это связано с тем, что процессор и планировщик задач не знают в каком порядке выполняться операции, поэтому они делают это оптимально с точки зрения выполнения программы, при этом логика нарушается.
Думаю, на сегодня хватит, и так много получилось. Основные примеры можно скачать здесь. Пишите комментарии, отзывы и пожелания. Задавайте свои ответы. Удачной компиляции.
Учимся писать многопоточные и многопроцессные приложения на Python
Эта статья не для матёрых укротителей Python’а, для которых распутать этот клубок змей — детская забава, а скорее поверхностный обзор многопоточных возможностей для недавно подсевших на питон.
К сожалению по теме многопоточности в Python не так уж много материала на русском языке, а питонеры, которые ничего не слышали, например, про GIL, мне стали попадаться с завидной регулярностью. В этой статье я постараюсь описать самые основные возможности многопоточного питона, расскажу что же такое GIL и как с ним (или без него) жить и многое другое.
Python — очаровательный язык программирования. В нем прекрасно сочетается множество парадигм программирования. Большинство задач, с которыми может встретиться программист, решаются здесь легко, элегантно и лаконично. Но для всех этих задач зачастую достаточно однопоточного решения, а однопоточные программы обычно предсказуемы и легко поддаются отладке. Чего не скажешь о многопоточных и многопроцессных программах.
Многопоточные приложения
В Python есть модуль threading, и в нем есть все, что нужно для многопоточного программирования: тут есть и различного вида локи, и семафор, и механизм событий. Один словом — все, что нужно для подавляющего большинства многопоточных программ. Причем пользоваться всем этим инструментарием достаточно просто. Рассмотрим пример программы, которая запускает 2 потока. Один поток пишет десять “0”, другой — десять “1”, причем строго по-очереди.
Никакой магии и voodoo-кода. Код четкий и последовательный. Причем, как можно заметить, мы создали поток из функции. Для небольших задач это очень удобно. Этот код еще и достаточно гибкий. Допустим у нас появился 3-й процесс, который пишет “2”, тогда код будет выглядеть так:
Мы добавили новое событие, новый поток и слегка изменили параметры, с которыми
стартуют потоки (можно конечно написать и более общее решение с использованием, например, MapReduce, но это уже выходит за рамки этой статьи).
Как видим по-прежнему никакой магии. Все просто и понятно. Поехали дальше.
Global Interpreter Lock
Существуют две самые распространенные причины использовать потоки: во-первых, для увеличения эффективности использования многоядерной архитектуры cоврменных процессоров, а значит, и производительности программы;
во-вторых, если нам нужно разделить логику работы программы на параллельные полностью или частично асинхронные секции (например, иметь возможность пинговать несколько серверов одновременно).
В первом случае мы сталкиваемся с таким ограничением Python (а точнее основной его реализации CPython), как Global Interpreter Lock (или сокращенно GIL). Концепция GIL заключается в том, что в каждый момент времени только один поток может исполняться процессором. Это сделано для того, чтобы между потоками не было борьбы за отдельные переменные. Исполняемый поток получает доступ по всему окружению. Такая особенность реализации потоков в Python значительно упрощает работу с потоками и дает определенную потокобезопасность (thread safety).
Но тут есть тонкий момент: может показаться, что многопоточное приложение будет работать ровно столько же времени, сколько и однопоточное, делающее то же самое, или за сумму времени исполнения каждого потока на CPU. Но тут нас поджидает один неприятный эффект. Рассмотрим программу:
Эта программа просто пишет в файл миллион строк “1” и делает это за
0.35 секунды на моем компьютере.
Рассмотрим другую программу:
Эта программа создает 2 потока. В каждом потоке она пишет в отдельный файлик по пол миллиона строк “1”. По-сути объем работы такой же, как и у предыдущей программы. А вот со временем работы тут получается интересный эффект. Программа может работать от 0.7 секунды до аж 7 секунд. Почему же так происходит?
Это происходит из-за того, что когда поток не нуждается в ресурсе CPU — он освобождает GIL, а в этот момент его может попытаться получить и он сам, и другой поток, и еще и главный поток. При этом операционная система, зная, что ядер много, может усугубить все попыткой распределить потоки между ядрами.
UPD: на данный момент в Python 3.2 существует улучшенная реализация GIL, в которой эта проблема частично решается, в частности, за счет того, что каждый поток после потери управления ждет небольшой промежуток времени до того, как сможет опять захватить GIL (на эту тему есть хорошая презентация на английском)
«Выходит на Python нельзя писать эффективные многопоточные программы?», — спросите вы. Нет, конечно, выход есть и даже несколько.
Многопроцессные приложения
Для того, чтобы в некотором смысле решить проблему, описанную в предыдущем параграфе, в Python есть модуль subprocess. Мы можем написать программу, которую хотим исполнять в параллельном потоке (на самом деле уже процессе). И запускать ее в одном или нескольких потоках в другой программе. Такой способ действительно ускорил бы работу нашей программы, потому, что потоки, созданные в запускающей программе GIL не забирают, а только ждут завершения запущенного процесса. Однако, в этом способе есть масса проблем. Основная проблема заключается в том, что передавать данные между процессами становится трудно. Пришлось бы как-то сериализовать объекты, налаживать связь через PIPE или друге инструменты, а ведь все это несет неизбежно накладные расходы и код становится сложным для понимания.
Здесь нам может помочь другой подход. В Python есть модуль multiprocessing. По функциональности этот модуль напоминает threading. Например, процессы можно создавать точно так же из обычных функций. Методы работы с процессами почти все те же самые, что и для потоков из модуля threading. А вот для синхронизации процессов и обмена данными принято использовать другие инструменты. Речь идет об очередях (Queue) и каналах (Pipe). Впрочем, аналоги локов, событий и семафоров, которые были в threading, здесь тоже есть.
Кроме того в модуле multiprocessing есть механизм работы с общей памятью. Для этого в модуле есть классы переменной (Value) и массива (Array), которые можно “обобщать” (share) между процессами. Для удобства работы с общими переменными можно использовать классы-менеджеры (Manager). Они более гибкие и удобные в обращении, однако более медленные. Нельзя не отметить приятную возможность делать общими типы из модуля ctypes с помощью модуля multiprocessing.sharedctypes.
Еще в модуле multiprocessing есть механизм создания пулов процессов. Этот механизм очень удобно использовать для реализации шаблона Master-Worker или для реализации параллельного Map (который в некотором смысле является частным случаем Master-Worker).
Из основных проблем работы с модулем multiprocessing стоит отметить относительную платформозависимость этого модуля. Поскольку в разных ОС работа с процессами организована по-разному, то на код накладываются некоторые ограничения. Например, в ОС Windows нет механизма fork, поэтому точку разделения процессов надо оборачивать в:
Впрочем, эта конструкция и так является хорошим тоном.
Для написания параллельных приложений на Python существуют и другие библиотеки и подходы. Например, можно использовать Hadoop+Python или различные реализации MPI на Python (pyMPI, mpi4py). Можно даже использовать обертки существующих библиотек на С++ или Fortran. Здесь можно было упомянуть про такие фреймфорки/библиотеки, как Pyro, Twisted, Tornado и многие другие. Но это все уже выходит за пределы этой статьи.
Если мой стиль вам понравился, то в следующей статье постараюсь рассказать, как писать простые интерпретаторы на PLY и для чего их можно применять.
Многопоточный Python на примерах: как правильно хранить настройки приложения
Если опустить первое и самое главное предубеждение относительно питонячьей многопоточности у большинства программистов — что её не существует из-за GIL, — то остается другое, и, наверное, вполне достоверное: многопоточность — это сложно, и нам этого, пожалуйста, не надо. И знаете что? Так оно и есть. Многопоточность — это сложно, особенно когда выбираешься за пределы стандартных руководств и попадаешь со своей многопоточной поделкой в реальный мир. И, возможно, вам не нужно. Ни здесь, ни далее я не буду обсуждать целесообразность написания многопоточного кода на Python и сразу перейду к тому, как это делать.
Если эта статья всем понравится, за ней может последовать серия на ту же тему: как писать многопоточный код с примерами на Python. Конкретно в этой статье примеры будут взяты из фреймворка Polog, который я пишу в свободное время. Он предназначен для логирования, но я постарался вынести все базовые принципы и паттерны в эту статью в специфико-агностичной форме. А там, где без специфики фреймворка не обойтись, буду указывать, в чём именно она состоит. Хочется верить, что это даже поспособствует усвоению материала за счёт каких-то ассоциативных связей, что ли.
Дисклеймер
Несмотря на то, что я привожу собственный код в качестве примера, я тем самым не утверждаю, что он написан лучшим из возможным способов. Более того, я даже не уверен, что вы не сможете найти в нём каких-либо (возможно, фатальных) недостатков. Если вдруг вы в состоянии это сделать, было бы круто, не сдерживайте себя.
Кроме того, здесь не будет ликбеза про многопоточность. Предполагается, что вы уже прочитали что-то вроде документации к модулю threading из стандартной библиотеки, и в целом хотя бы немного знакомы с темой. Вам не нужно объяснять, чем потоки отличаются от вашей бабушки, кто такие эти наши локи (и каковы их семейные взаимоотношения с дедом), что такое состояние гонки и всякое подобное. Про это всё вы уже сами прочитали и теперь вам интересно не столько то, что такое многопоточность, сколько то, как её применение может выглядеть в конкретном проекте.
Чего мы хотим?
Существует самый простой и очевидный способ хранить настройки приложения в качестве обычных переменных внутри модуля или класса. Его используют в том числе крупные веб-фреймворки, и я поначалу делал так же. Так почему же по итогу пришлось всё переделывать? Дело в том, что к хранению настроек внутри фреймворка возникли довольно специфические требования:
Прежде всего, необходима гибкая валидация значений. Причём, поскольку это библиотека, пользователь должен получать информативное исключение с описанием того, что именно он сделал не так, когда попытался применить заведомо невозможную настройку (например, установить какой-нибудь интервал времени в виде отрицательной величины).
Необходимо валидировать не просто значения сами по себе, но и их комбинации. К примеру, в Polog поддерживается два типа движков: синхронный (без использования воркеров в отдельных потоках) и асинхронный. Синхронный подгружается, когда настройка pool_size установлена в значение 0, асинхронный — при любом значении больше нуля. Асинхронный движок работает по схеме producer–consumer с передачей данных через очередь. Так вот, для этой самой очереди можно установить лимит — целое число больше нуля, а можно не устанавливать, оставив значение 0 по умолчанию. «Невозможной» комбинацией является pool_size равный нолю, и при этом max_queue_size (лимит размера очереди) больше нуля, поскольку никакой очереди в синхронном движке просто нет. Хотя каждое отдельное из значений — нулевой размер пула и ненулевой размер очереди — само по себе валидно, при попытке установить такую комбинацию мы тоже должны кинуть исключение с описанием конфликтующих полей.
Нужна возможность изменять настройки в любой момент. Исходно всё работало так: перед записью самого первого лога вы указывали нужные значения для всех пунктов настроек, и потом, когда первый лог создавался, инициализировались все внутренние объекты фреймворка согласно настройкам на этот момент времени. Дальше уже ничего поменять было нельзя. Так быть не должно, в идеале любой пункт настроек должно быть возможно изменить в любой момент времени, не потеряв при этом ни одной записи. Дальше станет ясно, почему этого добиться не так уж просто.
Каждый пункт настроек должен иметь значение по умолчанию.
Всё должно быть защищено от состояния гонки.
Хранилище должно иметь интерфейс словаря. Это просто и удобно, а ещё так компоненты фреймворка проще тестировать, подменяя хранилище настроек словарем.
Базовая концепция
У нас есть класс, оборачивающий доступ к словарю, объявленному как один из его атрибутов. Примерно вот такой:
Ключи в этом словаре — имена пунктов настроек, а значения — объекты, в которых хранятся значения. Именно в этих объектах класса SettingPoint и происходит почти вся описанная выше магия: валидация значений с киданием красивых исключений, блокировки и всё прочее. Далее мы сосредоточимся на устройстве и механизмах внутри этого класса.
Валидируем одиночные значения
Рассмотрение класса SettingPoint мы начнём с самого простого — как валидируются значения. Напомню, что создание экземпляра выглядело примерно так:
На этом этапе нас интересует словарь, который мы передаем для инициализации экземпляра как аргумент proves :
Ищем конфликты
Немного усложняем задачу: теперь нам нужно проверить, что два разных значения настроек не образуют невозможную комбинацию, хотя каждое из них в отдельности валидно. Для этого при инициализации объекта SettingPoint мы передаём словарь с описанием конфликтов, примерно такой:
Ключи в этом словаре — это названия полей, конфликты с которыми мы проверяем, а значения — функции. При каждой попытке сохранить новое значение этого пункта настроек мы передаём в каждую из функций три параметра: новое значение; значение, которое было до этого; и текущее значение того поля, конфликты с которым мы ищем. Для этого в объекте поля запускается простенький метод:
Что ж, мы провели два типа проверок и теперь должны быть уверены, что новое значение настройки нам подходит. Теперь наша задача — применить полученные данные.
Меняем настройки в рантайме
Пунктов настроек больше одного, но я опишу конвейер изменения одного из них, с которым пришлось повозиться больше всех. Чтобы объяснить, почему так сложно его изменить, придётся немного погрузить в устройство фреймворка до и после. Будет немного специфики, без неё никуда.
Исходно ядро Polog было исключительно многопоточным, на базе самописного thread pool. Тредпул работал по паттерну издатель-подписчик, то есть состоял из тр`х логических частей:
Объект, который передаёт логи в очередь.
Набор потоков-воркеров. В каждом из них запущен бесконечный цикл, в котором они ожидают появления новых записей в очереди. Как только первый из них забирает лог, он сразу его как-то обрабатывает, например, записывает в файл.
Воркеры создавались «лениво», в момент записи первого лога. Это позволяло применить любые настройки на этапе инициализации программы. Проблема только в том, что тредпул получался «приколоченным гвоздями». После записи первого лога уже ни добавить воркеров, ни убрать лишних. Кроме того, не была предусмотрена возможность использования беспоточного движка: класс движка тоже, получается, приколочен. Валидным размером тредпула в настройках было только число не меньше единицы.
Существует две базовые стратегии изменения тредпула «на лету»:
Изменение текущего тредпула с помощью добавления новых или удаления из него старых воркеров. Это вполне легко реализовать, нужно лишь получить новое число потоков; сравнить его с текущим; определить, добавляем или убираем; и применить изменение.
Обернуть компонент с тредпулом в объект уровнем выше, который при каждом изменении настроек будет пересоздавать пул с нуля.
Казалось бы, первый вариант экономичнее. Надо нам добавить 101-й поток, мы не занимаемся бессмысленным уничтожением и созданием заново предыдущей сотни. Мы просто создаём ещё один и добавляем в пул. Однако я выбрал второй вариант, поскольку его API универсальнее и позволяет легко изменять любые характеристики движка, а не только количество воркеров. Любое изменение — это уничтожение предыдущего компонента и создание нового в соответствии с новыми настройками. У такого решения, кстати, был важный и полезный побочный эффект: стало возможным существование более одного типа движков. Старый уничтожается, а новый может быть каким угодно, он не связан со старым какими-либо общими механизмами.
«Настоящих» движков (не обёрток) в настоящее время доступно два типа: синхронный и асинхронный, как я уже писал выше. Их порождает вот такая простенькая функция:
Если размер запрошенного пула потоков (настройка pool_size ) больше нуля, то создаётся асинхронный движок на базе тредпула. А если равен нулю, то создаётся более примитивный синхронный движок, у которого нет тредпула, а есть просто метод, куда можно передать лог и он будет обработан.
Для понимания, как выглядит минимальный возможный движок, синхронная версия выглядит так:
Это всё. Метод самоуничтожения спрятан в классе, от которого наследуется движок, и не делает ничего, поскольку тут нет никаких ресурсов, которые надо освобождать.
Асинхронный немного сложнее, ведь он должен уметь завершаться, не потеряв ни одного лога в очереди. Давайте на него тоже глянем, прежде чем идти дальше:
Как видите, вся работа по корректному завершению происходит внутри пула, так что провалимся в него:
Итак, завершение работы пула происходит в три этапа:
Дожидаемся, пока очередь с логами опустеет. При этом важно, чтобы с другой стороны её никто не пополнял, но об этом будет дальше. Ожидание опустения очереди — это обычный цикл, в котором с определённой периодичностью запрашивается размер очереди, и который завершается, когда очередь пуста.
Сообщаем каждому воркеру, что пора заканчивать работу. Дело в том, что у него на руках в этот момент ещё может быть последний взятый из очереди лог, поэтому мы не можем прервать его насильно и вынуждены вежливо попросить. Воркер, в свою очередь (на это мы посмотрим дальше), получив такое сообщение, должен закончить с последним взятым из очереди логом, после чего добровольно суициднуться прервать собственный цикл.
Джойним каждый поток с воркером.
Внутри воркера обработка сообщения об остановке выглядит как установка в значение True флага self.stopped :
Этот пример кода, возможно, придётся пораскуривать некоторое время, мне и самому его не так просто в голове уместить. Прежде всего требует пояснений, почему здесь два бесконечных цикла, а не один, как я обещал ранее. Дело в том, что если воркер ожидает из очереди данных без таймаута, то возможна ситуация, когда мы дождались бы опустения очереди, проставили флаг завершения и дальше бесконечно ждали бы его. Поэтому воркер ожидает данные из очереди не бесконечно, а периодически «просыпается», чтобы проверить, не попросили ли его прерваться. Не попросили — прекрасно, возвращаемся в очередь.
Выше мы выяснили, как происходит рождение и остановка движков, однако здесь мне придётся вернуться немного назад, чтобы рассказать ещё о паре важных моментов.
Во-вторых, при подмене движка во время работы программы нужно учитывать, что всё это время его обёртку могут вызывать логгеры из разных потоков, поэтому обёртку движка мы также временно блокируем. Код ниже демонстрирует, как происходит блокировка и последующая разблокировка движка при перезагрузке:
Таким образом, любой поток, который попытается записать лог в тот момент, пока движок перезагружается, будет временно заблокирован, и продолжит свое выполнение, когда всё уже завершится, ничего даже не заметив. Лог будет записан как положено и не потеряется.
Если вы добрались до этого места и всё ещё ничего не поняли, то вот краткий пересказ того, как происходит применение нового значения пункта pool_size :
У других пунктов настроек схема может быть попроще, но принципиально отличаться не будет.
Заключение
Если вы дочитали, а не пролистали, статью до этого места, вам представленный код мог показаться чересчур сложным для решаемой им задачи (а если раньше вы уже делали что-то похожее, возможно, наоборот, слишком простым). Нужно учитывать, что такое навороченное решение с почти реализованной транзакционностью не появляется за один присест. Это результат неоднократного рефакторинга, причём каждая следующая итерация по сути добавляла новый уровень ненаивности в реализацию. И я не знаю, сколько их ещё впереди. Наивные решения задач — это хорошо и эффективно в большинстве случаев, если только речь не про многопоточность. А многопоточность — это сложно.