Skip to content

Latest commit

 

History

History
320 lines (182 loc) · 56.8 KB

File metadata and controls

320 lines (182 loc) · 56.8 KB

Многопоточное программирование: учебник по POSIX pthreads

Источник: Multithreaded Programming (POSIX pthreads Tutorial)

Альфред Парк (randu.org) · 1999–2026

Содержание: 1. Введение 2. Что такое поток? 3. Шаблоны проектирования потоков 4. Защита разделяемых ресурсов 5. Примитивы синхронизации потоков 6. POSIX pthreads 7. Соображения производительности 8. Другие подходы 9. Ресурсы

Введение

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

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

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

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

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

Аналогия

Разве это не то, что продевают через ушко швейной иглы?

Да.

Как же это связано с программированием?

Представьте швейные иглы как процессоры, а нити в программе — как нитки-волокна. Если бы у вас было две иглы, но только одна нить, на завершение работы ушло бы больше времени (одна игла простаивала бы), чем если разрезать нить надвое и использовать обе иглы одновременно. Продолжая аналогию: если одной игле нужно пришить пуговицу (блокирующий ввод-вывод), другая игла могла бы продолжать делать другую полезную работу, даже если бы первой игле потребовался час на пришивание одной пуговицы. Если бы вы использовали только одну иглу, вы отстали бы примерно на час!

Определение

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

Компьютерная программа становится процессом, когда она загружается из некоторого хранилища в память компьютера и начинает выполнение. Процесс может исполняться процессором или набором процессоров. Описание процесса в памяти содержит жизненно важную информацию: счётчик команд (program counter), отслеживающий текущую позицию в программе (то есть какая инструкция сейчас выполняется), регистры, хранилища переменных, файловые дескрипторы, сигналы и так далее.

Поток (thread) — это последовательность таких инструкций внутри программы, которая может выполняться независимо от другого кода.

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

Некоторая информация не может копироваться, например стек (указатель стека указывает на разные области памяти для каждого потока), регистры и специфичные для потока данные. Этой информации достаточно, чтобы потоки планировались независимо от главного потока программы и, возможно, одного или нескольких других потоков внутри программы.

Для запуска многопоточных программ требуется явная поддержка операционной системы. К счастью, большинство современных ОС поддерживают потоки: Linux (через NPTL), варианты BSD, Mac OS X, Windows, Solaris, AIX, HP-UX и так далее. Операционные системы могут использовать разные механизмы для реализации поддержки многопоточности.

Терминология

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

  • Лёгкий процесс (Lightweight Process, LWP) можно представить как виртуальный CPU; число LWP обычно больше числа CPU в системе. Библиотеки потоков общаются с LWP для планирования потоков. LWP также иногда называют потоками ядра (kernel threads).
  • Модель X-to-Y. Отображение между LWP и потоками. В зависимости от реализации ОС и/или используемой пользовательской библиотеки потоков оно может быть 1:1, X:1 или X:Y. Linux, некоторые ядра BSD и некоторые версии Windows используют модель 1:1. Пользовательские библиотеки потоков обычно относятся к классу X:1, так как нижележащее ядро не знает о пользовательских потоках. Модель X:Y используется в Windows 7.
  • Область конкуренции (Contention Scope) — то, как потоки конкурируют за системные ресурсы (то есть планирование).
  • Привязанные потоки (Bound threads) имеют общесистемную область конкуренции, иначе говоря, эти потоки конкурируют с другими процессами во всей системе.
  • Непривязанные потоки (Unbound threads) имеют область конкуренции в рамках процесса.
  • Потокобезопасный (Thread-safe) — значит, программа защищает разделяемые данные, возможно, через взаимное исключение.
  • Реентерабельный (Reentrant) код — значит, программа может иметь более одного потока, выполняющегося конкурентно.
  • Async-safe — значит, функция реентерабельна при обработке сигнала (то есть может вызываться из обработчика сигнала).
  • Конкурентность против параллелизма — это не одно и то же! Параллелизм подразумевает одновременное выполнение кода (что в строгом смысле невозможно на однопроцессорных машинах), тогда как конкурентность подразумевает, что много задач может выполняться в любом порядке и, возможно, параллельно.

Закон Амдала и принцип Парето

Потоки могут дать преимущества… для подходящих приложений! Не тратьте время на многопоточность части кода или целой программы, которая этого не стоит.

Джин Амдал обосновал теоретическое максимальное улучшение, возможное для параллелизованной компьютерной программы, в предположении сильного масштабирования (то есть программа работает с фиксированным размером задачи). Его утверждение — широко известное положение под названием закон Амдала. По сути, закон Амдала гласит, что ускорение программы благодаря параллелизации не может быть больше обратной величины доли программы, которая неизменно последовательна. Например, если 50% вашей программы не поддаётся параллелизации, вы можете ожидать максимум двукратного ускорения, независимо от количества процессоров, брошенных на задачу. Конечно, многие задачи и наборы данных, обрабатываемые параллельными программами, не имеют фиксированного размера, или доля последовательного кода может быть близка к нулю. Важное здесь для читателя — понять, что большинство интересных задач, решаемых компьютерными программами, имеют ограничения в объёме параллелизма, который можно эффективно выразить (или который вводится самим механизмом параллелизации) и использовать в виде потоков или других параллельных конструкций.

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

Есть распространённая поговорка: «90% циклов процессора тратится в 10% кода». Формально это известно как принцип Парето. Тщательно анализируйте свой код или план проектирования; не тратьте всё время на оптимизацию/параллелизацию 90% кода, которые не так важны! Профилирование и анализ кода выходят за рамки этого документа, но рекомендуется к прочтению тем, кто не знаком с темой.

Шаблоны проектирования потоков

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

Пул потоков (Boss/Worker)

Один поток отправляет другие потоки делать полезную работу; обычно они являются частью пула рабочих потоков. Этот пул обычно выделяется заранее, прежде чем босс (или мастер) начнёт отправлять потоки на работу. Хотя потоки и лёгкие, их создание всё равно несёт накладные расходы.

Peer (Workcrew)

Модель peer похожа на модель boss/worker, за исключением того, что после создания пула рабочих босс становится ещё одним потоком в пуле и, таким образом, равноправным (peer) с другими потоками.

Конвейер (Pipeline)

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

Защита разделяемых ресурсов

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

Взаимное исключение

Взаимное исключение (mutual exclusion) — это метод сериализации доступа к разделяемым ресурсам. Вы же не хотите, чтобы поток изменял переменную, которую уже изменяет другой поток! Другой сценарий — «грязное чтение» (dirty read), когда значение в процессе обновления, а другой поток читает старое значение.

Взаимное исключение позволяет программисту создать определённый протокол для сериализации доступа к разделяемым данным или ресурсам. Логически, мьютекс (mutex) — это замок, который можно виртуально прикрепить к некоторому ресурсу. Если поток хочет изменить или прочитать значение из разделяемого ресурса, он сначала должен получить замок. Получив его, он может делать с разделяемым ресурсом что хочет, не беспокоясь о других потоках, потому что им придётся ждать. Когда поток заканчивает использовать разделяемый ресурс, он разблокирует мьютекс, позволяя другим потокам получить доступ. Это протокол, сериализующий доступ к разделяемому ресурсу. Заметьте, что такой протокол должен соблюдаться для данных или ресурса, которые защищает мьютекс, всеми потоками, которые могут касаться защищаемого ресурса. Если протокол нарушен (например, поток изменяет разделяемый ресурс, не запросив сначала блокировку мьютекса), то определённый программистом протокол потерпел неудачу. Ничто не мешает программисту потоков — случайно (чаще всего, то есть это баг — см. состояния гонки ниже) или намеренно — реализовать ущербный протокол сериализации.

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

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

Типы мьютексов

Есть разные типы замков, отличные от стандартного простого блокирующего.

  • Рекурсивный (Recursive): позволяет потоку, держащему замок, захватить его снова, что может быть нужно для рекурсивных алгоритмов.
  • С очередью (Queuing): обеспечивает справедливость при захвате замка через FIFO-упорядочивание приходящих запросов. Такие мьютексы могут быть медленнее из-за больших накладных расходов и вероятности необходимости будить следующие по очереди спящие потоки.
  • Читатель/писатель (Reader/Writer): позволяет нескольким читателям захватывать замок одновременно. Если замок держат читатели, запрос писателя блокируется, пока все читатели не отдадут замок. Это может привести к «голоданию» писателей (writer starvation).
  • Scoped: семантика в стиле RAII в отношении захвата и снятия замка.

В зависимости от используемой библиотеки потоков или интерфейса может быть доступно только подмножество дополнительных типов замков. POSIX pthreads допускает рекурсивные замки и замки читатель/писатель.

Потенциальные ловушки с мьютексами

