Архитектуры многопоточных приложений. Почему они не умеют писать многопоточные программы Разработка многопоточного приложения

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

Итак, что же это за правила

Иной программист, столкнувшись с проблемой, думает: «А, точно, надо применить регулярные выражения ». И вот у него уже две проблемы - Джейми Завински.

Иной программист, столкнувшись с проблемой, думает: «А, точно, применю-ка я здесь потоки». И вот у него уже десять проблем - Билл Шиндлер.

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

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

Возможно, вы задавались вопросом: а почему это происходит? Распространено такое ошибочное мнение: «Многопоточное программирование очень сложное». Но это не так. Если многопоточная программа ненадежна, то она обычно барахлит по тем же причинам, что и некачественные однопоточные программы. Просто программист не следует основополагающим, давно известным и проверенным методам разработки. Многопоточные программы лишь кажутся более сложными, так как чем больше параллельных потоков работают неправильно, тем больший беспорядок они учиняют - и гораздо быстрее, чем это сделал бы один поток.

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

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

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

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

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

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

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

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

Проблемы глобального изменяемого состояния постепенно стали очевидными в конце 80-х и начале 90-х, с распространением событийно-ориентированного программирования. Программы больше не начинались «с начала» и не проходили единственный предсказуемый путь выполнения «до конца». У современных программ есть исходное состояние, после выхода из которого в них происходят события - в непредсказуемом порядке, с переменными временными интервалами. Код остается однопоточным, но уже становится асинхронным. Вероятность искажения данных возрастает именно потому, что порядок возникновения событий очень важен. Сплошь и рядом встречаются ситуации такого рода: если событие B происходит после события A, то все работает нормально. Но если событие A произойдет после события B, а между ними успеет вклиниться событие C, то данные могут быть искажены до неузнаваемости.

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

Многопоточная программа с обширным глобальным изменяемым состоянием - это один из наиболее красноречивых известных мне примеров принципа неопределенности Гейзенберга. Невозможно проверить состояние программы, не изменив при этом ее поведение.

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

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

Кто-то может возразить, что иногда нет времени на такое филигранное проектирование программы, которое позволит обойтись без глобального состояния. Я же считаю, что на это можно и нужно тратить время. Ничто не сказывается на многопоточных программах так губительно, как попытки справиться с глобальным изменяемым состоянием. Чем большим количеством деталей приходится управлять, тем выше вероятность, что ваша программа войдет в пике и рухнет.

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

Количество проблем, которые могут возникнуть при такой единой блокировке, просто ошеломляет. Необходимо учесть и условия гонки, и проблемы пропускания (gating problems) при чрезмерно обширной блокировки, и вопросы, связанные со справедливостью распределения - вот лишь несколько примеров. Если же у вас несколько блокировок, в особенности если они вложенные, то также придется принять меры против взаимной блокировки, динамической взаимной блокировки, очередей на блокировку, а также исключить другие угрозы, связанные с параллелизмом. К тому же существуют и характерные проблемы одиночной блокировки.
Когда я пишу или проверяю код, я руководствуюсь практически безотказным железным правилом: если вы сделали блокировку, то, по-видимому, где-то допустили ошибку .

Это утверждение можно прокомментировать двумя способами:

  1. Если вам понадобилась блокировка, то, вероятно, у вас присутствует глобальное изменяемое состояние, которое требуется защитить от параллельных обновлений. Наличие глобального изменяемого состояния - это недоработка, допущенная на этапе проектирования приложения. Пересмотрите и измените дизайн.
  2. Правильно пользоваться блокировками нелегко, а локализовать баги, связанные с блокировкой, бывает невероятно сложно. Весьма вероятно, что вы будете использовать блокировку неправильно. Если я вижу блокировку, а программа при этом необычно себя ведет, то я первым делом проверяю код, зависящий от блокировки. И обычно нахожу в нем проблемы.

Обе эти интерпретации корректны.

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

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

