Как писать многопоточные приложения

Обновлено: 19.09.2024

Когда-то давно мы делали простой таймер с напоминанием на Python . Он работал так:

  1. Мы спрашивали пользователя, о чём ему напомнить и через сколько минут.
  2. Программа на это время засыпала и ничего не делала.
  3. Как только время сна заканчивалось, программа просыпалась и выводила напоминание.

У такой схемы есть минус: мы не можем пользоваться программой и выделенными на неё ресурсами до тех пор, пока она не проснётся. Процессор по кругу гоняет пустые команды и ждёт, когда можно будет продолжить полезную работу. Чтобы процессор и программа могли во время работы таймера делать что-то ещё, используют потоки.

Что такое поток

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

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

👉 Важно понимать, что поток — это высокоуровневое понятие из области программирования. На уровне вашего «железа» эти потоки всё ещё могут обсчитываться последовательно. Но благодаря тому, что они будут обсчитываться быстро, вам может показаться, что они работают параллельно.

Многопоточность

Представим такую ситуацию:

  • У вас на руке смарт-часы, которые собирают данные о вашем пульсе, УФ-излучении и движениях. На смарт-часах работает программа, которая обрабатывает эти данные.
  • Программа состоит из четырёх функций. Первая собирает данные с датчиков. Три другие обрабатывают эти данные и делают выводы.
  • Пока первая функция не собрала нужные данные, ничего другого не происходит.
  • Как только данные введены, запускаются три оставшиеся функции. Они не зависят друг от друга и каждая считает своё.
  • Как только все три функции закончат работу, программа выдаёт нужный результат.

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


Эта статья не для матёрых укротителей 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 и для чего их можно применять.


Какая тема вызывает больше всего вопросов и затруднений у начинающих? Когда я спросила об этом преподавателя и Java-программиста Александра Пряхина, он сразу ответил: «Многопоточность». Спасибо ему за идею и помощь в подготовке этой статьи!

Мы заглянем во внутренний мир приложения и его процессов, разберёмся, в чём суть многопоточности, когда она полезна и как её реализовать — на примере Java. Если учите другой язык ООП, не огорчайтесь: базовые принципы одни и те же.

О потоках и их истоках

Чтобы понять многопоточность, сначала вникнем, что такое процесс. Процесс – это часть виртуальной памяти и ресурсов, которую ОС выделяет для выполнения программы. Если открыть несколько экземпляров одного приложения, под каждый система выделит по процессу. В современных браузерах за каждую вкладку может отвечать отдельный процесс.

Вы наверняка сталкивались с «Диспетчером задач» Windows (в Linux это — «Системный монитор») и знаете, что лишние запущенные процессы грузят систему, а самые «тяжёлые» из них часто зависают, так что их приходится завершать принудительно.

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

Мы хотим, чтобы в единицу времени процессор успевал выполнить больше команд и обработать больше данных. То есть нам надо уместить в каждом кванте времени больше выполненного кода. Представьте единицу выполнения кода в виде объекта — это и есть поток.

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

У каждого приложения есть как минимум один процесс, а у каждого процесса — минимум один поток, который называют главным и из которого при необходимости запускают новые.

Разница между потоками и процессами

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

Процессы работают каждый со своими данными — обмениваться чем-то они могут только через механизм межпроцессного взаимодействия. Потоки обращаются к данным и ресурсам друг друга напрямую: что изменил один — сразу доступно всем. Поток может контролировать «собратьев» по процессу, в то время как процесс контролирует исключительно своих «дочек». Поэтому переключаться между потоками быстрее и коммуникация между ними организована проще.

Какой отсюда вывод? Если вам нужно как можно быстрее обработать большой объём данных, разбейте его на куски, которые можно обрабатывать отдельными потоками, а затем соберите результат воедино. Это лучше, чем плодить жадные до ресурсов процессы.

Но почему такое популярное приложение как Firefox идёт по пути создания нескольких процессов? Потому что именно для браузера изолированная работа вкладок — это надёжно и гибко. Если с одним процессом что-то не так, не обязательно завершать программу целиком — есть возможность сохранить хотя бы часть данных.

Что такое многопоточность

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

Вычислительная нагрузка распределяется между двумя или более ядрами, так что интерфейс и другие компоненты программы не замедляют работу друг друга.

Многопоточные приложения можно запускать и на одноядерных процессорах, но тогда потоки выполняются по очереди: первый поработал, его состояние сохранили — дали поработать второму, сохранили — вернулись к первому или запустили третий, и т.д.


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

Жди сигнала: синхронизация в многопоточных приложениях

