Мутексы
Пытлив умом был Федор, но не мудр
Не разгадав явление природы
Решил он, что ларек снесли за ночь
В нескольких предыдущих лекциях мы видели, что основная проблема при разработке многопоточных приложений - это обеспечение согласованности разделяемых структур данных. На практике эта проблема решается главным образом при помощи концепций критических секций и взаимоисключения.
Критическая секция -это участок кода программы, вовремя исполнения которого программа либо полагается на целостность разделяемой структуры данных, либо тем или иным образом нарушает эту целостность. Для примера рассмотрим операции над отсортированным массивом без дублирующихся элементов. Основные операции над таким массивом - поиск, вставка и исключение элемента. Поиск в отсортированном массиве удобно осуществлять дихотомическим или, что то же самое, двоичным алгоритмом. Этот алгоритм работает очень быстро, но если условие сортировки по какой-то причине будет нарушено, попытка дихотомического поиска завершится неудачно.
Все способы вставки и исключения элемента в отсортированном массиве предполагают либо [временное] нарушение условия сортировки, либо появление в массиве дубликатов, либо то и другое одновременно. Поэтому все перечисленные операции являются критическими секциями. Для обеспечения нормальной работы этих операций нам необходимо тем или иным способом гарантировать, что они не будут выполняться одновременно над одним и тем же массивом. Это и называется взаимоисключением.
Внимательный читатель может отметить, что в отсортированном массиве очень сложно реализовать параллельное исполнение нескольких вставок или удалений, и практически невозможно реализовать параллельное исполнение вставки и поиска, но несколько поисков вполне могут выполняться одновременно.
Разделяемые данные являются основным средством взаимодействия между нитями. Собственно, если бы нам были не нужны разделяемые данные, мы могли бы использовать процессы вместо нитей.
Главная сложность при определении критической секции состоит в том, что это, строго говоря, не формализованное понятие. Для каждого конкретного алгоритма можно точно определить, какие структуры данных с его точки зрения являются согласованными. Но критические секции связаны с данными, а с точки зрения данных невозможно определить, какие алгоритмы будут использоваться для работы с этими данными. Поэтому невозможно автоматически определять критерии согласованности данных и границы критических секций в коде. Все известные методики работы с разделяемыми данными - как с данными, размещенными в оперативной памяти, так и с базами данных - в конечном итоге опираются на то, что программист должен определить и явно указать в коде границы критических секций. Например, в реляционных СУБД критические секции соответствуют транзакциям. Использовать простые флаговые переменные для защиты критических секций невозможно. Действительно, последовательность команд, включающая в себя проверку флаговой переменной и ее установку, сама представляет собой критическую секцию и нуждается в защите.
Существует ряд решений задачи взаимоисключения на нескольких флаговых переменных, например алгоритм Деккера, который состоит в использовании двух флаговых переменных. Каждая из взаимодействующих нитей устанавливает "свой" флаг, а проверяет "чужой".
Этот алгоритм сложно распространить на взаимодействие произвольного количества нитей, поэтому на практике он очень редко применяется.
Большинство современных процессоров в той или иной форме предоставляют атомарную (неделимую) операцию проверки и установки флага. Например, в процессорах x86 это команда EXCHG (exchange, обменять), которая обменивает значения регистра и ячейки памяти.
Единственный разумный способ ожидать освобождения флаговой переменной - это холостой цикл. Исполняющая такой цикл программа занимает процессор, но не делает ничего полезного. Примитив взаимоисключения, использующий проверку флага в холостом цикле и его проверку и установку при помощи специальной атомарной команды процессора, называется спинлоком (spinlock). Спинлоки используются многими операционными системами для взаимодействия между копиями ядра ОС, исполняющимися на разных процессорах многопроцессорной машины, а также в некоторых приложениях жесткого реального времени. POSIX Thread API также предоставляет спинлоки, но поддержка соответствующей группы типов и функций опциональна (не обязательна для всех реализаций). Solaris поддерживает спинлоки POSIX, но в нашем курсе мы их изучать не будем.
Для взаимодействия между пользовательскими процессами и нитями современные ОС и многопоточные среды программирования предоставляют более сложные примитивы, объединяющие проверку флага и засыпание, пробуждение и установку флага и, наконец, сброс флага и пробуждение другой нити в атомарные операции.
Под словом "примитив" мы в данном случае понимаем непрозрачный тип данных, над которым определен фиксированный набор операций.
Мутекс является одним из таких примитивов. Слово мутекс (mutex) происходит от сокращения словосочетания mutual exclusion - взаимное исключение. В некоторых русскоязычных публикациях эти объекты также называют мьютексами. Такая транскрипция ближе к правильному английскому произношению этого слова.
Мутекс может находиться в двух состояниях - свободном и захваченном. Над мутексом определены две основные операции - блокировка (lock, захват, asquire) и снятие (unlock, освобождение, release). Блокировка свободного мутекса приводит к его переводу в захваченное состояние. Попытка блокировки захваченного мутекса приводит к засыпанию (блокировке) нити, которая пыталась выполнить эту операцию. Освобождение свободного мутекса - недопустимая операция; в зависимости от особенностей реализации эта операция может приводить к непредсказуемым последствиям или к ошибке или просто игнорироваться. Освобождение занятого мутекса приводит к переводу мутекса в свободное состояние; если в этот момент на мутексе были заблокированы одна или несколько нитей, одна из этих нитей пробуждается и захватывает мутекс.
Главной особенностью мутексов является тот факт, что захват и освобождение мутекса должны производиться одной нитью. Поэтому можно говорить о том, что нить, захватившая мутекс, владеет им.
Применение мутексов для решения задачи взаимоисключения очевидно. Мы должны связать с каждым разделяемым ресурсом мутекс. Когда нить входит в критическую секцию, связанную с ресурсом, она должна захватить мутекс, а когда выходит из нее - освободить.
Создание и уничтожение мутексов POSIX Thread Library
Мутексы в POSIX Thread API имеют тип pthread_mutex_t. Это непрозрачный тип, операции над которым должны осуществляться соответствующими функциями библиотеки POSIX Threads. Внутренняя структура объектов этого типа не документирована и может различаться в разных реализациях и даже в разных версиях одной реализации POSIX Threads.
Перед использованием мутекс необходимо инициализировать. Это может делаться функцией pthread_mutex_init(3C) или присваиванием мутексу константы PTHREAD_MUTEX_INITIALIZER, определенной в <pthread.h>. Функция pthread_mutex_init(3C) получает два параметра, указатель на инициализируемый объект и указатель на описание атрибутов мутекса, структуру pthread_mutex_attr_t. Все параметры мутекса задаются в pthread_mutex_attr_t, которая рассматривается далее на этой лекции. Результат выполнения остальных операций над неинициализированным мутексом не определен; это может приводить к блокировке нити на неопределенное время или к аварийному завершению процесса.
Перед освобождением памяти из-под мутекса мутекс необходимо уничтожить. Это делается функцией pthread_mutex_destroy(3C). Операции над мутексом могут приводить к размещению дополнительной памяти или объектов ядра ОС, поэтому уничтожение мутекса без выполнения pthread_mutex_destroy может приводить к утечке памяти или исчерпанию системных ресурсов. Выполнение операции pthread_mutex_destroy над мутексом, на котором заблокирована одна или несколько нитей, приводит к неопределенным последствиям. Дальнейшие операции над этим мутексом также приводят к неопределенным последствиям.
Операции над мутексом
Над мутексом определены четыре основные операции: pthread_mutex_lock(3C), pthread_mutex_unlock(3C), pthread_mutex_trylock(3C) и pthread_mutex_timedlock(3C). Семантика операций lock и unlock описывалась в начале лекции; единственное, что следует отметить - что pthread_mutex_lock(3C), в отличие от большинства других блокирующихся операций, не является точкой прерывания.
Pthread_mutex_trylock(3C) пытается захватить мутекс; если он занят, операция возвращает ошибку EAGAIN.
Pthread_mutex_timedlock(3C) - блокировка с тайм-аутом. Эта функция пытается захватить мутекс и блокируется, если это невозможно, но не дольше чем до указанного момента. Если функция вернула управление по тайм-ауту, она возвращает ошибку ETIMEOUT. Solaris предоставляет также функцию pthread_mutex_reltimedlock_np(3C), которая задает относительный тайм-аут, т.е. интервал времени от момента вызова. Суффикс _np у имени функции обозначает, что эта функция не входит в стандарт POSIX.
По умолчанию операции над мутексами не осуществляют никаких проверок. При повторном захвате мутекса той же нитью произойдет мертвая блокировка. Результат других некорректных последовательностей операций, например захвата мутекса в одной нити и освобождения в другой, или многократного освобождения, не определен стандартом, хотя в большинстве распространенных реализаций, в том числе и в Solaris 10, результаты таких операций обычно достаточно безобидны. Используя pthread_attr_t, при инициализации мутекса можно задать параметры, которые заставляют систему делать проверки при работе с мутексами и возвращать ошибки при некорректных последовательностях операций. Разумеется, операции над мутексами без проверок гораздо дешевле, чем с проверками.
Групповых операций над мутексами POSIX Thread Library не предоставляет. При практическом использовании мутексов следует иметь в виду, что мутекс похож на дорожный светофор: он указывает, что вам не следует пересекать дорогу, но не ставит вам физических препятствий. Иными словами, если программист по какой-то причине забыл захватить мутекс перед входом в критическую секцию или неверно определил границы критической секции, среда программирования сама по себе не сообщит ему об ошибке. Ошибки такого рода (так называемые ошибки соревнования, race condition) очень сложно обнаруживать при тестировании, особенно если проводить тестирование на машине с небольшим количеством процессоров. Даже если такая ошибка будет обнаружена, разработчику может оказаться нелегко воспроизвести ее.
Этот недостаток характерен для всех примитивов межпоточного взаимоисключения и синхронизации. Фактически, он является оборотной стороной того, что потоки разделяют память и никак не защищены друг от друга.
В поставку SunStudio 11 входит инструмент, который может использоваться для обнаружения ошибок соревнования в коде. Этот инструмент предполагает компиляцию и сборку программы со специальными ключами. Компилятор при этом вставляет в код специальные инструкции для сбора статистики обращений к данным. После прогона программы можно просмотреть эту статистику и, в том числе, обнаружить операции над разделяемыми данными, не защищенные мутексом.
Мертвые блокировки
При использовании мутексов и большинства других примитивов взаимоисключения возникает проблема мертвых блокировок ( deadlock ). Иногда мертвые блокировки также называют тупиками ( dead end ). Простейшая форма мертвой блокировки - это ситуация, когда одна и та же нить два раза пытается захватить один и тот же мутекс.
В реальном коде такая мертвая блокировка может возникать, например, в следующей ситуации: программист пытается реализовать класс, все методы которого являются Thread- safe. Для этого он включает в структуру данных мутекс и захватывает и освобождает этот мутекс при каждом вызове метода своего объекта.
Затем у программиста возникает потребность в вызове собственного метода (см. пример 1.1):
class monitor { private: pthread_mutex_t mx; public: int method1() { pthread_mutex_lock(&mx); … pthread_mutex_unlock(&mx); } int method2() { pthread_mutex_lock(&mx); … method1(); … pthread_mutex_unlock(&mx); } }1.1. Мертвая блокировка с одним ресурсом и одной нитью
POSIXThreadLibrary реализует несколько способов борьбы с этой ошибкой, которые рассматриваются далее на этой лекции.
Более сложный - и более опасный - сценарий возникновения такой же ошибки - вызов в обработчике сигнала функций, которые содержат внутренние блокировки и не являются Asynch-Signal-Safe.
Еще более сложный сценарий мертвой блокировки включает в себя минимум две нити и минимум два ресурса, например A и B. Если одна нить захватывает эти ресурсы в порядке lock(A) ; lock(B), а другая - в порядке lock(B) ; lock(A) ;, при неудачном стечении обстоятельств нити могут заблокировать друг друга. Такая блокировка может включать в себя и более чем две нити; формальным критерием такой блокировки является возникновение цикла в графе ожидающих друг друга нитей.
При использовании мутексов, теоретически можно обнаруживать ошибки такого рода на уровне библиотеки, выстраивая граф ожидающих друг друга нитей и находя в нем циклы. На практике, стандарт POSIX не требует выполнения такой проверки даже для мутексов типа PTHREAD_MUTEX_ERRORCHECK, и все проверенные мной реализации не осуществляют такой проверки. Таким образом, борьба с мертвыми блокировками, включающими несколько нитей, оказывается обязанностью программиста.
Два основных способа борьбы с мертвой блокировкой - это упорядочение блокировок и групповые захваты мутексов. Более радикальные способы включают в себя запрет удерживать в каждый момент времени более одного мутекса и полный отказ от разделяемых данных и переход к взаимодействию через трубы или очереди сообщений. Первый способ слишком радикален для большинства практических применений; второй способ лишает нас основного преимущества многопоточных программ (возможности взаимодействовать через разделяемую память) и не будет рассматриваться в этом курсе. Средств группового захвата мутексов POSIX Thread API не предоставляет, хотя такой захват можно реализовать самостоятельно, используя pthread_mutex_trylock(3C) и условные переменные (условные переменные рассматриваются в следующей лекции).
Оба эти способа страдают тем недостатком, что они нарушают требования модульности и инкапсуляции. При работе с программным модулем мы должны знать обо всех блокировках, которые этот модуль может установить. Особенно важен этот недостаток при использовании групповых блокировок. Упорядочение блокировок меньше страдает от этого недостатка.
Действительно, большинство реальных приложений и библиотек имеют многослойную структуру; при этом функции и методы "верхних" слоев вызывают функции и методы "нижних" слоев, но вызовы в обратном направлении происходят редко или вообще не происходит. Такая структура приложения создает естественный порядок захвата блокировок, и все, что требуется от программиста - не нарушать этот порядок умышленно, например, удерживая какую-то блокировку между вызовами функций своего слоя.
Впрочем, ряд приемов программирования, таких, как хуки, callback, сигналы, при определенных обстоятельствах - виртуальные методы и обработка исключений - приводят к нарушению порядка межслойного взаимодействия и, соответственно, могут привести к нарушению порядка захвата блокировок. Проявление этой проблемы мы видели на предыдущей лекции, когда обсуждали сигналы и атрибут Async-Signal-Safe.