Большинство того, что вы знаете о многопоточности, не имеет значения

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

Представьте себе, что вы рассказали подростку, как самому собрать двигатель внутреннего сгорания. Затем без всякого обучения вождению вы сажаете его за руль автомобиля и говорите: «Езжай»! Подросток понимает, как работает машина, но не имеет ни малейшего представления о том, как добраться на ней из точки A в точку B.

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

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

Зачастую такие программы отлично вписываются в модель «поставщик-потребитель», требующую применения всего трех потоков:

  • поток ввода считывает данные и помещает их в очередь ввода;
  • рабочий поток считывает записи из очереди ввода, обрабатывает их и помещает результаты в очередь вывода;
  • поток вывода считывает записи из очереди вывода и сохраняет их.

Три этих потока работают независимо, коммуникация между ними происходит на уровне очередей.

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

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

Но самое важное заключается в том, что современные языки программирования и библиотеки очень упрощают создание приложений по модели «производитель-потребитель». В .NET вы найдете параллельные коллекции и библиотеку TPL Dataflow. В Java есть сервис Executor, а также BlockingQueue и другие классы из пространства имен java.util.concurrent. В С++ есть библиотека Boost для работы с потоками и библиотека Thread Building Blocks от Intel. В Visual Studio 2013 от Microsoft появились асинхронные агенты. Подобные библиотеки также имеются в Python, JavaScript, Ruby, PHP и, насколько мне известно, во многих других языках. Вы сможете создать приложение вида «производитель-потребитель» при помощи любого из этих пакетов, ни разу не прибегая к блокировкам, семафорам, условным переменным или каким-либо другим синхронизационным примитивам.

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

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

Работайте с библиотеками

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

Современным разработчикам приходится решать массу задач на уровне программирования приложений, бывает, что просто некогда задумываться о том, что происходит на системном уровне. Чем затейливее становятся приложения, тем более сложные детали приходится скрывать между уровнями API. Мы занимаемся этим уже не один десяток лет. Можно утверждать, что качественное скрытие сложности системы от программиста - основная причина, по которой программисту удается писать современные приложения. Если уж на то пошло - разве мы не скрываем сложность системы, реализуя цикл сообщений пользовательского интерфейса, выстраивая низкоуровневые протоколы обмена информацией и т.д.?

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

В более ранних постах было рассказано про многопоточность в 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.

Мьютексы

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

#include
#include
#include
#include

Std:: mutex mtx;
static int counter = 0 ;


for (;; ) {
{
std:: lock_guard < std:: mutex > lock(mtx) ;

break ;
int ctr_val = ++ counter;
std:: cout << "Thread " << tnum << ": counter = " <<
ctr_val << std:: endl ;
}

}
}

int main() {
std:: vector < std:: thread > threads;
for (int i = 0 ; i < 10 ; i++ ) {


}

// can"t use const auto& here since .join() is not marked const

thr.join () ;
}

Std:: cout << "Done!" << std:: endl ;
return 0 ;
}

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

RWLock

Нередко возникает ситуация, в которой доступ к объекту чаще происходит на чтение, чем на запись. В этом случае вместо обычного мьютекса эффективнее использовать read-write lock, он же RWLock. RWLock может быть захвачен сразу несколькими потоками на чтение, или только одним потоком на запись. RWLock’у в C++ соответствуют классы std::shared_mutex и std::shared_timed_mutex:

#include
#include
#include
#include

// 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 ;

void thread_proc(int tnum) {
for (;; ) {
{
// see also std::shared_lock
std:: unique_lock < std:: shared_timed_mutex > lock(mtx) ;
if (counter == MAX_COUNTER_VAL)
break ;
int ctr_val = ++ counter;
std:: cout << "Thread " << tnum << ": counter = " <<
ctr_val << std:: endl ;
}
std:: this_thread :: sleep_for (std:: chrono :: milliseconds (10 ) ) ;
}
}