Представьте, что несколько потоков пытаются одновременно изменить одну и ту же область данных. Чьи изменения будут в итоге приняты, а чьи — отменены? Чтобы работа с общими ресурсами не приводила к путанице, потокам нужно координировать свои действия. Для этого они обмениваются информацией с помощью сигналов. Каждый поток сообщает другим, что он сейчас делает и каких изменений ждать. Так данные всех потоков о текущем состоянии ресурсов синхронизируются.

В категориях объектно-ориентированного программирования сигналы — это объекты синхронизации. У каждого из них — своя роль во взаимодействии.

Основные средства синхронизации

Взаимоисключение (mutual exclusion, сокращённо — mutex) — «флажок», переходящий к потоку, который в данный момент имеет право работать с общими ресурсами. Исключает доступ остальных потоков к занятому участку памяти. Мьютексов в приложении может быть несколько, и они могут разделяться между процессами. Есть подвох: mutex заставляет приложение каждый раз обращаться к ядру операционной системы, что накладно.

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

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

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

Как реализовать многопоточность в Java

За работу с потоками в Java отвечает класс Thread. Создать новый поток для выполнения задачи — значит создать экземпляр класса Thread и связать его с нужным кодом. Сделать это можно двумя путями:

образовать от Thread подкласс;

имплементировать в своём классе интерфейс Runnable, после чего передавать экземпляры класса в конструктор Thread.

