Меню
Бесплатно
Главная  /  Мультимедиа  /  Многопоточные программы с примеры. Восемь простых правил разработки многопоточных приложений

Многопоточные программы с примеры. Восемь простых правил разработки многопоточных приложений

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Как правило, полностью избавиться от глобального состояния не удается, но в арсенале разработчика есть очень эффективные инструменты, позволяющие строго контролировать, какие компоненты программы могут изменять состояние. Кроме того, мы научились создавать ограничительные слои 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. Мы занимаемся этим уже не один десяток лет. Можно утверждать, что качественное скрытие сложности системы от программиста - основная причина, по которой программисту удается писать современные приложения. Если уж на то пошло - разве мы не скрываем сложность системы, реализуя цикл сообщений пользовательского интерфейса, выстраивая низкоуровневые протоколы обмена информацией и т.д.?

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

конец файла . Таким образом, записи в логе, выполняемые разными процессами, никогда несмешиваются. В более современных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-битных архитектурах, и некоторые другие ресурсы. Поэтому данная архитектура оптимальна не для всех приложений.
  • Невозможность исполнять приложение на многомашинном вычислительном комплексе. Упоминавшиеся в предыдущем разделе приемы, такие, как отображение на память разделяемых файлов, для многопоточной программы не применимы.
  • В целом можно сказать, что многопоточные приложения имеют почти те же преимущества и недостатки, что и многопроцессные приложения, использующие разделяемую память .

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

    Глава №10.

    Многопоточные приложения

    Многозадачность в современных операционных системах воспринимается как нечто само собой разумеющееся [До появления Apple OS X на компьютерах Macintosh не было современных многозадачных операционных систем. Правильно спроектировать операционную систему с полноценной многозадачностью очень трудно, поэтому за основу OS X пришлось взять систему Unix. ]. Пользователь рассчитывает на то, что при одновременном запуске текстового редактора и почтового клиента эти программы не будут конфликтовать, а при приеме электронной почты редактор не перестанет работать. При одновременном запуске нескольких программ операционная система быстро переключается между программами, по очереди предоставляя им процессор (если, конечно, на компьютере не установлено несколько процессоров). В результате создается иллюзия одновременной работы нескольких программ, поскольку даже лучшая машинистка (и самое быстрое Интернет-соединение) не угонится за современным процессором.

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

    В VB нормальной поддержки многопоточности не было никогда. Правда, в VB5 появилась одна из ее разновидностей - совместная потоковая модель (apartment threading). Как вы вскоре увидите, совместная модель предоставляет в распоряжение программиста часть преимуществ многопоточности, но не позволяет использовать все возможности в полной мере. Рано или поздно с учебной машины приходится пересаживаться на настоящую, и VB .NET стал первой версией VB с поддержкой свободной многопоточной модели.

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

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

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

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

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

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

    Знакомство с многопоточностью

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

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

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

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

    В программном потоке выполнятся процедура, а не объект.

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

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

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

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

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

    Imports System.Threading

    Импортирование пространства имен упрощает ввод программы и позволяет использовать технологию IntelliSense.

    Непосредственная связь потоков с процедурами наводит на предположение о том, что в этой картине важное место занимают делегаты (см. главу 6). В частности, в пространство имен Threading входит делегат ThreadStart, обычно используемый при запуске программных потоков. Синтаксис использования этого делегата выглядит так:

    Public Delegate Sub ThreadStart()

    Код, вызываемый при помощи делегата ThreadStart, не должен иметь параметров и возвращаемого значения, поэтому потоки не могут создаваться для функций (которые возвращают значение) и для процедур с параметрами. Для передачи информации из потока тоже приходится искать альтернативные средства, поскольку выполняемые методы не возвращают значений и не могут использовать передачу по ссылке. Например, если процедура ThreadMethod находится в классе WilluseThread, то ThreadMethod может передавать информацию посредством изменения свойств экземпляров класса WillUseThread.

    Домены приложений

    Программные потоки.NET работают в так называемых доменах приложений, определяемых в документации как «изолированная среда, в которой выполняется приложение». Домен приложения можно рассматривать как облегченный вариант процессов Win32; один процесс Win32 может содержать несколько доменов приложений. Главное отличие между доменами приложений и процессами заключается в том, что процесс Win32 обладает самостоятельным адресным пространством (в документации домены приложений также сравниваются с логическими процессами, работающими внутри физического процесса). В.NET все управление памятью осуществляется исполнительной средой, поэтому в одном процессе Win32 могут работать несколько доменов приложений. Одним из преимуществ этой схемы является улучшение возможностей масштабирования (scaling) приложений. Средства для работы с доменами приложений находятся в классе AppDomain. Рекомендуем изучить документацию по этому классу. С его помощью можно получить информацию об окружении, в котором работает ваша программа. В частности, класс AppDomain применяется при выполнении рефлексии для системных классов.NET. Следующая программа выводит список загруженных сборок.

    Imports System.Reflection

    Module Modulel

    Sub Main()

    Dim theDomain As AppDomain

    theDomain = AppDomain.CurrentDomain

    Dim Assemblies()As

    Assemblies = theDomain.GetAssemblies

    Dim anAssemblyxAs

    For Each anAssembly In Assemblies

    Console.WriteLinetanAssembly.Full Name) Next

    Console.ReadLine()

    End Sub

    End Module

    Создание потоков

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

    Public Class WillUseThreads

    Public Sub SubtractFromCounter()

    Dim count As Integer

    Do While True count -= 1

    Console.WriteLlne("Am in another thread and counter ="

    & count)

    Loop

    End Sub

    End Class

    Поскольку условие цикла Do остается истинным всегда, можно подумать, что ничто не помешает выполнению процедуры SubtractFromCounter. Тем не менее в многопоточном приложении это не всегда так.

    В следующем фрагменте приведена процедура Sub Main, запускающая поток, и команда Imports:

    Option Strict On Imports System.Threading Module Modulel

    Sub Main()

    1 Dim myTest As New WillUseThreads()

    2 Dim bThreadStart As New ThreadStart(AddressOf _

    myTest.SubtractFromCounter)

    3 Dim bThread As New Thread(bThreadStart)

    4 " bThread.Start()

    Dim i As Integer

    5 Do While True

    Console.WriteLine("In main thread and count is " & i) i += 1

    Loop

    End Sub

    End Module

    Давайте последовательно разберем наиболее принципиальные моменты. Прежде всего процедура Sub Man n всегда работает в главном потоке (main thread). В програм-мах.NET всегда работают минимум два потока: главный и поток сборки мусора. В строке 1 создается новый экземпляр тестового класса. В строке 2 мы создаем делегат ThreadStart и передаем адрес процедуры SubtractFromCounter экземпляра тестового класса, созданного в строке 1 (эта процедура вызывается без параметров). Благо даря импортированию пространства имен Threading длинное имя можно не указывать. Объект нового потока создается в строке 3. Обратите внимание на передачу делегата ThreadStart при вызове конструктора класса Thread. Некоторые программисты предпочитают объединять эти две строки в одну логическую строку:

    Dim bThread As New Thread(New ThreadStarttAddressOf _

    myTest.SubtractFromCounter))

    Наконец, строка 4 «запускает» поток, для чего вызывается метод Start экземпляра класса Thread, созданного для делегата ThreadStart. Вызывая этот метод, мы указываем операционной системе, что процедура Subtract должна работать в отдельном потоке.

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

    На рис. 10.1 показан пример того, что может произойти после запуска программы и ее последующего прерывания клавишей Ctrl+Break. В нашем случае новый поток запустился лишь после того, как счетчик в главном потоке увеличился до 341!

    Рис. 10.1. Простая многопоточная программно время работы

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

    Рис. 10.2. Переключение между потоками в простой многопоточной программе

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

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

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

    bThread.Priority = ThreadPriority.Highest

    Рис. 10.3. Поток с максимальным приоритетом обычно начинает работать быстрее

    Рис. 10.4. Процессор предоставляется и потокам с более низким приоритетом

    Команда назначает новому потоку максимальный приоритет и уменьшает приоритет главного потока. Из рис. 10.3 видно, что новый поток начинает работать быстрее, чем прежде, но, как показывает рис. 10.4, главный поток тоже получает управ ление (правда, очень ненадолго и лишь после продолжительной работы потока с вычитанием). При запуске программы на ваших компьютерах будут получены результаты, похожие на показанные на рис. 10.3 и 10.4, но из-за различий между нашими системами точного совпадения не будет.

    В перечисляемый тип ThreadPrlority входят значения для пяти уровней приоритета:

    ThreadPriority.Highest

    ThreadPriority.AboveNormal

    ThreadPrlority.Normal

    ThreadPriority.BelowNormal

    ThreadPriority.Lowest

    Метод Join

    Иногда программный поток требуется приостановить до момента завершения другого потока. Допустим, вы хотите приостановить поток 1 до тех пор, пока поток 2 не завершит свои вычисления. Для этого из потока 1 вызывается метод Join для потока 2. Иначе говоря, команда

    thread2.Join()

    приостанавливает текущий поток и ожидает завершения потока 2. Поток 1 переходит в заблокированное состояние.

    Если присоединить поток 1 к потоку 2 методом Join, операционная система автоматически запустит поток 1 после завершения потока 2. Учтите, что процесс запуска является недетерминированным: нельзя точно сказать, через какой промежуток времени после завершения потока 2 заработает поток 1. Существует и другая версия Join, которая возвращает логическую величину:

    thread2.Join(Integer)

    Этот метод либо ожидает завершения потока 2, либо разблокирует поток 1 после истечения заданного интервала времени, вследствие чего планировщик операционной системы снова будет выделять потоку процессорное время. Метод возвращает True, если поток 2 завершается до истечения заданного интервала тайм-аута, и False в противном случае.

    Не забывайте основное правило: независимо оттого, завершился ли поток 2 или про-изошел тайм-аут, вы не можете управлять моментом активизации потока 1.

    Имена потоков, CurrentThread и ThreadState

    Свойство Thread.CurrentThread возвращает ссылку на объект потока, выполняемого в настоящий момент.

    Хотя для отладки многопоточных приложений в VB .NET существует замечательное окно потоков, о котором рассказано далее, нас очень часто выручала команда

    MsgBox(Thread.CurrentThread.Name)

    Нередко выяснялось, что код выполняется совсем не в том потоке, в котором ему полагалось выполняться.

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

    Окно потоков

    Окно потоков (Threads window) Visual Studio .NET оказывает неоценимую помощь в отладке многопоточных программ. Оно активизируется командой подменю Debug > Windows в режиме прерывания. Допустим, вы назначили имя потоку bThread следующей командой:

    bThread.Name = "Subtracting thread"

    Примерный вид окна потоков после прерывания программы комбинацией клавиш Ctrl+Break (или другим способом) показан на рис. 10.5.

    Рис. 10.5. Окно потоков

    Стрелкой в первом столбце помечается активный поток, возвращаемый свойством Thread.CurrentThread. Столбец ID содержит числовые идентификаторы потоков. В следующем столбце перечислены имена потоков (если они были присвоены). Столбец Location указывает выполняемую процедуру (например, процедура WriteLine класса Console на рис. 10.5). Остальные столбцы содержат информацию о приоритете и приостановленных потоках (см. следующий раздел).

    Окно потоков (а не операционная система!) позволяет управлять потоками вашей программы при помощи контекстных меню. Например, вы можете остановить текущий поток, для чего следует щелкнуть в соответствующей строке правой кнопкой мыши и выбрать команду Freeze (позже работу остановленного потока можно возобновить). Остановка потоков часто используемая при отладке, чтобы неправильно работающий поток не мешал работе приложения. Кроме того, окно потоков позволяет активизировать другой (не остановленный) поток; для этого следует щелкнуть правой кнопкой мыши в нужной строке и выбрать в контекстном меню команду Switch To Thread (или просто сделать двойной щелчок на строке потока). Как будет показано далee, это очень удобно при диагностике потенциальных взаимных блокировок (deadlocks).

    Приостановка потока

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

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

    Другая версия Sleep заставляет текущий поток уступить оставшуюся часть выделенного процессорного времени:

    Thread.Sleep(0)

    Следующий вариант переводит текущий поток в пассивное состояние на неограниченное время (активизация происходит только при вызове Interrupt):

    Thread.Slеер(Timeout.Infinite)

    Поскольку пассивные потоки (даже при неограниченном времени ожидания) могут прерываться методом Interrupt, что приводит к инициированию исключения ThreadlnterruptExcepti on, вызов Slеер всегда заключается в блок Try-Catch, как в следующем фрагменте:

    Try

    Thread.Sleep(200)

    " Пассивное состояние потока было прервано

    Catch e As Exception

    "Остальные исключения

    End Try

    Каждая программа.NET работает в программном потоке, поэтому метод Sleep также используется для приостановки работы программ (если пространство имен Threadipg не импортируется программой, приходится использовать полное имя Threading.Thread. Sleep).

    Завершение или прерывание программных потоков

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

    Sub ThreadedMethod()

    " В программе необходимо предусмотреть средства для опроса

    " условной переменной.

    " Например, условную переменную можно оформить в виде свойства

    Do While conditionVariable = False And MoreWorkToDo

    " Основной код

    Loop End Sub

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

    Если проверка условной переменной должна происходить в строго определенном месте, воспользуйтесь командой If-Then в сочетании с Exit Sub внутри бесконечного цикла.

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

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

    Метод Interrupt может вызываться только для потоков, находящихся в состоянии Wait, Sleep или Join. Если вызвать Interrupt для потока, находящегося в одном из перечисленных состояний, то через некоторое время поток снова начнет работать, а исполнительная среда инициирует в потоке исключение ThreadlnterruptedExcepti on. Это происходит даже в том случае, если поток был переведен в пассивное состояние на неопределенный срок вызовом Thread.Sleepdimeout. Infinite). Мы говорим «через некоторое время», поскольку планирование потоков имеет недетерминированную природу. Исключение ThreadlnterruptedExcepti on перехватывается секцией Catch, содержащей код выхода из состояния ожидания. Тем не менее секция Catch вовсе не обязана завершать поток по вызову Interrupt - поток обрабатывает исключение по своему усмотрению.

    В.NET метод Interrupt может вызываться даже для незаблокированных потоков. В этом случае поток прерывается при ближайшей блокировке.

    Приостановка и уничтожение потоков

    Пространство имен Threading содержит и другие методы, прерывающие нормальное функционирование потоков:

    • Suspend;
    • Abort.

    Трудно сказать, зачем в.NET была включена поддержка этих методов - при вызове Suspend и Abort программа, скорее всего, начнет работать нестабильно. Ни один из методов не позволяет нормально провести деинициализацию потока. Кроме того, при вызове Suspend или Abort невозможно предсказать, в каком состоянии поток оставит объекты после приостановки или аварийного завершения.

    В результате вызова Abort инициируется исключение ThreadAbortException. Чтобы вы поняли, почему это странное исключение не следует обрабатывать в программах, мы приводим отрывок из документации.NET SDK:

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

    Мораль: Abort и Suspend использовать не рекомендуется (а если без Suspend все же не обойтись, возобновите приостановленный поток методом Resume). Безопасно завершить поток можно только путем опроса синхронизируемой условной переменной или вызовом метода Interrupt, о котором говорилось выше.

    Фоновые потоки (демоны)

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

    Если в приложении остались только фоновые потоки, приложение автоматически завершается.

    Более серьезный пример: извлечение данных из кода HTML

    Мы рекомендуем использовать потоки лишь в том случае, когда функциональность программы четко делится на несколько операций. Хорошим примером является программа извлечения данных из кода HTML из главы 9. Наш класс выполняет две операции: выборку данных с сайта Amazon и их обработку. Перед нами идеальный пример ситуации, в которой многопоточное программирование действительно уместно. Мы создаем классы для нескольких разных книг и затем анализируем данные в разных потоках. Создание нового потока для каждой книги повышает эффективность программы, поскольку во время приема данных одним потоком (что может потребовать ожидания на сервере Amazon) другой поток будет занят обработкой уже полученных данных.

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

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

    Public Sub FindRank()

    m_Rank = ScrapeAmazon()

    Console.WriteLine("the rank of " & m_Name & "Is " & GetRank)

    End Sub

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

    Dim theBook(3.1) As String theBook(0.0) = "1893115992"

    theBook(0.l) = "Programming VB .NET" " И т.д.

    Четыре потока создаются в том же цикле, в котором создаются объекты AmazonRanker:

    For i= 0 То 3

    Try

    theRanker = New AmazonRanker(theBook(i.0). theBookd.1))

    aThreadStart = New ThreadStar(AddressOf theRanker.FindRan()

    aThread = New Thread(aThreadStart)

    aThread.Name = theBook(i.l)

    aThread.Start() Catch e As Exception

    Console.WriteLine(e.Message)

    End Try

    Next

    Ниже приведен полный текст программы:

    Option Strict On Imports System.IO Imports System.Net

    Imports System.Threading

    Module Modulel

    Sub Main()

    Dim theBook(3.1) As String

    theBook(0.0) = "1893115992"

    theBook(0.l) = "Programming VB .NET"

    theBook(l.0) = "1893115291"

    theBook(l.l) = "Database Programming VB .NET"

    theBook(2,0) = "1893115623"

    theBook(2.1) = "Programmer "s Introduction to C#."

    theBook(3.0) = "1893115593"

    theBook(3.1) = "Gland the .Net Platform "

    Dim i As Integer

    Dim theRanker As =AmazonRanker

    Dim aThreadStart As Threading.ThreadStart

    Dim aThread As Threading.Thread

    For i = 0 To 3

    Try

    theRanker = New AmazonRankerttheBook(i.0). theBook(i.1))

    aThreadStart = New ThreadStart(AddressOf theRanker. FindRank)

    aThread = New Thread(aThreadStart)

    aThread.Name= theBook(i.l)

    aThread.Start()

    Catch e As Exception

    Console.WriteLlnete.Message)

    End Try Next

    Console.ReadLine()

    End Sub

    End Module

    Public Class AmazonRanker

    Private m_URL As String

    Private m_Rank As Integer

    Private m_Name As String

    Public Sub New(ByVal ISBN As String. ByVal theName As String)

    m_URL = "http://www.amazon.com/exec/obidos/ASIN/" & ISBN

    m_Name = theName End Sub

    Public Sub FindRank() m_Rank = ScrapeAmazon()

    Console.Writeline("the rank of " & m_Name & "is "

    & GetRank) End Sub

    Public Readonly Property GetRank() As String Get

    If m_Rank <> 0 Then

    Return CStr(m_Rank) Else

    " Проблемы

    End If

    End Get

    End Property

    Public Readonly Property GetName() As String Get

    Return m_Name

    End Get

    End Property

    Private Function ScrapeAmazon() As Integer Try

    Dim theURL As New Uri(m_URL)

    Dim theRequest As WebRequest

    theRequest =WebRequest.Create(theURL)

    Dim theResponse As WebResponse

    theResponse = theRequest.GetResponse

    Dim aReader As New StreamReader(theResponse.GetResponseStream())

    Dim theData As String

    theData = aReader.ReadToEnd

    Return Analyze(theData)

    Catch E As Exception

    Console.WriteLine(E.Message)

    Console.WriteLine(E.StackTrace)

    Console. ReadLine()

    End Try End Function

    Private Function Analyze(ByVal theData As String) As Integer

    Dim Location As.Integer Location = theData.IndexOf("Amazon.com

    Sales Rank:") _

    + "Amazon.com Sales Rank:".Length

    Dim temp As String

    Do Until theData.Substring(Location.l) = "<" temp = temp

    &theData.Substring(Location.l) Location += 1 Loop

    Return Clnt(temp)

    End Function

    End Class

    Многопоточные операции часто используются в.NET и пространствах имен ввода-вы-вода, поэтому в библиотеке.NET Framework для них предусмотрены специальные асинхронные методы. Дополнительная информация о применении асинхронных методов при написании многопоточных программ приведена в описании методов BeginGetResponse и EndGetResponse класса HTTPWebRequest

    Главная опасность (общие данные)

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

    Вашему вниманию предлагается небольшая программа, которая демонстрирует возникающие проблемы, не углубляясь в излишние подробности. В этой программе моделируется дом, в каждой комнате которого установлен термостат. Если температура на 5 и более градусов по Фаренгейту (около 2,77 градусов по Цельсию) меньше положенной, мы приказываем системе отопления повысить температуру на 5 градусов; в противном случае температура повышается только на 1 градус. Если текущая температура больше либо равна заданной, изменение не производится. Регулировка температуры в каждой комнате осуществляется отдельным потоком с 200-миллисекундной задержкой. Основная работа выполняется следующим фрагментом:

    If mHouse.HouseTemp < mHouse.MAX_TEMP = 5 Then Try

    Thread.Sleep(200)

    Catch tie As ThreadlnterruptedException

    " Пассивное ожидание было прервано

    Catch e As Exception

    " Другие исключения End Try

    mHouse.HouseTemp +- 5 " И т.д.

    Ниже приведен полный исходный текст программы. Результат показан на рис. 10.6: температура в доме достигла 105 градусов по Фаренгейту (40,5 градуса по Цельсию)!

    1 Option Strict On

    2 Imports System.Threading

    3 Module Modulel

    4 Sub Main()

    5 Dim myHouse As New House(l0)

    6 Console. ReadLine()

    7 End Sub

    8 End Module

    9 Public Class House

    10 Public Const MAX_TEMP As Integer = 75

    11 Private mCurTemp As Integer = 55

    12 Private mRooms() As Room

    13 Public Sub New(ByVal numOfRooms As Integer)

    14 ReDim mRooms(numOfRooms = 1)

    15 Dim i As Integer

    16 Dim aThreadStart As Threading.ThreadStart

    17 Dim aThread As Thread

    18 For i = 0 To numOfRooms -1

    19 Try

    20 mRooms(i)=NewRoom(Me, mCurTemp,CStr(i) &"throom")

    21 aThreadStart - New ThreadStart(AddressOf _

    mRooms(i).CheckTempInRoom)

    22 aThread =New Thread(aThreadStart)

    23 aThread.Start()

    24 Catch E As Exception

    25 Console.WriteLine(E.StackTrace)

    26 End Try

    27 Next

    28 End Sub

    29 Public Property HouseTemp()As Integer

    30 . Get

    31 Return mCurTemp

    32 End Get

    33 Set(ByVal Value As Integer)

    34 mCurTemp = Value 35 End Set

    36 End Property

    37 End Class

    38 Public Class Room

    39 Private mCurTemp As Integer

    40 Private mName As String

    41 Private mHouse As House

    42 Public Sub New(ByVal theHouse As House,

    ByVal temp As Integer, ByVal roomName As String)

    43 mHouse = theHouse

    44 mCurTemp = temp

    45 mName = roomName

    46 End Sub

    47 Public Sub CheckTempInRoom()

    48 ChangeTemperature()

    49 End Sub

    50 Private Sub ChangeTemperature()

    51 Try

    52 If mHouse.HouseTemp < mHouse.MAX_TEMP - 5 Then

    53 Thread.Sleep(200)

    54 mHouse.HouseTemp +- 5

    55 Console.WriteLine("Am in " & Me.mName & _

    56 ".Current temperature is "&mHouse.HouseTemp)

    57 . Elself mHouse.HouseTemp < mHouse.MAX_TEMP Then

    58 Thread.Sleep(200)

    59 mHouse.HouseTemp += 1

    60 Console.WriteLine("Am in " & Me.mName & _

    61 ".Current temperature is " & mHouse.HouseTemp)

    62 Else

    63 Console.WriteLine("Am in " & Me.mName & _

    64 ".Current temperature is " & mHouse.HouseTemp)

    65 " Ничего не делать, температура нормальная

    66 End If

    67 Catch tae As ThreadlnterruptedException

    68 " Пассивное ожидание было прервано

    69 Catch e As Exception

    70 " Другие исключения

    71 End Try

    72 End Sub

    73 End Class

    Рис. 10.6. Проблемы многопоточности

    В процедуре Sub Main (строки 4-7) создается «дом» с десятью «комнатами». Класс House устанавливает максимальную температуру 75 градусов по Фаренгейту (около 24 градусов по Цельсию). В строках 13-28 определяется довольно сложный конструктор дома. Ключевыми для понимания программы являются строки 18-27. Строка 20 создает очередной объект комнаты, при этом конструктору передается ссылка на объект дома, чтобы объект комнаты при необходимости мог к нему обратиться. Строки 21-23 запускают десять потоков для регулировки температуры в каждой комнате. Класс Room определяется в строках 38-73. Ссылка на объект House coxpa няется в переменной mHouse в конструкторе класса Room (строка 43). Код проверки и регулировки температуры (строки 50-66) выглядит просто и естественно, но как вы вскоре убедитесь, это впечатление обманчиво! Обратите внимание на то, что этот код заключен в блок Try-Catch, поскольку в программе используется метод Sleep.

    Вряд ли кто-нибудь согласится жить при температуре в 105 градусов по Фаренгейту (40,5 24 градусов по Цельсию). Что же произошло? Проблема связана со следующей строкой:

    If mHouse.HouseTemp < mHouse.MAX_TEMP - 5 Then

    А происходит следующее: сначала температуру проверяет поток 1. Он видит, что температура слишком низка, и поднимает ее на 5 градусов. К сожалению, перед повышением температуры поток 1 прерывается и управление передаётся поток 2. Поток 2 проверяет ту же самую переменную, которая еще не была изменена потоком 1. Таким образом, поток 2 тоже готовится поднять температуру на 5 градусов, но сделать этого не успевает и тоже переходит в состояние ожидания. Процесс продолжается до тех пор, пока поток 1 не активизируется и не перейдет к следующей команде - повышению температуры на 5 градусов. Повышение повторяется при активизации всех 10 потоков, и жильцам дома придется плохо.

    Решение проблемы: синхронизация

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

    If mHouse.HouseTemp < mHouse.MAX_TEMP - 5 Then...

    полностью отрабатываются активным потоком до того, как он будет прерван. Это свойство называется атомарностыд - блок кода должен выполняться каждым потоком без прерывания, как атомарная единица. Группа команд, объединенных в атомарный блок, не может быть прервана планировщиком потоков до ее завершения. В любом многопоточном языке программирования существуют свои способы обеспечения атомарности. В VB .NET проще всего воспользоваться командой SyncLock, при вызове которой передается объектная переменная. Внесите в процедуру ChangeTemperature из предыдущего примера небольшие изменения, и программа заработает нормально:

    Private Sub ChangeTemperature() SyncLock (mHouse)

    Try

    If mHouse.HouseTemp < mHouse.MAXJTEMP -5 Then

    Thread.Sleep(200)

    mHouse.HouseTemp += 5

    Console.WriteLine("Am in " & Me.mName & _

    ".Current temperature is " & mHouse.HouseTemp)

    Elself

    mHouse.HouseTemp < mHouse. MAX_TEMP Then

    Thread.Sleep(200) mHouse.HouseTemp += 1

    Console.WriteLine("Am in " & Me.mName &_ ".Current temperature is " & mHouse.HomeTemp) Else

    Console.WriteLineC"Am in " & Me.mName & _ ".Current temperature is " & mHouse.HouseTemp)

    " Ничего не делать, температура нормальная

    End If Catch tie As ThreadlnterruptedException

    " Пассивное ожидание было прервано Catch e As Exception

    " Другие исключения

    End Try

    End SyncLock

    End Sub

    Код блока SyncLock выполняется атомарно. Доступ к нему со стороны всех остальных потоков будет закрыт, пока первый поток не снимет блокировку командой End SyncLock. Если поток в синхронизируемом блоке переходит в состояние пассивного ожидания, блокировка сохраняется вплоть до прерывания или возобновления работы потока.

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

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

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

    Public Class ConditionVariable

    Private Shared locker As Object= New Object()

    Private Shared mOK As Boolean Shared

    Property TheConditionVariable()As Boolean

    Get

    Return mOK

    End Get

    Set(ByVal Value As Boolean) SyncLock (locker)

    mOK= Value

    End SyncLock

    End Set

    End Property

    End Class

    Команда SyncLock и класс Monitor

    Использование команды SyncLock связано с некоторыми тонкостями, не проявившимися в приведенных выше простых примерах. Так, очень важную роль играет выбор объекта синхронизации. Попробуйте запустить предыдущую программу с командой SyncLock(Me) вместо SyncLock(mHouse). Температура снова поднимается выше пороговой величины!

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

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

    Поскольку синхронизация связана с конкретным объектом, в некоторых ситуациях возможна непреднамеренная блокировка других фрагментов. Допустим, у вас имеются два синхронизированных метода first и second, причем оба метода синхронизируются по объекту bigLock. Когда поток 1 входит в метод first и захватывает bigLock, ни один поток не сможет войти в метод second, потому что доступ к нему уже ограничен потоком 1!

    Функциональность команды SyncLock можно рассматривать как подмножество функциональности класса Monitor. Класс Monitor обладает расширенными возможностями настройки, и с его помощью можно решать нетривиальные задачи синхронизации. Команда SyncLock является приближенным аналогом методов Enter и Exi t класса Moni tor:

    Try

    Monitor.Enter(theObject) Finally

    Monitor.Exit(theObject)

    End Try

    Для некоторых стандартных операций (увеличение/уменьшение переменной, обмен содержимого двух переменных) в.NET Framework предусмотрен класс Interlocked, методы которого выполняют эти операции на атомарном уровне. С использованием класса Interlocked данные операции выполняются значительно быстрее, нежели при помощи команды SyncLock.

    Взаимная блокировка

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

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

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

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

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

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

    1 Option Strict On

    2 Imports System.Threading

    3 Module Modulel

    4 Sub Main()

    5 Dim Tom As New Programmer("Tom")

    6 Dim Bob As New Programmer("Bob")

    7 Dim aThreadStart As New ThreadStart(AddressOf Tom.Eat)

    8 Dim aThread As New Thread(aThreadStart)

    9 aThread.Name= "Tom"

    10 Dim bThreadStart As New ThreadStarttAddressOf Bob.Eat)

    11 Dim bThread As New Thread(bThreadStart)

    12 bThread.Name = "Bob"

    13 aThread.Start()

    14 bThread.Start()

    15 End Sub

    16 End Module

    17 Public Class Fork

    18 Private Shared mForkAvaiTable As Boolean = True

    19 Private Shared mOwner As String = "Nobody"

    20 Private Readonly Property OwnsUtensil() As String

    21 Get

    22 Return mOwner

    23 End Get

    24 End Property

    25 Public Sub GrabForktByVal a As Programmer)

    26 Console.Writel_ine(Thread.CurrentThread.Name &_

    "trying to grab the fork.")

    27 Console.WriteLine(Me.OwnsUtensil & "has the fork.") . .

    28 Monitor.Enter(Me) "SyncLock (aFork)"

    29 If mForkAvailable Then

    30 a.HasFork = True

    31 mOwner = a.MyName

    32 mForkAvailable = False

    33 Console.WriteLine(a.MyName&"just got the fork.waiting")

    34 Try

    Thread.Sleep(100) Catch e As Exception Console.WriteLine (e.StackTrace)

    End Try

    35 End If

    36 Monitor.Exit(Me)

    End SyncLock

    37 End Sub

    38 End Class

    39 Public Class Knife

    40 Private Shared mKnifeAvailable As Boolean = True

    41 Private Shared mOwner As String ="Nobody"

    42 Private Readonly Property OwnsUtensi1() As String

    43 Get

    44 Return mOwner

    45 End Get

    46 End Property

    47 Public Sub GrabKnifetByVal a As Programmer)

    48 Console.WriteLine(Thread.CurrentThread.Name & _

    "trying to grab the knife.")

    49 Console.WriteLine(Me.OwnsUtensil & "has the knife.")

    50 Monitor.Enter(Me) "SyncLock (aKnife)"

    51 If mKnifeAvailable Then

    52 mKnifeAvailable = False

    53 a.HasKnife = True

    54 mOwner = a.MyName

    55 Console.WriteLine(a.MyName&"just got the knife.waiting")

    56 Try

    Thread.Sleep(100)

    Catch e As Exception

    Console.WriteLine (e.StackTrace)

    End Try

    57 End If

    58 Monitor.Exit(Me)

    59 End Sub

    60 End Class

    61 Public Class Programmer

    62 Private mName As String

    63 Private Shared mFork As Fork

    64 Private Shared mKnife As Knife

    65 Private mHasKnife As Boolean

    66 Private mHasFork As Boolean

    67 Shared Sub New()

    68 mFork = New Fork()

    69 mKnife = New Knife()

    70 End Sub

    71 Public Sub New(ByVal theName As String)

    72 mName = theName

    73 End Sub

    74 Public Readonly Property MyName() As String

    75 Get

    76 Return mName

    77 End Get

    78 End Property

    79 Public Property HasKnife() As Boolean

    80 Get

    81 Return mHasKnife

    82 End Get

    83 Set(ByVal Value As Boolean)

    84 mHasKnife = Value

    85 End Set

    86 End Property

    87 Public Property HasFork() As Boolean

    88 Get

    89 Return mHasFork

    90 End Get

    91 Set(ByVal Value As Boolean)

    92 mHasFork = Value

    93 End Set

    94 End Property

    95 Public Sub Eat()

    96 Do Until Me.HasKnife And Me.HasFork

    97 Console.Writeline(Thread.CurrentThread.Name&"is in the thread.")

    98 If Rnd() < 0.5 Then

    99 mFork.GrabFork(Me)

    100 Else

    101 mKnife.GrabKnife(Me)

    102 End If

    103 Loop

    104 MsgBox(Me.MyName & "can eat!")

    105 mKnife = New Knife()

    106 mFork= New Fork()

    107 End Sub

    108 End Class

    Основная процедура Main (строки 4-16) создает два экземпляра класса Programmer и затем запускает два потока для выполнения критического метода Eat класса Programmer (строки 95-108), описанного ниже. Процедура Main задает имена потоков и занускает их; вероятно, все происходящее понятно и без комментариев.

    Интереснее выглядит код класса Fork (строки 17-38) (аналогичный класс Knife определяется в строках 39-60). В строках 18 и 19 задаются значения общих полей, по которым можно узнать, доступна ли в данный момент вилка, и если нет - кто ею пользуется. ReadOnly-свойство OwnUtensi1 (строки 20-24) предназначено для простейшей передачи информации. Центральное место в классе Fork занимает метод «захвата вилки» GrabFork, определяемый в строках 25-27.

    1. Строки 26 и 27 просто выводят на консоль отладочную информацию. В основном коде метода (строки 28-36) доступ к вилке синхронизируется по объектной пе ременной Me. Поскольку в нашей программе используется только одна вилка, синхронизация по Me гарантирует, что два потока не смогут одновременно захватить ее. Команда Slee"p (в блоке, начинающемся в строке 34) имитирует задержку между захватом вилки/ножа и началом еды. Учтите, что команда Sleep не снимает блокировку с объектов и лишь ускоряет возникновение взаимной блокировки!
      Однако наибольший интерес представляет код класса Programmer (строки 61-108). В строках 67-70 определяется общий конструктор, что гарантирует наличие в программе только одной вилки и ножа. Код свойств (строки 74-94) прост и не требует комментариев. Самое главное происходит в методе Eat, выполняемом двумя отдельными потоками. Процесс продолжается в цикле до тех пор, пока какой-либо поток не захватит вилку вместе с ножом. В строках 98-102 объект случайным образом захватывает вилку/нож, используя вызов Rnd, - именно это и порождает взаимную блокировку. Происходит следующее:
      Поток, выполняющий метод Eat объекта Тот, активизируется и входит в цикл. Он захватывает нож и переходит в состояние ожидания.
    2. Поток, выполняющий метод Eat объекта Bob, активизируется и входит в цикл. Он не может захватить нож, но захватывает вилку и переходит в состояние ожидания.
    3. Поток, выполняющий метод Eat объекта Тот, активизируется и входит в цикл. Он пытается захватить вилку, однако вилка уже захвачена объектом Bob; поток переходит в состояние ожидания.
    4. Поток, выполняющий метод Eat объекта Bob, активизируется и входит в цикл. Он пытается захватить нож, однако нож уже захвачен объектом Тот; поток переходит в состояние ожидания.

    Все это продолжается до бесконечности - перед нами типичная ситуация взаимной блокировки (попробуйте запустить программу, и вы убедитесь в том, что поесть так никому и не удается).
    О возникновении взаимной блокировки можно узнать и в окне потоков. Запустите программу и прервите ее клавишами Ctrl+Break. Включите в окно просмотра переменную Me и откройте окно потоков. Результат выглядит примерно так, как показано на рис. 10.7. Из рисунка видно, что поток Bob захватил нож, но вилки у него нет. Щелкните правой кнопкой мыши в окне потоков на строке Тот и выберите в контекстном меню команду Switch to Thread. Окно просмотра показывает, что у потока Тот имеется вилка, но нет ножа. Конечно, это не является стопроцентным доказательством, но подобное поведение по крайней мере заставляет заподозрить неладное.
    Если вариант с синхронизацией по одному объекту (как в программе с повышением -температуры в доме) невозможен, для предотвращения взаимных блокировок можно пронумеровать объекты синхронизации и всегда захватывать их в постоянном порядке. Продолжим аналогию с обедающими программистами: если поток всегда сначала берет нож, а потом вилку, проблем с взаимной блокировкой не будет. Первый поток, захвативший нож, сможет нормально поесть. В переводе на язык программных потоков это означает, что захват объекта 2 возможен лишь при условии предварительного захвата объекта 1.

    Рис. 10.7. Анализ взаимной блокировки в окне потоков

    Следовательно, если убрать вызов Rnd в строке 98 и заменить его фрагментом

    mFork.GrabFork(Me)

    mKnife.GrabKnife(Me)

    взаимная блокировка исчезает!

    Совместная работа с данными по мере их создания

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

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

    • Поток 1 (потребитель) активизируется, входите синхронизированный метод, ищет данные, не находит их и переходит в состояние ожидания. Предвари телъно он должен снять блокировку, чтобы не мешать работе потока- поставщика.
    • Поток 2 (поставщик) входит в синхронизированный метод, освобожденный потоком 1, создает данные для потока 1 и каким-то образом оповещает поток 1 о наличии данных. Затем он снимает блокировку, чтобы поток 1 смог обработать новые данные.

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

    Связи «поставщик/потребитель» встречаются очень часто, поэтому в библиотеках классов многопоточного программирования для таких ситуаций создаются специальные примитивы. В.NET эти примитивы называются Wait и Pulse-PulseAl 1 и являются частью класса Monitor. Рисунок 10.8 поясняет ситуацию, которую мы собираемся запрограммировать. В программе организуются три очереди потоков: очередь ожидания, очередь блокировки и очередь выполнения. Планировщик потоков не выделяет процессорное время потокам, находящимся в очереди ожидания. Чтобы потоку выделялось время, он должен переместиться в очередь выполнения. В результате работа приложения организуется гораздо эффективнее, чем при обычном опросе условной переменной.

    На псевдокоде идиома потребителя данных формулируется так:

    " Вход в синхронизированный блок следующего вида

    While нет данных

    Перейти в очередь ожидания

    Loop

    Если данные есть, обработать их.

    Покинуть синхронизированный блок

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

    На псевдокоде идиома поставщика данных выглядит так:

    " Вход в синхронизированный блок вида

    While данные НЕ нужны

    Перейти в очередь ожидания

    Else Произвести данные

    После появления готовых данных вызвать Pulse-PulseAll.

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

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

    1 Option Strict On

    2 Imports System.Threading

    3 Module Modulel

    4 Sub Main()

    5 Dim theFamily As New Family()

    6 theFamily.StartltsLife()

    7 End Sub

    8 End fjodule

    9

    10 Public Class Family

    11 Private mMoney As Integer

    12 Private mWeek As Integer = 1

    13 Public Sub StartltsLife()

    14 Dim aThreadStart As New ThreadStarUAddressOf Me.Produce)

    15 Dim bThreadStart As New ThreadStarUAddressOf Me.Consume)

    16 Dim aThread As New Thread(aThreadStart)

    17 Dim bThread As New Thread(bThreadStart)

    18 aThread.Name = "Produce"

    19 aThread.Start()

    20 bThread.Name = "Consume"

    21 bThread. Start()

    22 End Sub

    23 Public Property TheWeek() As Integer

    24 Get

    25 Return mweek

    26 End Get

    27 Set(ByVal Value As Integer)

    28 mweek - Value

    29 End Set

    30 End Property

    31 Public Property OurMoney() As Integer

    32 Get

    33 Return mMoney

    34 End Get

    35 Set(ByVal Value As Integer)

    36 mMoney =Value

    37 End Set

    38 End Property

    39 Public Sub Produce()

    40 Thread.Sleep(500)

    41 Do

    42 Monitor.Enter(Me)

    43 Do While Me.OurMoney > 0

    44 Monitor.Wait(Me)

    45 Loop

    46 Me.OurMoney =1000

    47 Monitor.PulseAll(Me)

    48 Monitor.Exit(Me)

    49 Loop

    50 End Sub

    51 Public Sub Consume()

    52 MsgBox("Am in consume thread")

    53 Do

    54 Monitor.Enter(Me)

    55 Do While Me.OurMoney = 0

    56 Monitor.Wait(Me)

    57 Loop

    58 Console.WriteLine("Dear parent I just spent all your " & _

    money in week " & TheWeek)

    59 TheWeek += 1

    60 If TheWeek = 21 *52 Then System.Environment.Exit(0)

    61 Me.OurMoney =0

    62 Monitor.PulseAll(Me)

    63 Monitor.Exit(Me)

    64 Loop

    65 End Sub

    66 End Class

    Метод StartltsLife (строки 13-22) осуществляет подготовку к запуску потоков Produce и Consume. Самое главное происходит в потоках Produce (строки 39-50) и Consume (строки 51-65). Процедура Sub Produce проверяет наличие денег, и если деньги есть, переходит в очередь ожидания. В противном случае родитель генерирует деньги (строка 46) и оповещает объекты в очереди ожидания об изменении ситуации. Учтите, что вызов Pulse-Pulse All вступает в силу лишь при снятии блокировки командой Monitor.Exit. И наоборот, процедура Sub Consume проверяет наличие денег, и если денег нет - оповещает об этом ожидающего родителя. Строка 60 просто завершает программу по прошествии 21 условного года; вызов System. Environment.Exit(0) является.NET-аналогом команды End (команда End тоже поддерживается, но в отличие от System. Environment. Exit она не позволяет вернуть код завершения операционной системе).

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

    Многопоточность в графических программах

    Наше обсуждение многопоточности в приложениях с графическим интерфейсом начнется с примера, поясняющего, для чего нужна многопоточность в графических приложениях. Создайте форму с двумя кнопками Start (btnStart) и Cancel (btnCancel), как показано на рис. 10.9. При нажатии кнопки Start создается класс, который содержит случайную строку из 10 миллионов символов и метод для подсчета вхождений буквы «Е» в этой длинной строке. Обратите внимание на применение класса StringBuilder, повышающего эффективность создания длинных строк.

    Шаг 1

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



    Шаг 2

    При снятии блокировки поток 2 или поток 3 выходит из очереди блокировки и входит в синхронизированный блок, устанавливая блокировку

    ШагЗ

    Допустим, поток 3 входит в синхронизированный блок, создает данные и вызывает Pulse-Pulse All.

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



    Рис. 10.8. Проблема «поставщик/потребитель»

    Рис. 10.9. Многопоточность в простом приложении с графическим интерфейсом

    Imports System.Text

    Public Class RandomCharacters

    Private m_Data As StringBuilder

    Private mjength, m_count As Integer

    Public Sub New(ByVal n As Integer)

    m_Length = n -1

    m_Data = New StringBuilder(m_length) MakeString()

    End Sub

    Private Sub MakeString()

    Dim i As Integer

    Dim myRnd As New Random()

    For i = 0 To m_length

    " Сгенерировать случайное число от 65 до 90,

    " преобразовать его в прописную букву

    " и присоединить к объекту StringBuilder

    m_Data.Append(Chr(myRnd.Next(65.90)))

    Next

    End Sub

    Public Sub StartCount()

    GetEes()

    End Sub

    Private Sub GetEes()

    Dim i As Integer

    For i = 0 To m_length

    If m_Data.Chars(i) = CChar("E") Then

    m_count += 1

    End If Next

    m_CountDone = True

    End Sub

    Public Readonly

    Property GetCount() As Integer Get

    If Not (m_CountDone) Then

    Return m_count

    End If

    End Get End Property

    Public Readonly

    Property IsDone()As Boolean Get

    Return

    m_CountDone

    End Get

    End Property

    End Class

    С двумя кнопками на форме связывается весьма простой код. В процедуре btn-Start_Click создается экземпляр приведенного выше класса RandomCharacters, инкапсулирующего строку с 10 миллионами символов:

    Private Sub btnStart_Click(ByVal sender As System.Object.

    ByVal e As System.EventArgs) Handles btnSTart.Click

    Dim RC As New RandomCharacters(10000000)

    RC.StartCount()

    MsgBox("The number of es is " & RC.GetCount)

    End Sub

    Кнопка Cancel выводит окно сообщения:

    Private Sub btnCancel_Click(ByVal sender As System.Object._

    ByVal e As System.EventArgs)Handles btnCancel.Click

    MsgBox("Count Interrupted!")

    End Sub

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

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

    Application.DoEvents()

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

    Следующий шаг: кнопка Show Count

    Допустим, вы решили проявить творческую фантазию и придать форме вид, показанный на рис. 10.9. Обратите внимание: кнопка Show Count пока недоступна.

    Рис. 10.10. Форма с заблокированной кнопкой

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

    Public Class RandomCharacters

    Private m_0ata As StringBuilder

    Private m_CountDone As Boolean

    Private mjength. m_count As Integer

    Private m_Button As Windows.Forms.Button

    Public Sub New(ByVa1 n As Integer,_

    ByVal b As Windows.Forms.Button)

    m_length = n - 1

    m_Data = New StringBuilder(mJength)

    m_Button = b MakeString()

    End Sub

    Private Sub MakeString()

    Dim I As Integer

    Dim myRnd As New Random()

    For I = 0 To m_length

    m_Data.Append(Chr(myRnd.Next(65. 90)))

    Next

    End Sub

    Public Sub StartCount()

    GetEes()

    End Sub

    Private Sub GetEes()

    Dim I As Integer

    For I = 0 To mjength

    If m_Data.Chars(I) = CChar("E") Then

    m_count += 1

    End If Next

    m_CountDone =True

    m_Button.Enabled=True

    End Sub

    Public Readonly

    Property GetCount()As Integer

    Get

    If Not (m_CountDone) Then

    Throw New Exception("Count not yet done") Else

    Return m_count

    End If

    End Get

    End Property

    Public Readonly Property IsDone() As Boolean

    Get

    Return m_CountDone

    End Get

    End Property

    End Class

    Вполне вероятно, что в некоторых случаях этот код будет работать. Тем не менее:

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

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

    Организовать взаимодействие объектов с применением событий тоже не удастся. 06-работник события выполняется в том же потоке, в котором произошел вызов RaiseEvent поэтому события вам не помогут.

    И все же здравый смысл подсказывает, что в графических приложениях должны существовать средства модификации элементов из другого потока. В.NET Framework существует поточно-безопасный способ вызова методов приложений GUI из другого потока. Для этой цели используется особый тип делегатов Method Invoker из пространства имен System.Windows. Forms. В следующем фрагменте приведен новый вариант метода GetEes (измененные строки выделены жирным шрифтом):

    Private Sub GetEes()

    Dim I As Integer

    For I = 0 To m_length

    If m_Data.Chars(I) = CChar("E")Then

    m_count += 1

    End If Next

    m_CountDone = True Try

    Dim mylnvoker As New Methodlnvoker(AddressOf UpDateButton)

    myInvoker.Invoke() Catch e As ThreadlnterruptedException

    "Неудача

    End Try

    End Sub

    Public Sub UpDateButton()

    m_Button.Enabled =True

    End Sub

    Межпоточные обращения к кнопке осуществляются не напрямую, а через Method Invoker. .NET Framework гарантирует, что этот вариант безопасен по отношению к потокам.

    Почему при многопоточном программировании возникает столько проблем?

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

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

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

    Но и это не все: современные компиляторы оптимизируют быстродействие программ, а оборудование компьютера может вмешиваться в процесс управления памятью. Как следствие, компилятор или оборудование может без вашего ведома изменить порядок команд, указанный в исходном тексте программы [Многие компиляторы оптимизируют циклические операции копирования массивов вида for i=0 to n:b(i)=a(i):ncxt. Компилятор (или даже специализированное устройство управления памятью) может просто создать массив, а потом заполнить его одной операцией копирования вместо многократного копирования отдельных элементов! ].

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

    Какая тема вызывает больше всего вопросов и затруднений у начинающих? Когда я спросила об этом преподавателя и 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.

    Клэй Бреширс (Clay Breshears)

    Введение

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

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

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

    Правило 1. Выделите операции, выполняемые в программном коде независимо друг от друга

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

    Рассмотрим ещё один пример – рабочий цикл пункта проката DVD-дисков, в который приходят заказы на определённые фильмы. Заказы распределяются между работниками пункта, которые ищут эти фильмы на складе. Естественно, если один из работников возьмёт со склада диск, на котором записан фильм с участием Одри Хепбёрн, это никоим образом не затронет другого работника, ищущего очередной боевик с Арнольдом Шварценеггером, и уж тем более не повлияет на их коллегу, находящегося в поисках дисков с новым сезоном сериала «Друзья». В нашем примере мы считаем, что все проблемы, связанные с отсутствием фильмов на складе, были решены до того, как заказы поступили в пункт проката, а упаковка и отправка любого заказа не повлияет на обработку других.

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

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

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

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

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

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

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

    Не так давно, помимо двухъядерных процессоров, на рынке появились четырёхъядерные. Более того, Intel уже объявила о создании процессора с 80 ядрами, способного выполнять триллион операций с плавающей точкой в секунду. Поскольку количество ядер в процессорах будет со временем только расти, ваш программный код должен иметь соответствующий потенциал для масштабируемости. Масштабируемость – параметр, по которому можно судить о способности приложения адекватно реагировать на такие изменения, как увеличение системных ресурсов (количество ядер, объём памяти, частота шины и проч.) или увеличение объёма данных. Учитывая, что количество ядер в процессорах будущего увеличится, создавайте масштабируемый код, производительность которого будет расти благодаря увеличению системных ресурсов.

    Перефразируя один из законов Норткота Паркинсона (C. Northecote Parkinson), можно сказать, что «обработка данных занимает все доступные системные ресурсы». Это означает, что при увеличении вычислительных ресурсов (например, количества ядер), все они, вероятнее всего, будут использоваться для обработки данных. Вернёмся к приложению для сжатия видео, рассмотренному выше. Появление у процессора дополнительных ядер вряд ли скажется на размере обрабатываемых кадров – вместо этого увеличится число потоков, обрабатывающих кадр, что приведёт к уменьшению количества пикселей на поток. В результате, из-за организации дополнительных потоков, возрастет объем служебных данных, а степень детализации параллелизма снизится. Ещё одним более вероятным сценарием может стать увеличение размера или количества видеофайлов, которые нужно будет кодировать. В этом случае организация дополнительных потоков, которые будут обрабатывать более объёмные (или дополнительные) видеофайлы, позволит разделить весь объём работ непосредственно на том этапе, где произошло увеличение. В свою очередь, приложение с такими возможностями будет иметь высокий потенциал для масштабируемости.

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

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

    Правило 4. Применяйте поточно-ориентированные библиотеки

    Если для обработки данных в «горячих» точках кода может понадобиться какая-либо библиотека, обязательно подумайте об использовании готовых функций вместо собственного кода. Одним словом, не пытайтесь изобрести велосипед, разрабатывая сегменты кода, функции которых уже предусмотрены в оптимизированных процедурах из состава библиотек. Многие библиотеки, в том числе Intel® Math Kernel Library (Intel® MKL) и Intel® Integrated Performance Primitives (Intel® IPP), уже содержат многопоточные функции, оптимизированные под многоядерные процессоры.

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

    Правило 5. Используйте подходящую модель многопоточности

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

    Минусом явной многопоточности является невозможность точного управления потоками.

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

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

    Правило 6. Результат работы программного кода не должен зависеть от последовательности выполнения параллельных потоков

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

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

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

    Правило 7. Используйте локальное хранение потоков. При необходимости назначайте блокировки на отдельные области данных

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

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

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

    Как поступить, если возникла необходимость синхронизировать доступ к большому объёму данных, например, к массиву, состоящему из 10000 элементов? Организовать единственную блокировку для всего массива – значит наверняка создать узкое место в приложении. Неужели придётся организовывать блокировку для каждого элемента в отдельности? Тогда, даже если к данным будут обращаться 32 или 64 параллельных потока, придётся предотвращать конфликты доступа к довольно большой области памяти, причём вероятность возникновения таких конфликтов – 1%. К счастью, существует своеобразная золотая середина, так называемые «блокировки по модулю». Если используется N блокировок по модулю, каждая из них будет синхронизировать доступ к N-й части общей области данных. Например, если организовано две таких блокировки, одна из них будет предотвращать доступ к чётным элементам массива, а вторая – к нечётным. В таком случае, потоки, обращаясь к необходимому элементу, определяют его чётность и устанавливают соответствующую блокировку. Количество блокировок по модулю выбирается с учётом количества потоков и вероятности одновременного обращения нескольких потоков к одной и той же области памяти.

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

    Правило 8. Измените программный алгоритм, если это требуется для реализации многопоточности

    Критерием оценки производительности приложений, как последовательных, так и параллельных, является время выполнения. В качестве оценки алгоритма подходит асимптотический порядок. По этому теоретическому показателю практически всегда можно оценить производительность приложения. То есть, при всех прочих равных условиях, приложение со степенью роста O(n log n) (быстрая сортировка), будет работать быстрее приложения со степенью роста O(n2) (выборочная сортировка), хотя результаты работы этих приложений одинаковы.

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

    В качестве иллюстрации последнего утверждения рассмотрим умножение двух квадратных матриц. Алгоритм Штрассена имеет один из лучших асимптотических порядков выполнения: O(n2.81), который намного лучше, чем порядок O(n3) алгоритма с обычным тройным вложенным циклом. Согласно алгоритму Штрассена, каждая матрица делится на четыре подматрицы, после чего производится семь рекурсивных вызовов для перемножения n/2 × n/2 подматриц. Для распараллеливания рекурсивных вызовов можно создать новый поток, который последовательно выполнит семь независимых перемножений подматриц, пока они не достигнут заданного размера. В таком случае количество потоков будет экспоненциально возрастать, а степень детализации вычислений, выполняемых каждым вновь образованным потоком, будет повышаться с уменьшением размера подматриц. Рассмотрим другой вариант – организацию пула из семи потоков, работающих одновременно и выполняющих по одному перемножению подматриц. По завершению работы пула потоков происходит рекурсивный вызов метода Штрассена для умножения подматриц (как и в последовательной версии программного кода). Если в системе, выполняющей такую программу, будет больше восьми процессорных ядер, часть из них будет простаивать.

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

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

    Чтобы вернуться на web-страницу учебных курсов по многопоточному программированию, перейдите по