int main() {
std:: vector < std:: thread > threads;
for (int i = 0 ; i < 10 ; i++ ) {
std:: thread thr(thread_proc, i) ;
threads.emplace_back (std:: move (thr) ) ;
}

for (auto & thr : threads) {
thr.join () ;
}

Std:: cout << "Done!" << std:: endl ;
return 0 ;
}

По аналогии с std::lock_guard для захвата RWLock’а используются классы std::unique_lock и std::shared_lock, в зависимости от того, как мы хотим захватить лок. Класс std::shared_timed_mutex появился в C++14 и работает на всех* современных платформах (не скажу за мобильные устройства, игровые консоли, и так далее). В отличие от std::shared_mutex , он имеет методы try_lock_for, try_lock_unti и другие, которые пытаются захватить мьютекс в течение заданного времени. Я сильно подозреваю, что std::shared_mutex должен быть дешевле std::shared_timed_mutex. Однако std::shared_mutex появился только в C++17, а значит поддерживается не везде. В частности, все еще широко используемый GCC 5.4 про него не знает.

Thread Local Storage

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

#include
#include
#include
#include

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

void thread_proc(int tnum) {
for (;; ) {
counter++ ;
if (counter == MAX_COUNTER_VAL)
break ;
{
std:: lock_guard < std:: mutex > lock(io_mtx) ;
std:: cout << "Thread " << tnum << ": counter = " <<
counter << std:: endl ;
}
std:: this_thread :: sleep_for (std:: chrono :: milliseconds (10 ) ) ;
}
}

int main() {
std:: vector < std:: thread > threads;
for (int i = 0 ; i < 10 ; i++ ) {
std:: thread thr(thread_proc, i) ;
threads.emplace_back (std:: move (thr) ) ;
}

for (auto & thr : threads) {
thr.join () ;
}

Std:: cout << "Done!" << std:: endl ;
return 0 ;
}

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

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

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

Пример кода:

#include
#include
#include
#include
#include

static std:: atomic_int atomic_counter(0 ) ;
static const int MAX_COUNTER_VAL = 100 ;

Std:: mutex io_mtx;

void thread_proc(int tnum) {
for (;; ) {
{
int ctr_val = ++ atomic_counter;
if (ctr_val >= MAX_COUNTER_VAL)
break ;

{
std:: lock_guard < std:: mutex > lock(io_mtx) ;
std:: cout << "Thread " << tnum << ": counter = " <<
ctr_val << std:: endl ;
}
}
std:: this_thread :: sleep_for (std:: chrono :: milliseconds (10 ) ) ;
}
}

int main() {
std:: vector < std:: thread > threads;

int nthreads = std:: thread :: hardware_concurrency () ;
if (nthreads == 0 ) nthreads = 2 ;

for (int i = 0 ; i < nthreads; i++ ) {
std:: thread thr(thread_proc, i) ;
threads.emplace_back (std:: move (thr) ) ;
}

for (auto & thr : threads) {
thr.join () ;
}

Std:: cout << "Done!" << std:: endl ;
return 0 ;
}

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

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

Заключение

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

За кадром осталось еще немало примитивов: std::condition_variable(_any), std::(shared_)future, std::promise, std::sync и другие. Для ознакомления с ними я рекомендую сайт cppreference.com . Также может иметь смысл прочитать книгу C++ Concurrency in Action . Но должен предупредить, что она уже не новая, содержит многовато воды, и в сущности пересказывает десяток статей с cppreference.com.

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

конец файла . Таким образом, записи в логе, выполняемые разными процессами, никогда несмешиваются. В более современныхUnix-системах для ведения логов предоставляется специальный сервис syslog(3C) .