Пока мы не будем затрагивать тему тупиковых ситуаций (deadlock'ов), когда потоки блокируют работу друг друга и зависают — оставим это для следующей статьи. А сейчас перейдём к практике.

Пример многопоточности в Java: пинг-понг мьютексами

Если вы думаете, что сейчас будет что-то страшное — выдохните. Работу с объектами синхронизации мы рассмотрим почти в игровой форме: два потока будут перебрасываться mutex'ом. Но по сути вы увидите реальное приложение, где в один момент времени только один поток может обрабатывать общедоступные данные.

Сначала создадим класс, наследующий свойства уже известного нам Thread, и напишем метод «удара по мячу» (kickBall):

Теперь позаботимся о мячике. Будет он у нас не простой, а памятливый: чтоб мог рассказать, кто по нему ударил, с какой стороны и сколько раз. Для этого используем mutex: он будет собирать информацию о работе каждого из потоков — это позволит изолированным потокам общаться друг с другом. После 15-го удара выведем мяч из игры, чтоб его сильно не травмировать.

А теперь на сцену выходят два потока-игрока. Назовём их, не мудрствуя лукаво, Пинг и Понг:

«Полный стадион народа — время начинать матч». Объявим об открытии встречи официально — в главном классе приложения:

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

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

Допустим, у нас есть 2 рабочих, которые хорошо копают ямы. Предположим, что один выкопает яму глубиной 2 метра, за 1 час. Тогда два рабочих, выкопают эту яму за полчаса. Это похоже на правду. Давайте возьмем 3600 таких рабочих. Теоретически, они выкопают яму глубиной 2 метра за 1 секунду. Но на практике они будут друг другу мешать, топтаться на одном месте и нервничать.

Далее немного практики. Что бы начать работать с потоками, необходимо подключить пространство имен System.Threading, добавив в начало файла с кодом следующую директиву:

Пример запуска такого потока:

После вызова метода Start() у объекта потока, управление вернется сразу, но в этот момент уже начнет работать ваш новый поток. Новый поток выполнит тело функции MyThreadFunction и завершится. Мой друг спросил меня, а почему функция не возвращает значение? А потому, что его некуда вернуть. После вызова Start(), управление передается дальше, при этом созданный поток может работать еще длительное время. Что бы обмениваться данными между потоками, можно пользоваться переменными класса. Об этом позже.

Кроме того, существует еще один вариант метода, из которого можно сделать поток. Выглядит он вот так:

Идем дальше. Что бы создать несколько потоков, необязательно использовать несколько функций, если потоки одинаковые. Вот пример:

Как видите, для создания 10 потоков нам понадобилась всего 1 функция.

Каждый поток имеет свой стек, поэтому локальные переменные метода для каждого потока свои. Что бы это продемонстрировать, мы создадим поток для метода класса, а потом вызовем этот же метод из главного потока. Вот код:

После завершения выполнения потоков, в консоле будет выведено 10 чисел.

Теперь давайте пример. Создадим отдельный класс, который будет создавать дополнительные потоки. Каждый поток будет работать с одной переменной (свойство класса). Для обеспечения сохранности данных мы будем использовать оператор lock. Его синтаксис очень простой:

А вот и пример программы:

  1. Первый поток выведет 0 на экран;
  2. Второй поток выведет 0 на экран и увеличит значение на единицу;
  3. Третий поток выведет 1 на экран;
  4. Первый поток увеличит значение на единицу;
  5. Третий поток увеличит значение на единицу;
  6. Второй поток выведет 3 на экран;

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

В более ранних постах было рассказано про многопоточность в Windows при помощи CreateThread и прочего WinAPI, а также многопоточность в Linux и других *nix системах при помощи pthreads. Если вы пишите на C++11 или более поздних версиях, то вам доступны std::thread и другие многопоточные примитивы, появившиеся в этом стандарте языка. Далее будет показано, как с ними работать. В отличие от WinAPI и pthreads, код, написанный на std::thread, является кроссплатформенным.

Примечание: Приведенный код был проверен на GCC 7.1 и Clang 4.0 под Arch Linux, GCC 5.4 и Clang 3.8 под Ubuntu 16.04 LTS, GCC 5.4 и Clang 3.8 под FreeBSD 11, а также Visual Studio Community 2017 под Windows 10. CMake до версии 3.8 не умеет говорить компилятору использовать стандарт C++17, указанный в свойствах проекта. Как установить CMake 3.8 в Ubuntu 16.04 описано здесь. Чтобы код компилировался при помощи Clang, в *nix системах должен быть установлен пакет libc++. Для Arch Linux пакет доступен на AUR. В Ubuntu есть пакет libc++-dev, но вы можете столкнуться с непофикшенным багом, из-за которого код так просто собираться не будет. Воркэраунд описан на StackOverflow. Во FreeBSD для компиляции проекта нужно установить пакет cmake-modules.

Мьютексы

Ниже приведен простейший пример использования трэдов и мьютексов:

std :: mutex mtx ;
static int counter = 0 ;
static const int MAX_COUNTER_VAL = 100 ;

Обратите внимание на оборачивание std::mutex в std::lock_guard в соответствии c идиомой RAII. Такой подход гарантирует, что мьютекс будет отпущен при выходе из скоупа в любом случае, в том числе при возникновении исключений. Для захвата сразу нескольких мьютексов с целью предотвращения дэдлоков существует класс std::scoped_lock. Однако он появился только в C++17 и потому может работать не везде. Для более ранних версий C++ есть аналогичный по функционалу шаблон std::lock, правда для корректного освобождения локов по RAII он требует написания дополнительного кода.

RWLock

// std::shared_mutex mtx; // will not work with GCC 5.4
std :: shared_timed_mutex mtx ;

static int counter = 0 ;
static const int MAX_COUNTER_VAL = 100 ;

Thread Local Storage

Иногда бывает нужно создать переменную, вроде глобальной, но которую видит только один поток. Другие потоки тоже видят переменную, но у них она имеет свое локальное значение. Для этого придумали Thread Local Storage, или TLS (не имеет ничего общего с Transport Layer Security!). Помимо прочего, TLS может быть использован для существенного ускорения генерации псевдослучайных чисел. Пример использования TLS на C++:

std :: mutex io_mtx ;
thread_local int counter = 0 ;
static const int MAX_COUNTER_VAL = 10 ;

Мьютекс здесь используется исключительно для синхронизации вывода в консоль. Для доступа к thread_local переменным никакая синхронизация не требуется.

Атомарные переменные

Атомарные переменные часто используются для выполнения простых операций без использования мьютексов. Например, вам нужно инкрементировать счетчик из нескольких потоков. Вместо того, чтобы оборачивать int в std::mutex, эффективнее воспользоваться std::atomic_int. Также C++ предлагает типы std::atomic_char, std::atomic_bool и многие другие. Еще на атомарных переменных реализуют lock-free алгоритмы и структуры данных. Стоит отметить, что они весьма сложны в разработке и отладке, и не на всех системах работают быстрее аналогичных алгоритмов и структур данных с локами.

std :: mutex io_mtx ;

Обратите внимание на использование процедуры hardware_concurrency. Она возвращает оценку количества трэдов, которое в текущей системе может выполняться параллельно. Например, на машине с четырехядерным процессором, поддерживающим hyper threading, процедура возвращает число 8. Также процедура может возвращать ноль, если сделать оценку не удалось или процедура попросту не реализована.

Кое-какую информацию о работе атомарных переменных на уровне ассемблера можно найти в заметке Шпаргалка по основным инструкциям ассемблера x86/x64.

Заключение

Насколько я вижу, все это действительно неплохо работает. То есть, при написании кроссплатформенных приложений на C++ про WinAPI и pthreads можно благополучно забыть. В чистом C начиная с C11 также существуют кроссплатформенные трэды. Но они все еще не поддерживаются Visual Studio (я проверил) , и вряд ли когда-либо будут поддерживаться. Не секрет, что Microsoft не видит интереса в развитии поддержки языка C в своем компиляторе, предпочитая концентрироваться на C++.

Полная версия исходников к этой заметке, как обычно, лежит на GitHub. А как вы сейчас пишите многопоточные приложения на C++?

Читайте также: