Новосибирский Государственный Университет
Опубликован: 28.04.2010 | Доступ: свободный | Студентов: 684 / 60 | Оценка: 4.62 / 4.24 | Длительность: 10:21:00
Лекция 5:

Нити и стандартные библиотеки Unix

< Лекция 4 || Лекция 5: 12 || Лекция 6 >

Сигналы в многопоточном процессе

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

Примеры синхронных сигналов - SIGFPE (FloatingPointException,ошибка операции с плавающей точкой или целочисленное деление на ноль), SIGSEGV (Segmentation Violation, ошибка доступа к защищенной странице или сегменту памяти), SIGBUS ( Bus [Error], ошибка шины - в процессорах SPARC возникает при обращении к невыровненным словам), SIGPIPE (запись в трубу или сокет, другой конец которых закрыт).

Примеры асинхронных сигналов - SIGINT (генерируется терминальным драйвером при получении символа Ctrl-C), SIGALARM (генерируется таймером астрономического времени), SIGTERM (генерируется по умолчанию шелловской командой kill(1) ). Важно понимать, что если синхронный сигнал будет сгенерирован системным вызовом kill(2) или шелловской командой kill(1), он будет обрабатываться как асинхронный. Обработчики синхронных сигналов вызываются в той нити, в которой этот сигнал возник. Обработчики асинхронных сигналов вызываются в любой нити, способной обработать этот сигнал.

Системные вызовы signal(2), sigset(2) и sigaction(2) устанавливают глобальный обработчик сигнала, исполняющийся во всех нитях процесса. Штатного способа установить собственный обработчик сигнала в пределах нити не существует. Однако вопрос о том, в какой именно нити будет вызван обработчик, важен с нескольких точек зрения. Во первых, если обработчик сигнала использует longjmp(3C), то он должен вызываться в той нити, в которой был исполнен соответствующий setjmp(3C). В противном случае, longjmp(3C) приведет к разрушению стека нити и аварийному завершению программы. Аналогичная проблема может возникать, если обработчик сигнала генерирует исключения С++, только в этом случае проблема, скорее всего, ограничится необработанным исключением.

Во вторых, если нить и обработчик сигнала используют функции, не являющиеся AsynchSignal - Safe, им необходимо как-то координировать исполнение этих функций, скорее всего (но не обязательно) гарантируя, что они никогда не будут вызваны в одной нити. Для решения обеих проблем необходимо управлять тем, в каких нитях будут вызваны обработчики сигналов. Для этой цели существуют два средства - маска сигналов нити и функция pthread_kill(3C).

Маска сигналов нити функционально аналогична маске сигналов процесса, с той очевидной разницей, что она работает в пределах одной нити. Маска представляет собой множество (набор) сигналов. В большинстве Unix-систем это множество реализовано в виде битовой маски, но API для работы с масками, определяемое стандартом POSIX, допускает и другие реализации. Маска представляет собой непрозрачный тип sigset_t, над которым определены некоторые теоретико-множественные операции, описанные на странице руководства sigsetops(3C). Если сигнал установлен в маске (маскирован), то нить не будет обрабатывать этот сигнал. Если в масках одной или нескольких других нитей этого сигнала нет, обработчик будет вызван в одной из этих нитей. Если сигнал маскирован во всех нитях, он будет задержан до момента, пока в одной из нитей он не будет размаскирован.

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

Маска сигналов нити наследуется у родителя. Операции над маской сигналов нити осуществляются библиотечной функцией pthread_sigmask(3C). Эта функция аналогична системному вызову sigprocmask(2) и имеет похожие три параметра:

  • int how - определяет операцию, которую необходимо выполнить над маской. Может принимать три значения: SIG_BLOCK (сигналы, перечисленные в параметре set, будут заблокированы, остальные - оставлены без изменения), SIG_UNBLOCK (сигналы, перечисленные в параметре set, будут разблокированы, остальные - оставлены без изменения) и SIG_SETMASK (маска сигналов нити будет заменена на значение параметра set ).
  • sigset_t * set - входной параметр, набор сигналов. Точное значение этого параметра определяется значением параметра how. Если в качестве этого параметра передать нулевой указатель, параметр how будет проигнорирован и маска изменена не будет.

    Это можно использовать для получения текущего значения маски без ее изменения.

  • sigset_t *oset - выходной параметр, в нем сохраняется старое значение маски сигналов нити. Если в качестве этого параметра передать нулевой указатель, старое значение маски будет потеряно навсегда. Используя маски сигналов нитей можно создать выделенную нить или группу нитей, которые будут обрабатывать все сигналы.