Преимущества:

  1. Простота разработки. Фактически, мы запускаем много копий однопоточного приложения и они работают независимо друг от друга. Можно не использовать никаких специфически многопоточных API и средств межпроцессного взаимодействия .
  2. Высокая надежность. Аварийное завершение любого из процессов никак не затрагивает остальные процессы.
  3. Хорошая переносимость. Приложение будет работать налюбой многозадачной ОС
  4. Высокая безопасность. Разные процессы приложения могут запускаться от имени разных пользователей. Таким образом можно реализовать принцип минимальных привилегий, когда каждый из процессов имеет лишь те права, которые необходимы ему для работы. Даже если в каком-то из процессов будет обнаружена ошибка, допускающая удаленное исполнение кода, взломщик сможет получить лишь уровень доступа, с которым исполнялся этот процесс.

Недостатки:

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

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

Примеры: apache 1.x ( сервер HTTP )

Многопроцессные приложения, взаимодействующие через сокеты, трубы и очереди сообщений System V IPC

Перечисленные средства IPC ( Interprocess communication ) относятся к так называемым средствам гармонического межпроцессного взаимодействия. Онипозволяют организовать взаимодействие процессов и потоков без использования разделяемой памяти. Теоретики программирования очень любят эту архитектуру, потому что она практически исключает многие варианты ошибок соревнования.

Преимущества:

  1. Относительная простота разработки.
  2. Высокая надежность. Аварийное завершение одного из процессов приводит к закрытию трубы или сокета, а в случае очередей сообщений – к тому, что сообщения перестают поступать в очередь или извлекаться из нее. Остальные процессы приложения легко могут обнаружить эту ошибку и восстановиться после нее, возможно (но не обязательно) просто перезапустив отказавший процесс.
  3. Многие такие приложения (особенно основанные на использовании сокетов) легко переделываются для исполненияв распределенной среде, когда разные компоненты приложения исполняются на разных машинах.
  4. Хорошая переносимость. Приложение будет работать на большинстве многозадачных ОС, в том числе на старых Unix-системах.
  5. Высокая безопасность. Разные процессы приложения могут запускаться от имени разных пользователей. Таким образом можно реализовать принцип минимальных привилегий, когда каждый из процессов имеет лишь те права, которые необходимы ему для работы.

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

Недостатки:

  1. Не для всех прикладных задач такую архитектуру легко разработать и реализовать.
  2. Все перечисленные типы средств IPC предполагают последовательную передачу данных. Если необходим произвольный доступ к разделяемым данным, такая архитектура неудобна.
  3. Передача данных через трубу, сокет и очередь сообщений требует исполнения системных вызовов и двойного копирования данных – сначала из адресного пространства исходного процесса в адресное пространство ядра, затем из адресного пространства ядра в память целевого процесса . Это дорогие операции. При передаче больших объемов данных это может превратиться в серьезную проблему.
  4. В большинстве систем действуют ограничения на общее количество труб, сокетов и средств IPC. Так, в Solaris по умолчанию допускается не более 1024 открытых труб, сокетов и файлов на процесс (это обусловлено ограничениями системного вызова select). Архитектурное ограничение Solaris – 65536 труб, сокетов и файлов на процесс.

    Ограничение на общее количество сокетов TCP/IP – не более 65536 на сетевой интерфейс (обусловлено форматом заголовков TCP). Очереди сообщений System V IPC размещаются вадресном пространствеядра, поэтому действуют жесткиеограничения на количество очередей в системе и на объем и количество одновременно находящихся в очередях сообщений.

  5. Создание и уничтожение процесса, а также переключение между процессами – дорогие операции. Не во всех случаях такая архитектура оптимальна.

Многопроцессные приложения, взаимодействующие через разделяемую память

В качестве разделяемой памяти может использоваться разделяемая память System V IPC и отображение файлов на память . Для синхронизации доступа можно использовать семафоры System V IPC , мутексы и семафоры POSIX , при отображении файлов на память – захват участков файла.

