Требуется обновление браузера.

Организация межпотокового взаимодействия с использованием объектов ядра операционной системы [окончание]


Просмотров: 3070
Август 2012 года
А.А. Огинский, А.М. Набатчиков, Е.А. Бурлак. Организация межпотокового взаимодействия с использованием объектов ядра операционной системы // Вестник компьютерных и информационных технологий. – 2012. – №8 (98). – С. 52-56.
ВАК
УДК: 004.032.3:004.272.4
Ключевые слова: многопоточные приложения; межпоточное взаимодействие; объекты ядра; синхронный и асинхронный опрос устройства

"ВЕСТНИК КОМПЬЮТЕРНЫХ И ИНФОРМАЦИОННЫХ ТЕХНОЛОГИЙ"

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

Использование потоков


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

  • разработка оболочки библиотеки [1, 2], обеспечивающей более удобную работу с API (Application Programming Interface) устройства, в том числе на языках, не обладающих необходимым инструментарием для работы с потоками. В этом случае оболочка может быть реализована на C++, например, в виде динамически подключаемой библиотеки для использования в приложении, написанном на другом языке [3]. Такая необходимость возникла при исследованиях, представленных в [4];
  • если помимо регистрации данных на каждой итерации осуществляются операции, бо́льшую часть времени не зависящие от состояния устройства, вследствие чего их можно выполнять параллельно с опросом. Например, получаемые с устройства данные подлежат длительной обработке, в ходе которой текущее состояние источника информации игнорируется. При этом актуальность данных определяется разницей во времени между их получением и началом операций над ними.

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

i06.png
Рис. 6. Схема работы варианта реализации оболочки, позволяющей выделить процесс получения данных в отдельный поток

Асинхронный опрос источника данных, например органа управления летательного аппарата в авиатренажёре, вынесен в отдельный поток: постановка запроса устройству происходит за время t1, устройство формирует ответ за время t2, поток записывает в некоторый участок памяти (буфер) результат опроса, затрачивая на эту операцию время t3. Поток приложения, использующий оболочку, выполняет длительную обработку информации (расчет параметров динамики летательного аппарата, визуализацию закабинной обстановки и т.д.) за время t4, затем тратит время t5 на чтение актуальных (на сколько позволяет скорость работы устройства) данных из буфера.

Таким образом, устройство опрашивается постоянно: каждый новый блок данных заменяет собой предыдущий. Данные запрашиваются по мере необходимости (на рис. 6 блоки A и C) за время t5 < t1 + t2, при этом невостребованные данные перезаписываются на более поздние (на рис. 6 блок B).

При использовании данной организации взаимодействия необходимо рассмотреть два случая:

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

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

Реализация многопоточного приложения


В общем случае интерфейс разрабатываемой библиотеки имеет три экспортируемых функции:

  • запуск опроса;
  • получение данных;
  • остановка опроса.

С буфером, хранящим актуальный блок информации с устройства, взаимодействуют две функции, монопольно захватывающие ресурс:

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

Необходимо запрограммировать поведение потока, создав отдельную функцию.

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

Как показывает практика, многие ошибки совершаются уже на этапе создания потока. Часто в примерах реализаций можно встретить использование Windows API функции
CreateThread
, которая приводит к ожидаемому результату, но может привести к некоторым проблемам, поэтому не должна использоваться, если программа создаётся на С/С++ [5]. Программисты, тестируя приложение в версиях Windows NT/2000/XP, порой забывают о параметре
LPDWORD lpThreadId
, который в Windows 95/98/Me не может принимать значение
NULL
[6] (которое, как правило, и передаётся). Рекомендуется использовать функции
_beginthread
и
_beginthreadex
(Visual C++ и GCC) или другой эквивалент, в зависимости от компилятора. Для работы с потоками необходимо подключить заголовочный файл
process.h
(листинг №2).

Листинг № 2. Создание потока
12345678910111213141516
void Start(){
    InitializeCriticalSection(&CrSc);
// инициализация критической секции (см. ниже)
    STOP=0; // сброс флага останова
    BUFF=new double[size]; // буфер
    for(short i=0;i<size;i++)
        BUFF[i]=-2; // неопределённое (начальное) значение элементов буфера
    unsigned lpThreadId;
//создание потока
    Thr=(HANDLE)_beginthreadex(NULL, // дескриптор защиты
                               0, // начальный размер стека
                               ThFunc, // функция потока
                               NULL, // параметр потока
                               0, // опции создания
                               &lpThreadId); // идентификатор потока
}

Созданный поток сразу начинает выполнять функцию
ThFunc
, передав в неё указанный параметр (в данном случае
NULL
). Тело функции потока во многом повторяет код листинга № 1 (см. начало в № 7, 2012). Обработка нового блока информации от устройства сводится к вызову функции записи в буфер (листинг № 3).

Листинг № 3. Запись в буфер
12345678
void Write(double *FROM){
    EnterCriticalSection(&CrSc);
//поток захватывает ресурс или ждёт его освобождения
    memcpy(BUFF,FROM, size*sizeof(double));
//копирование данных в буфер, из области памяти, заданной указателем FROM
    LeaveCriticalSection(&CrSc);
//освобождение ресурса
}

Проблема монопольного доступа к буферу решается в данной реализации при помощи так называемых «критических секций». Участок исходного кода программы, заключённый между командами
EnterCriticalSection
и
LeaveCriticalSection
, не может выполняться одновременно более чем одним потоком. После того, как один из потоков войдёт в критическую секцию, любой другой поток будет приостановлен на вызове
EnterCriticalSection
до момента выхода первого потока из критической секции. Первый поток может быть вытеснен системой (процессорное время будут получать и другие потоки), но ни один поток не войдёт в секцию до её освобождения. Таким образом, гарантируется монопольный доступ к ресурсу, если все участки кода, реализующие взаимодействие с ним, будут объявлены критическими (листинг № 4).

Листинг № 4. Чтение буфера
12345678
void Read(double *TO){
    EnterCriticalSection(&CrSc);
//поток захватывает ресурс или ждёт его освобождения
    memcpy(TO,BUFF,size*sizeof(double));
//копирование данных из буфера, в область памяти, заданную указателем TO
    LeaveCriticalSection(&CrSc);
//освобождение ресурса
}

Критическая секция реализуется с использованием структуры
CRITICAL_SECTION
, экземпляр которой должен быть инициализирован при помощи функции
InitializeCriticalSection
перед использованием и удалён, после того как необходимость в секции отпала, при помощи функции
DeleteCriticalSection
. Работу с критическими секциями можно сделать более гибкой, используя функции
TryEnterCriticalSection
и
InitalizeCriticalSectionAndSpinCount
[5]: первая позволяет совершить попытку входа в критическую секцию без блокирования потока (в Windows 98 функция не реализована), вторая – предварить переход потока в состояние ожидания заданным количеством итераций спин-блокировки.

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

Мьютексы и критические секции одинаковы в том, как они влияют на планирование ждущих потоков, но различны по некоторым другим характеристикам [1]. Преимущество критических секций – простота в использовании и очень быстрое выполнение, так как реализованы на основе Interlocked-функций. Главный недостаток – нельзя синхронизировать потоки в разных процессах.

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

Завершение потока можно реализовать четырьмя способами:

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

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

Листинг № 5. Завершение потока
1234567
void Stop(){
    InterlockedExchange(&STOP,1);// флаг «стоп» поднят
    WaitForSingleObject(Thr,INFINITE);// ждём завершения потока
    CloseHandle(Thr);// закрываем дескриптор
    DeleteCriticalSection(&CrSc);// удаляем критическую секцию
    delete []BUFF;// удаляем буфер
}

В цикле опроса устройства (внутри функции
ThFunc
) поток проверяет значение флага
STOP
на каждой итерации как условие завершения опроса.

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

Так как поток не завершается мгновенно, необходимо приостановить выполнение дальнейших операций - уничтожение ресурсов, которые поток может использовать, до его полного завершения. Корректной реализацией такого ожидания является не задержка на фиксированный большой, достаточный для завершения потока, интервал времени, а вызов функции
WaitForSingleObject
с передачей ей дескриптора потока (листинг № 5).

Дополнительные аспекты


При разработке многопоточного приложения придётся отказаться от паттерна проектирования «одиночка» (singleton, [6]): так называемый синглтон Майерса не является потокобезопасным. Полностью переносимое решение проблемы в рамках существующего стандарта языка отсутствует [7].

Поток приложения (даже однопоточного) может последовательно выполняться на разных ядрах. По умолчанию Windows использует нежесткую привязку (soft affinity) потоков к процессорам для повторного использования данных в кэше процессора, программист может дополнительно реализовать жёсткую привязку (hard affinity) при помощи функции
SetProcessAffinityMask
[5]. Если жёсткая привязка не указана, два последовательных вызова приложением функции могут быть выполнены на разных процессорах. Многопоточная архитектура программы только увеличивает вероятность такого события.

На некоторых многоядерных процессорах при работе в ОС Windows Server 2000, Windows Server 2003 и Windows XP описанное выше на разных ядрах приводит к сложной ситуации при взаимодействии со счётчиком производительности ядра [8], вызванной разной, отличной от номинальной, частотой работы ядер. Такая ситуация вызвана применением технологий автоматического понижения (AMD Cool'n'Quiet и Intel SpeedStep) или повышения (AMD Turbo Core и Intel Turbo Boost) частоты работы ядер. Таким образом, функция
QueryPerformanceCounter
, использующаяся для замеров времени с высокой точностью будет работать некорректно - рассчитанное время будет изменяться нелинейно, в том числе идти вспять.

В зависимости от конкретной решаемой задачи возможно использование механизмов пула потоков [5], упрощающих создание, уничтожение и контроль за потоками.

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

Заключение


  • Асинхронный опрос позволяет повысить частоту обработки сигнала и снизить время ожидания ответа за счёт распараллеливания задачи с надлежащим использованием механизмов межпроцессного взаимодействия.
  • Атомарный доступ к отдельной переменной в пользовательском режиме реализуется посредством Interlocked-функций.
  • Спин-блокировка – не всегда лучший примитив синхронизации. Затраты на переход в режим ядра для использования его объектов могут не оправдаться.
  • Целесообразность применения мьютексов или критических секций определяется конкретной решаемой задачей.
  • Завершение потока вызовами
    ExitThread
    (самоуничтожение),
    TerminateThread
    (завершение по запросу от другого потока) или окончанием процесса, содержащего данный поток, некорректно.
  • Корректный алгоритм определения факта завершения потока – вызов функции
    WaitForSingleObject
    с передачей в неё дескриптора потока.
  • Вместо функций Windows API
    CreateThread
    и
    ExitThread
    должны использоваться эквивалентные функции из библиотеки применяемого компилятора.
  • На работоспособность многопоточного приложения может повлиять версия операционной системы: некоторые функции могут быть определены, но не реализованы, или не поддерживать некоторые значения аргументов. Windows 98 при работе с многоядерными процессорами использует только одно ядро, игнорируя остальные.
  • При разработке многопоточного приложения во многих случаях целесообразно отказаться от паттерна проектирования «одиночка».
  • Если счётчик производительности ядра возвращает некорректные значения, следует, в первую очередь, убедиться, что эта ситуация не является следствием особенностей взаимодействия аппаратной части ПЭВМ и операционной системы.

Библиографический список


  • Таненбаум Э. Современные операционные системы. 3-е изд. СПб: Питер, 2010. 1120 с.
  • Страуструп Б. Язык программирования С++. М.: Бином, 2011. 1136 с.
  • Бей И. Взаимодействие разноязыковых программ. Руководство программиста. М.: Вильямс, 2005. 880 с.
  • Экспериментальные исследования работы человека-оператора в режиме слежения при установке на максимальное быстродействие // Тр. ГосНИИАС. Вопросы авионики. 2010. Вып. 1(19). С. 22 - 32.
  • Рихтер Дж. Windows для профессионалов: создание эффективных Win32 приложений с учетом специфики 64-разрядной версии Windows: пер. c англ. 4-е изд. СПб.: Питер, 2001. 752 с.
  • Приемы объектно-ориентированного проектирования. Паттерны проектирования / Э. Гамма и др. СПб: Питер, 2007. 366 с.
  • Meyers S., Alexandrescu A. C++ and the Perils of Double-Checked Locking // Dr. Dobbs Journal. 2004. July – August. С. 46–61.
  • Programs that Use the QueryPerformanceCounter Function May Perform Poorly in Windows Server 2000, in Windows Server 2003, and in Windows XP [Электронный ресурс] // Microsoft Technical support. [сайт]. [2011]. URL: http://support.microsoft.com/kb/895980/en-us?fr=1 (дата обращения: 24.05.2012).

Комментарии

Инкогнито
  Загружаем captcha