Важная проблема, связанная с мьютексами, — возможность взаимоблокировки (deadlock). Программа может войти в deadlock, если два (или более) потока остановили выполнение или вечно крутятся в цикле. Например, простейшая ситуация deadlock: поток 1 берёт замок A, поток 2 берёт замок B, поток 1 хочет замок B, а поток 2 хочет замок A. Мгновенный deadlock. Можно предотвратить это, следя за тем, чтобы потоки брали замки в согласованном порядке (то есть соблюдением порядка блокировки — lock ordering). Deadlock также может случиться, если потоки некорректно разблокируют мьютексы.

Состояние гонки (race condition) — это когда недетерминированное поведение возникает из-за доступа потоков к разделяемым данным или ресурсам без следования определённому протоколу синхронизации для сериализации такого доступа. Это может приводить к ошибочным результатам, вызывающим сбой или непоследовательное поведение, что делает состояния гонки особенно сложными для отладки. Помимо некорректно синхронизированного доступа к разделяемым ресурсам, частые виновники — вызовы библиотек вне контроля вашей программы. Позаботьтесь о том, чтобы в вашей программе обеспечивался последовательный доступ к разделяемым файловым дескрипторам и другим внешним ресурсам. Большинство man-страниц содержат информацию о потокобезопасности конкретной функции, и если она не потокобезопасна — есть ли альтернативы (например, gethostbyname() и gethostbyname_r()).

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

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

Атомарные операции позволяют конкурентные алгоритмы и доступ к определённым разделяемым типам данных без использования мьютексов. Например, при достаточной поддержке компилятора и системы можно изменять переменную (например, 64-битное целое) в многопоточном контексте без протокола блокировки. Многие атомарные вызовы непереносимы и специфичны для компилятора и системы. Intel Threading Building Blocks (см. ниже) содержит полупереносимую атомарную поддержку под C++. Стандарты C++1x и C1x также включат поддержку атомарных операций. Об атомарной поддержке, специфичной для gcc, смотрите здесь и здесь.

Алгоритмы без блокировок (lock-free) могут обеспечить высокую конкурентность и масштабируемость операций. Однако lock-free алгоритмы могут быть сложнее своих блокирующих собратьев, потенциально внося дополнительные накладные расходы, способные вызвать негативные эффекты кэша и другие проблемы. Требуется тщательный анализ и тестирование производительности для рассматриваемой задачи.

Примитивы синхронизации потоков

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

Join

Присоединение потока (join) — это протокол, позволяющий программисту собрать все связанные потоки в логической точке синхронизации. Например, в параллелизме fork-join потоки порождаются для решения параллельных задач, а затем присоединяются обратно к главному потоку после завершения своих задач (выполняя неявный барьер в точке join). Заметьте, что поток, выполняющий join, уже завершил выполнение своей потоковой функции.

Переменные состояния

Переменные состояния (condition variables) позволяют потокам синхронизироваться по значению разделяемого ресурса. Обычно переменные состояния используются как система уведомлений между потоками.

Например, у вас может быть счётчик, при достижении определённого значения которого вы хотите активировать поток. Поток (или потоки), активирующийся при достижении счётчиком лимита, будет ждать (wait) на переменной состояния. Активные потоки сигналят (signal) на этой переменной состояния, чтобы уведомить другие потоки, ждущие/спящие на этой переменной состояния; тем самым ожидающий поток пробуждается. Можно также использовать механизм broadcast, если вы хотите разбудить все потоки, ждущие на переменной состояния. Концептуально это показано на рисунке справа с псевдокодом.

При ожидании на переменных состояния ожидание должно быть внутри цикла, а не в простом if-условии, из-за ложных пробуждений (spurious wakeups). Нет гарантии, что если поток пробудился, то это результат вызова signal или broadcast.

Барьеры

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

Спинлоки

Спинлоки (spinlocks) — это замки, которые крутятся (spin) на мьютексах. Спиннинг означает постоянный опрос, пока не выполнено условие. В случае спинлоков, если поток не может получить мьютекс, он будет продолжать опрашивать замок, пока он не освободится. Преимущество спинлока в том, что поток остаётся активным и не входит в сон-ожидание освобождения мьютекса, поэтому в определённых случаях может работать лучше типичных блокирующих мьютексов со сном-ожиданием. Мьютексы с высокой конкуренцией — плохие кандидаты для спинлоков.

Спинлоков следует избегать в однопроцессорных контекстах. Почему?

Семафоры

Семафоры — ещё один тип примитива синхронизации, существующий в двух видах: бинарные и считающие. Бинарные семафоры действуют подобно простым мьютексам, тогда как считающие могут вести себя как рекурсивные мьютексы. Считающие семафоры можно инициализировать любым произвольным значением, которое должно зависеть от того, сколько ресурсов у вас доступно для конкретных разделяемых данных. Много потоков могут захватывать замок одновременно, пока не достигнут лимита. Это называется глубиной замка (lock depth).

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

POSIX pthreads

Теперь, когда у нас есть хорошая база концепций потоков, поговорим о конкретной реализации потоков — POSIX pthreads. Библиотеку pthread можно найти почти в любой современной POSIX-совместимой ОС (и даже под Windows, см. pthreads-win32).

Заметьте, что невозможно покрыть больше, чем введение в pthreads, в рамках этого краткого обзора и учебника. Концепции pthreads, такие как классы планирования потоков, специфичные для потока данные, отмена потоков, обработка сигналов и замки читатель/писатель, здесь не рассматриваются. См. раздел «Ресурсы» для дополнительной информации.

Если вы программируете на C++, я настоятельно рекомендую оценить Boost C++ Libraries. Одна из библиотек — Thread, предоставляющая общий интерфейс для переносимой многопоточности.

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

Предварительные шаги

Прежде чем начать, есть несколько обязательных шагов перед стартом любого кода с pthreads:

  1. Добавьте #include <pthread.h> в ваши файлы исходного кода.
  2. Если вы используете gcc, просто укажите -pthread — это задаст все нужные определения и библиотеки линковки. С другими компиляторами, возможно, придётся определить _REENTRANT и линковаться с -lpthread.
  3. Опционально: некоторые компиляторы могут требовать определения _POSIX_PTHREAD_SEMANTICS для определённых вызовов функций, вроде sigwait().

Создание pthreads

Поток pthread представлен типом pthread_t. Для создания потока доступна следующая функция:

Разберём аргументы, требуемые для pthread_create():

  1. pthread_t *thread: собственно объект потока, содержащий pthread id
  2. pthread_attr_t *attr: атрибуты, применяемые к этому потоку
  3. void *(*start_routine)(void *): функция, которую этот поток выполняет
  4. void *arg: аргументы, передаваемые потоковой функции выше

Прежде чем перейти к примеру, посмотрим на две другие важные функции потоков:

pthread_exit() завершает поток и делает указатель *value_ptr доступным любому вызову pthread_join().

pthread_join() приостанавливает вызывающий поток в ожидании успешного завершения потока, указанного первым аргументом pthread_t thread, с опциональными данными *value_ptr, переданными из вызова pthread_exit() завершающегося потока.

Рассмотрим пример программы, задействующей вышеуказанные функции pthread:

Эта программа создаёт NUM_THREADS потоков и выводит их назначенные пользователем id. Первое, что стоит заметить, — вызов pthread_create() в функции main. Синтаксис третьего и четвёртого аргументов особенно важен. Обратите внимание, что thr_func — имя потоковой функции, а четвёртый аргумент — аргумент, передаваемый этой функции. Здесь мы передаём аргумент потоковой функции, созданный нами как структура thread_data_t. Конечно, можно передавать простые типы данных как указатели, если этого достаточно, или NULL, если аргументы не нужны. Однако хорошая практика — уметь передавать аргументы произвольного типа и размера, что и продемонстрировано для этой цели.

Несколько замечаний:

  • Обязательно проверяйте возвращаемые значения всех важных функций.
  • Второй аргумент pthread_create()NULL, что означает создание потока с атрибутами по умолчанию. Значения по умолчанию зависят от системы и реализации pthread.
  • Заметьте, что мы разнесли pthread_join() и pthread_create(). Почему не стоит встраивать pthread_join() в цикл создания потоков?
  • Хотя явно вызывать pthread_exit() в конце потоковой функции не требуется, это хорошая практика, так как может возникнуть необходимость вернуть произвольные данные вызывающему через pthread_join().

Атрибуты pthread

Потокам можно назначать различные атрибуты во время создания. Это управляется вторым аргументом pthread_create(). Сначала нужно передать переменную pthread_attr_t через:

Некоторые атрибуты, которые можно установить:

Атрибуты можно получить через парные функции get. Обратитесь к man-страницам за описанием эффекта каждого из этих атрибутов.

Мьютексы pthread

Мьютексы pthread создаются следующей функцией:

Функции pthread_mutex_init() требуется переменная pthread_mutex_t для работы как первый аргумент. Атрибуты мьютекса можно передать через второй параметр. Для атрибутов по умолчанию передайте NULL вторым параметром. Альтернативно, мьютексы можно инициализировать значениями по умолчанию через удобный макрос, а не вызов функции:

Здесь объект мьютекс с именем lock инициализируется значениями pthread-мьютекса по умолчанию.

Для блокировки и разблокировки мьютексов pthreads предоставляет следующие функции:

Каждый из этих вызовов требует ссылки на объект мьютекса. Разница между lock и trylock в том, что lock блокирует, а trylock неблокирующий и вернётся немедленно, даже если получение мьютекса не удалось из-за того, что он уже захвачен/заблокирован. Абсолютно необходимо проверять возвращаемое значение вызова trylock, чтобы определить, успешно ли захвачен мьютекс. Если нет, будет возвращён код ошибки EBUSY.

Расширим предыдущий пример кодом с мьютексами:

В примере выше мы добавляем разделяемые данные shared_x и обеспечиваем сериализованный доступ к этой переменной через мьютекс с именем lock_x. Внутри thr_func() мы вызываем pthread_mutex_lock() перед чтением или изменением разделяемых данных. Заметьте, что мы продолжаем держать замок даже во время вызова printf(), так как освобождение замка до вывода может привести к несогласованным результатам в выводе. Вспомним, что код между вызовами lock и unlock называется критической секцией. Критические секции следует минимизировать для большей конкурентности.

Переменные состояния pthread

Переменные состояния pthread создаются следующим вызовом функции или инициализирующим макросом, аналогично мьютексам:

Как и в случае с вызовом инициализации мьютекса, переменным состояния можно задать нестандартные атрибуты через второй параметр. Для значений по умолчанию используйте инициализирующий макрос или укажите NULL во втором параметре вызова pthread_cond_init().

Потоки могут действовать с переменными состояния тремя способами: wait, signal или broadcast:

pthread_cond_wait() усыпляет текущий поток. Ему требуется мьютекс связанного значения разделяемого ресурса, на котором он ждёт. pthread_cond_signal() сигналит одному потоку из возможно многих спящих потоков проснуться. pthread_cond_broadcast() сигналит всем потокам, ждущим на переменной состояния cond, проснуться. Вот пример использования переменных состояния pthread:

В thr_func1() мы берём мьютекс count_lock, чтобы прочитать значение count без входа в потенциальное состояние гонки. Последующий pthread_cond_wait() также требует заблокированный мьютекс вторым параметром, чтобы избежать гонки, где поток готовится ждать на переменной состояния, а другой поток сигналит условие как раз перед тем, как первый поток реально начнёт ждать (как объясняется в man-странице pthread_cond_wait). Обратите внимание, как используется цикл while вместо оператора if для вызова pthread_cond_wait(). Это из-за проблемы ложных пробуждений, упомянутой ранее. Если поток был пробуждён, это не значит, что это было из-за вызова pthread_cond_signal() или pthread_cond_broadcast(). pthread_cond_wait(), если пробуждён, автоматически пытается повторно захватить мьютекс и заблокируется, если не сможет. Замки, на которых могут ждать другие потоки, следует освобождать до того, как вы делаете signal или broadcast.

Барьеры pthread

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

Объекты барьера инициализируются как мьютексы или переменные состояния, за исключением одного дополнительного параметра — count. Переменная count определяет число потоков, которые должны присоединиться к барьеру, чтобы барьер завершился и разблокировал все потоки, ждущие на барьере. Если используются атрибуты барьера по умолчанию (то есть NULL вторым параметром), можно использовать инициализирующий макрос с указанным count.

Сам вызов барьера таков:

Эта функция должна находиться внутри кода потока, где барьер должен сработать. Как только count потоков вызовут pthread_barrier_wait(), условие барьера выполнено, все потоки разблокированы и выполнение продолжается.

Разное

Вот несколько советов и вопросов, которые стоит рассмотреть при использовании pthreads:

  • Проверяйте все возвращаемые значения важных вызовов pthread!
  • Иногда желательно, чтобы поток не завершался (например, сервер с пулом рабочих потоков). Это решается размещением кода потока в бесконечном цикле и использованием переменных состояния. Конечно, должны быть некоторые условия завершения бесконечного цикла (то есть break, когда это сочтётся нужным).

Дополнительные полезные вызовы pthread:

  • pthread_kill() может использоваться для доставки сигналов конкретным потокам.
  • pthread_self() возвращает дескриптор вызывающего потока.
  • pthread_equal() сравнивает на равенство два pthread id.
  • pthread_once() может использоваться, чтобы гарантировать, что инициализирующая функция внутри потока выполняется только один раз.
  • В библиотеке pthread есть ещё много полезных функций. Обратитесь к man-страницам pthreads или книге Николса (Приложение C).

Соображения производительности

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

  • Гранулярность замков — насколько «большими» (грубыми) или «маленькими» (тонкими) являются ваши мьютексы? Блокируют ли они всю структуру или поля структуры? Чем тоньше замки, тем больше конкурентности можно получить, но ценой больших накладных расходов и потенциальных deadlock.
  • Порядок блокировки — следите, чтобы замки всегда брались в согласованном порядке (если нет — предпринимайте шаги для исправления ситуаций, когда замки захватываются в неправильном порядке, например, вызовами trylock/unlock).
  • Частота блокировки — блокируете слишком часто? Блокируете в ненужные моменты? Сокращайте такие случаи, чтобы полностью использовать конкурентность и снизить накладные расходы синхронизации.
  • Критические секции — это уже упоминалось, но предпринимайте дополнительные шаги для минимизации критических секций, которые могут быть потенциально большими узкими местами.
  • Пул рабочих потоков — если вы используете модель Boss/Worker, выделяйте потоки заранее, а не создавайте по требованию. Пользователю не важно, как долго ваш сервер инициализировался — важно, как быстро он обрабатывает его запрос!
  • Область конкуренции — ваши потоки работают лучше, когда конкурируют со всеми процессами системы? Или когда их индивидуально планирует сама библиотека потоков? Ответ может дать только эксперимент.
  • Класс планирования — мы не касались этой темы, но смена класса планирования потока с FIFO на RR может дать лучшие времена отклика. Но правда ли это то, чего вы хотите? Обратитесь к книгам Николса или Льюиса за дополнительной информацией о классах планирования потоков.
  • Слишком много потоков? — в какой момент потоков становится слишком много? Может ли это серьёзно влиять и снижать производительность? Опять же, только эксперимент даст реальные ответы на этот вопрос.

Другие подходы

Библиотеки шаблонов C++

Есть различные библиотеки шаблонов, облегчающие реализацию многопоточности (полу)переносимым образом. Тем, кто программирует на C++, стоит посмотреть Boost, Intel Threading Building Blocks (TBB) и POCO.

Мультипроцессность и разделяемая память

Этот учебник исследовал лишь основы многопоточного программирования. А как насчёт мультипроцессного программирования?

Эти темы выходят за рамки документа, но для синхронизации между процессами используется та или иная форма IPC: пайпы, семафоры, очереди сообщений или разделяемая память. Из всех форм IPC разделяемая память обычно самая быстрая (исключая doors). Можно использовать семантику mmap(), POSIX (например, shm_open()) или SysV (например, shmget()) при работе с межпроцессным управлением ресурсами, IPC и синхронизацией. Тем, кто интересуется программированием разделяемой памяти на C++, рекомендую сначала посмотреть Boost.Interprocess.

OpenMP

OpenMP — переносимый интерфейс для реализации fork-join параллелизма на многопроцессорных машинах с разделяемой памятью. Он доступен для C/C++ и Fortran. Для быстрого введения смотрите слайды здесь.

MPI

Message Passing Interface (MPI) — де-факто стандарт для параллельной обработки с распределённой памятью. Данные можно отправлять/принимать с отдельных вычислительных машин с поддержкой векторизованного ввода-вывода (scatter/gather), синхронизации и коллективных операций.

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

Ресурсы

Трудно покрыть больше, чем введение в потоки, этим кратким учебником и обзором. Для более глубокого изучения потоков (таких как классы планирования потоков, специфичные для потока данные (thread local storage), отмена потоков, обработка сигналов и замки читатель/писатель) и программирования pthreads рекомендую эти книги:

  • Lewis, Bill and Daniel J. Berg. Multithreaded Programming with Pthreads. California: Prentice Hall, 1998.
  • Nichols, Bradford, et. al. Pthreads Programming. Beijing: O'Reilly & Associates, Inc., 1998.

В сети есть много отличных ресурсов о pthreads. Используйте любимую поисковую систему, чтобы их найти.


threads posix linux