Преимущества:

  1. Эффективный произвольный доступ к разделяемым данным. Такая архитектура пригодна для реализации серверов баз данных.
  2. Высокая переносимость. Может быть перенесено налюбую операционную систему, поддерживающую или эмулирующую System V IPC .
  3. Относительно высокая безопасность. Разные процессыприложениямогут запускаться от имени разных пользователей. Таким образом можно реализовать принцип минимальных привилегий, когда каждый из процессов имеет лишь те права, которые необходимы ему для работы. Однако разделение уровней доступа не такое жесткое, как в ранее рассмотренных архитектурах.

Недостатки:

  1. Относительная сложность разработки. Ошибки при синхронизации доступа – так называемые ошибки соревнования – очень сложно обнаруживать при тестировании.

    Это может привести к повышению общей стоимости разработки в 3–5 раз по сравнению с однопоточными или более простыми многозадачными архитектурами.

  2. Низкая надежность. Аварийное завершение любого из процессов приложения может оставить (и часто оставляет) разделяемую память в несогласованном состоянии.

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

  3. Создание и уничтожение процесса и переключение между ними – дорогие операции.

    Поэтому данная архитектура оптимальна не для всех приложений.

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

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

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

Фактически, данная архитектура сочетает недостатки многопроцессных и собственно многопоточных приложений. Тем не менее, ряд популярных приложений, разработанных в 80е и начале 90х, до того, как в Unix были стандартизованы многопоточные API , используют эту архитектуру. Это многие серверы баз данных, как коммерческие ( Oracle , DB2 , Lotus Domino), такисвободно распространяемые,современные версии Sendmail инекоторые другие почтовые серверы.

Собственно многопоточные приложения

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