Если необходимо обработать сигнал в определенной нити, можно использовать функцию pthread_kill(3C). Эта функция посылает указанный сигнал указанной нити. Однако если этот сигнал в этой нити замаскирован, он не будет доставлен.

Альтернативой традиционным обработчикам сигналов является системный вызов sigwait(2).

Этот вызов может обрабатывать один или несколько сигналов, определяемых параметром входным параметром sigset_t *set. Если нить не имеет ни одного ожидающего обработки сигнала из множества, заданного параметром set, sigwait(2) блокируется. Если такой сигнал появляется (даже если он маскирован), sigwait(2) извлекает этот сигнал из набора или очереди сигналов, ожидающих обработки, и возвращает управление. Таким образом, sigwait(2) представляет собой своего рода синхронный обработчик для одного или нескольких сигналов. В однопоточной программе такой обработчик был бы неудобен, но в многопоточной программе использование sigwait(2) позволяет упростить архитектуру межпоточного взаимодействия и решить ряд проблем, в том числе (но не только) связанных с функциями, не являющимися Asynch-Thread-Safe.

Обработчик сигнала sigwait(2) имеет приоритет перед традиционными функциями обработчиками, устанавливаемыми при помощи signal(2)/sigset(2)/sigaction(2). На практике означает, что если в программе есть обработчик сигнала, установленный signal(2), и при этом одна из нитей заблокирована в sigwait(2),то сигнал будет обработан при помощи sigwait(2). Однако не следует злоупотреблять этим фактом - ведь нить не все время будет заблокирована в sigwait(2), и в эти моменты времени может быть вызван традиционный обработчик.

В старых версиях Solaris, sigwait(2) возвращал номер сигнала в качестве кода возврата; в Solaris 10, если программа компилируется с ключом - D_POSIX_THREAD_SEMANTIC, sigwait(2) возвращает номер сигнала во втором (выходном) параметре. Код возврата при этом соответствует коду ошибки или равен 0, если ошибки не было.

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

fork(2) в многопоточной программе

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

Из такого описания следует, что fork(2) в многопоточной программе должен был бы приводить к копированию всех нитей родительского процесса. Собственно, в старых версиях Unix SVR4, в том числе и в старых версиях Solaris, так оно и было. Понятно, что такое поведение не очень логично при большинстве традиционных применений fork(2), поэтому в стандарте POSIX Thread API такое поведение не предусмотрено и требуется, чтобы при fork(2) дублировалась только нить, в которой произошел вызов fork(2).

Для обеспечения совместимости с приложениями, рассчитанными на старую семантику, Solaris 10 предоставляет системный вызов forkall(2). Этот вызов не соответствует стандартам и не имеет аналогов в других реализациях POSIX Threads API.

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

Для решения этой проблемы POSIX Thread API предоставляет функцию pthread_atfork(3C). Эта функция имеет три параметра типа "указатель на функцию", обозначаемые как prepare, parent и child.

Функция prepare вызывается после вызова fork(2), но перед собственно созданием дочернего процесса. Функции parent и child вызываются, соответственно, в родительском и дочернем процессе.

Многократный вызов pthread_atfork(3C) приводит к регистрации нескольких обработчиков, при этом обработчики prepare вызываются в порядке LIFO (Last In First Out, то есть в порядке, обратном тому, в котором делались вызовы pthread_atfork(3C) ), а parent и child - в порядке FIFO (First In First Out, то есть в том же порядке, что и pthread_atfork(3C) ).

Стандартное решение проблемы наследования блокировок, рекомендуемое на странице системного руководства pthread_atfork(3C), состоит в следующем. Если вы реализуете какую-либо библиотеку, использующую примитивы взаимоисключения или синхронизации, и хотите сделать ее Fork-Safe, вы должны зарегистрировать обработчики atfork. Вы можете реализовать отдельную тройку обработчиков на каждый примитив взаимоисключения или одну тройку на всю библиотеку. По ряду очевидных причин предпочтителен второй вариант, поэтому далее мы будем рассматривать его.

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

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

Примечание

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

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

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

< Лекция 4 || Лекция 5: 12 || Лекция 6 >