Поэтому потоки должны использовать специальные средства для организации взаимодействия. Наиболее важные средства – это примитивы взаимоисключения (мутексы и блокировки чтения-записи). Используя эти примитивы, программист может добиться того, чтобы ни один поток не обращался к разделяемым ресурсам, пока они находятся в несогласованном состоянии (это и называется взаимоисключением). System V IPC , разделяются только те структуры, которые размещены в сегменте разделяемой памяти. Обычные переменные и размещаемые обычным образом динамические структуры данных свои укаждого изпроцессов). Ошибки придоступекразделяемым данным – ошибки соревнования – очень сложно обнаруживать при тестировании.

  • Высокая стоимость разработки и отладки приложений, обусловленная п. 1.
  • Низкая надежность. Разрушение структур данных, например в результате переполнения буфера или ошибок работы с указателями, затрагивает все нити процесса и обычно приводит к аварийному завершению всего процесса. Другие фатальные ошибки, например, деление на ноль в одной из нитей, также обычно приводят к аварийной остановке всех нитей процесса.
  • Низкая безопасность. Все нити приложения исполняются в одном процессе, то есть от имени одного и того же пользователя и с одними и теми же правами доступа. Невозможно реализовать принцип минимума необходимых привилегий, процесс должен исполняться от имени пользователя, который может исполнять все операции, необходимые всем нитям приложения.
  • Создание нити – все-таки довольно дорогая операция. Для каждой нити в обязательном порядке выделяется свой стек, который по умолчанию занимает 1 мегабайт ОЗУ на 32битных архитектурах и 2 мегабайта на 64-битных архитектурах, и некоторые другие ресурсы. Поэтому данная архитектура оптимальна не для всех приложений.
  • Невозможность исполнять приложение на многомашинном вычислительном комплексе. Упоминавшиеся в предыдущем разделе приемы, такие, как отображение на память разделяемых файлов, для многопоточной программы не применимы.
  • В целом можно сказать, что многопоточные приложения имеют почти те же преимущества и недостатки, что и многопроцессные приложения, использующие разделяемую память .

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    Public class PingPongThread extends Thread{ PingPongThread(String name){ this.setName(name); // переопределяем имя потока } @Override public void run() { Ball ball = Ball.getBall(); while(ball.isInGame()){ kickBall(ball); } } private void kickBall(Ball ball) { if(!ball.getSide().equals(getName())){ ball.kick(getName()); } } }

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

    Public class Ball { private int kicks = 0; private static Ball instance = new Ball(); private String side = ""; private Ball(){} static Ball getBall(){ return instance; } synchronized void kick(String playername){ kicks++; side = playername; System.out.println(kicks + " " + side); } String getSide(){ return side; } boolean isInGame(){ return (kicks < 15); } }

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

    Public class PingPongGame { PingPongThread player1 = new PingPongThread("Ping"); PingPongThread player2 = new PingPongThread("Pong"); Ball ball; PingPongGame(){ ball = Ball.getBall(); } void startGame() throws InterruptedException { player1.start(); player2.start(); } }

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

    Public class PingPong { public static void main(String args) throws InterruptedException { PingPongGame game = new PingPongGame(); game.startGame(); } }

    Как видите, ничего зубодробительного здесь нет. Это пока только введение в многопоточность, но вы уже представляете, как это работает, и можете экспериментировать - ограничивать длительность игры не числом ударов, а по времени, например. Мы ещё вернёмся к теме многопоточности - рассмотрим пакет java.util.concurrent, библиотеку Akka и механизм volatile. А еще поговорим о реализации многопоточности на Python.

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

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

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

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

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

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

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

    Любой поток состоит из двух компонентов:

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

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

    1. Поток (thread) определяет последовательность исполнения кода в процессе.

    2. Процесс ничего не исполняет, он просто служит контейнером потоков.

    3. Потоки всегда создаются в контексте какого-либо процесса, и вся их жизнь проходит только в его границах.

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

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

    Многозада́чность (англ. multitasking ) - свойство операционной системы или среды программирования обеспечивать возможность параллельной (или псевдопараллельной) обработки нескольких процессов. Истинная многозадачность операционной системы возможна только в распределённых вычислительных системах.

    Файл:Screenshot of Debian (Release 7.1, "Wheezy") running the GNOME desktop environment, Firefox, Tor, and VLC Player.jpg

    Рабочий стол современной операционной системы, отражающий активность нескольких процессов.

    Существует 2 типа многозадачности :

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

    · Поточная многозадачность (основанная на потоках). Наименьший элемент управляемого кода - поток (одна программа может выполнять 2 и более задачи одновременно).

    Многопоточность - специализированная форма многозадачности .

    · 1 Свойства многозадачной среды

    · 2 Трудности реализации многозадачной среды

    · 3 История многозадачных операционных систем

    · 4 Типы псевдопараллельной многозадачности

    o 4.1 Невытесняющая многозадачность

    o 4.2 Совместная или кооперативная многозадачность

    o 4.3 Вытесняющая или приоритетная многозадачность (режим реального времени)

    · 5 Проблемные ситуации в многозадачных системах

    o 5.1 Голодание (starvation)

    o 5.2 Гонка (race condition)

    · 7 Примечания

    Свойства многозадачной среды[править | править исходный текст]

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

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

    · Каждая задача имеет свой приоритет, в соответствии с которым получает процессорное время и память

    · Система организует очереди задач так, чтобы все задачи получили ресурсы, в зависимости от приоритетов и стратегии системы

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

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

    · Система обеспечивает защиту адресного пространства задачи от несанкционированного вмешательства других задач

    · Система обеспечивает защиту адресного пространства своего ядра от несанкционированного вмешательства задач

    · Система распознаёт сбои и зависания отдельных задач и прекращает их

    · Система решает конфликты доступа к ресурсам и устройствам, не допуская тупиковых ситуаций общего зависания от ожидания заблокированных ресурсов

    · Система гарантирует каждой задаче, что рано или поздно она будет активирована

    · Система обрабатывает запросы реального времени

    · Система обеспечивает коммуникацию между процессами

    Трудности реализации многозадачной среды[править | править исходный текст]

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

    Кроме надёжности, многозадачная среда должна быть эффективной. Затраты ресурсов на её поддержание не должны: мешать процессам проходить, замедлять их работу, резко ограничивать память.

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

    Такие потоки называют также потоками выполнения (от англ. thread of execution ); иногда называют «нитями» (буквальный перевод англ. thread ) или неформально «тредами».

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

    Многопоточность (как доктрину программирования) не следует путать ни с многозадачностью, ни с многопроцессорностью, несмотря на то, что операционные системы, реализующие многозадачность, как правило реализуют и многопоточность.

    К достоинствам многопоточности в программировании можно отнести следующее:

    · Упрощение программы в некоторых случаях за счет использования общего адресного пространства.

    · Меньшие относительно процесса временны́е затраты на создание потока.

    · Повышение производительности процесса за счет распараллеливания процессорных вычислений и операций ввода-вывода.

    · 1 Типы реализации потоков

    · 2 Взаимодействие потоков

    · 3 Критика терминологии

    · 6 Примечания

    Типы реализации потоков[править | править исходный текст]

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

    Достоинства и недостатки этого типа следующие: Недостатки

    1. Отсутствие прерывания по таймеру внутри одного процесса

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

    3. Сложность реализации

    · Поток в пространстве ядра. Наряду с таблицей процессов в пространстве ядра имеется таблица потоков.

    · «Волокна» (англ. fibers ). Несколько потоков режима пользователя, исполняющихся в одном потоке режима ядра. Поток пространства ядра потребляет заметные ресурсы, в первую очередь физическую память и диапазон адресов режима ядра для стека режима ядра. Поэтому было введено понятие «волокна» - облегчённого потока, выполняемого исключительно в режиме пользователя. У каждого потока может быть несколько «волокон».

    Взаимодействие потоков[править | править исходный текст]

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

    · Взаимоисключения (mutex, мьютекс) - это объект синхронизации, который устанавливается в особое сигнальное состояние, когда не занят каким-либо потоком. Только один поток владеет этим объектом в любой момент времени, отсюда и название таких объектов (от английского mut ually ex clusive access - взаимно исключающий доступ) - одновременный доступ к общему ресурсу исключается. После всех необходимых действий мьютекс освобождается, предоставляя другим потокам доступ к общему ресурсу. Объект может поддерживать рекурсивный захват второй раз тем же потоком, увеличивая счетчик, не блокируя поток, и требуя потом многократного освобождения. Такова, например, критическая секция в Win32. Тем не менее есть и такие реализации, которые не поддерживают такое и приводят к взаимной блокировке потока при попытке рекурсивного захвата. Это FAST_MUTEX в ядре Windows.

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

    · События. Объект, хранящий в себе 1 бит информации «просигнализирован или нет», над которым определены операции «просигнализировать», «сбросить в непросигнализированное состояние» и «ожидать». Ожидание на просигнализированном событии есть отсутствие операции с немедленным продолжением исполнения потока. Ожидание на непросигнализированном событии приводит к приостановке исполнения потока до тех пор, пока другой поток (или же вторая фаза обработчика прерывания в ядре ОС) не просигнализирует событие. Возможно ожидание нескольких событий в режимах «любого» или «всех». Возможно также создания события, автоматически сбрасываемого в непросигнализированное состояние после пробуждения первого же - и единственного - ожидающего потока (такой объект используется как основа для реализации объекта «критическая секция»). Активно используются в MS Windows, как в режиме пользователя, так и в режиме ядра. Аналогичный объект имеется и в ядре Linux под названием kwait_queue.

    · Критические секции обеспечивают синхронизацию подобно мьютексам за исключением того, что объекты, представляющие критические секции, доступны в пределах одного процесса. События, мьютексы и семафоры также можно использовать в однопроцессном приложении, однако реализации критических секций в некоторых ОС (например, Windows NT) обеспечивают более быстрый и более эффективный механизм взаимно-исключающей синхронизации - операции «получить» и «освободить» на критической секции оптимизированы для случая единственного потока (отсутствия конкуренции) с целью избежать любых ведущих в ядро ОС системных вызовов. Подобно мьютексам объект, представляющий критическую секцию, может использоваться только одним потоком в данный момент времени, что делает их крайне полезными при разграничении доступа к общим ресурсам.

    · Условные переменные (condvars). Сходны с событиями, но не являются объектами, занимающими память - используется только адрес переменной, понятие «содержимое переменной» не существует, в качестве условной переменной может использоваться адрес произвольного объекта. В отличие от событий, установка условной переменной в просигнализированное состояние не влечет за собой никаких последствий в случае, если на данный момент нет потоков, ожидающих на переменной. Установка события в аналогичном случае влечет за собой запоминание состояния «просигнализировано» внутри самого события, после чего следующие потоки, желающие ожидать события, продолжают исполнение немедленно без остановки. Для полноценного использования такого объекта необходима также операция «освободить mutex и ожидать условную переменную атомарно». Активно используются в UNIX-подобных ОС. Дискуссии о преимуществах и недостатках событий и условных переменных являются заметной частью дискуссий о преимуществах и недостатках Windows и UNIX.

    · Порт завершения ввода-вывода (IO completion port, IOCP). Реализованный в ядре ОС и доступный через системные вызовы объект «очередь» с операциями «поместить структуру в хвост очереди» и «взять следующую структуру с головы очереди» - последний вызов приостанавливает исполнение потока в случае, если очередь пуста, и до тех пор, пока другой поток не осуществит вызов «поместить». Самой важной особенностью IOCP является то, что структуры в него могут помещаться не только явным системным вызовом из режима пользователя, но и неявно внутри ядра ОС как результат завершения асинхронной операции ввода-вывода на одном из дескрипторов файлов. Для достижения такого эффекта необходимо использовать системный вызов «связать дескриптор файла с IOCP». В этом случае помещенная в очередь структура содержит в себе код ошибки операции ввода-вывода, а также, для случая успеха этой операции - число реально введенных или выведенных байт. Реализация порта завершения также ограничивает число потоков, исполняющихся на одном процессоре/ядре после получения структуры из очереди. Объект специфичен для MS Windows, и позволяет обработку входящих запросов соединения и порций данных в серверном программном обеспечении в архитектуре, где число потоков может быть меньше числа клиентов (нет требования создавать отдельный поток с расходами ресурсов на него для каждого нового клиента).

    · ERESOURCE. Мьютекс, поддерживающий рекурсивный захват, с семантикой разделяемого или эксклюзивного захвата. Семантика: объект может быть либо свободен, либо захвачен произвольным числом потоков разделяемым образом, либо захвачен всего одним потоком эксклюзивным образом. Любые попытки осуществить захваты, нарушающее это правило, приводят к блокировке потока до тех пор, пока объект не освободится так, чтобы сделать захват разрешенным. Также есть операции вида TryToAcquire - никогда не блокирует поток, либо захватывает, либо (если нужна блокировка) возвращает FALSE, ничего не делая. Используется в ядре Windows, особенно в файловых системах - так, например, любому кем-то открытому дисковому файлу соответствует структура FCB, в которой есть 2 таких объекта для синхронизации доступа к размеру файла. Один из них - paging IO resource - захватывается эксклюзивно только в пути обрезания файла, и гарантирует, что в момент обрезания на файле нет активного ввода-вывода от кэша и от отображения в память.

    · Rundown protection. Полудокументированный (вызовы присутствуют в файлах-заголовках, но отсутствуют в документации) объект в ядре Windows. Счетчик с операциями «увеличить», «уменьшить» и «ждать». Ожидание блокирует поток до тех пор, пока операции уменьшения не уменьшат счетчик до нуля. Кроме того, операция увеличения может отказать, и наличие активного в данный момент времени ожидания заставляет отказывать все операции увеличения.



    Есть вопросы?

    Сообщить об опечатке

    Текст, который будет отправлен нашим редакторам: