Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

1531

.pdf
Скачиваний:
2
Добавлен:
15.11.2022
Размер:
52.65 Mб
Скачать

В первой строке выделяется память из кучи с использованием стандартной С-функции malloc для сохранения списка element и запоминается в head указатель на element. head – это структура данных, которая помогает сохранять сведения об областях памяти, используемых приложениями. В строке 2 запоминается нагрузка, в строке 3 индицируется, что это последний элемент списка. В строке 4 устанавливается значение в tail – указатель на последний элемент списка. Когда список не пуст, функция addListener будет использовать tail для добавления элемента к списку.

Программа main использует функции, определенные на рис. 78:

int main(void) { addListener(&print); addListener(&print); update(1); addListener(&print); update(2);

return 0;

}

Она дважды регистрирует print как callback-функцию, затем выполняет update (устанавливает x = 1), снова регистрирует print и в завершение снова выполняет update (устанавливает x = 2). Функция print просто выводит на дисплей текущую величину. В процессе работы программы на дисплее появится последовательность 1 1 2 2 2.

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

В программе на рис. 78 состояние памяти включает значение глобальной переменной ‘x’ и список элементов, указываемых переменной head (другая глобальная переменная). Сам список представляется как связанный список, в котором каждый элемент является указателем (адресом) функции, которая вызывается при изменении ‘x’. Во время выполнения Си-программы в состояние памяти включается также стек, содержащий локальные переменные. Используя EFSM можно смоделировать выполнение простой С-программы, предполагая, что программа имеет фиксированное и ограниченное число переменных. Переменные Си-программы будут переменными EFSM. Состояния EFSM соответствуют местам в программе, апереходы – выполнению программы.

На рис. 79 приведена модель функции update из примера на рис. 78. Тип сигнала pure (строгий) означает, что в каждый момент либо сигнал

141

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

Рис. 79. Пример модели программы-шаблона observer на Си

Автомат переходит из начального состояния Idle, когда вызывается функция update. Вызов сигнализирует о наличии входного аргумента типа int. Когда выполняется этот переход, newx (в стеке) будет присвоено значение аргумента и глобальная переменная ‘x’ получит обновление. После первого перехода EFSM попадает состояние 31 (соответствует оператору element_t* element = head; ).

Затем следует безусловный переход в состояние 32 (while (element != 0)) и устанавливается значение element. Из состояния 32 есть два варианта перехода. Если element = 0, то EFSM перейдет в состояние Idle с выходным значением return (возврат из функции), иначе – переход в состояние 33. Переход из состояния 33 в 34 сопровождается вызовом функции listener с аргументом, равным переменной newx из стека. Переход из 34 обратно в 32 выполняется после получения сигна-

ла returnFromListener, индицирующего возврат из listener.

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

142

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

2.5.2. Потоки

Потоки (thread, или тред) – императивные программы, которые выполняются одновременно и разделяют (совместно используют) адресное пространство. Одновременность выполнения опирается на механизм прерываний, имеющийся во всех микропроцессорах.

Большинство ОС обеспечивают высокоуровневый механизм в противоположность прерываниям для императивных программ с разделяемой памятью. Механизм выступает в форме коллекции процедур, которые программист может использовать. Такие процедуры обычно соответствуют стандартизованным API (application program interface),

которые дают возможность писать переносимые программы (работают на различных процессорах и ОС). Такими API, например, являются Pthreads (или POSIX threads), интегрированные во многие современные ОС. Pthreads определяет множество Си-типов, функций и констант. Они стандартизованы IEEE в 1988 г. для унификации вариантов ОС Unix. В Pthreads поток определяется Си-функцией и создается вызовом функции создания потока.

На рис. 80 показана простая многопоточная Си-программа, использующая Pthreads. printN (строки 3...9) – функция, которую поток начинает выполнять при старте. Ее называют стартовой подпрограммой. Стартовая подпрограмма печатает передаваемый аргумент 10 раз и завершается. Функция main создает два потока (строки 14, 15), каждый из которых будет выполнять стартовую подпрограмму. Первый создаваемый поток будет печатать значение 1, а второй – 2. Когда запускаются эти программы, значения 1 и 2 будут в некотором чередующемся порядке, зависящем от планировщика задач. Обычно повторное выполнение вызовет отличный чередующийся порядок 1 и 2. Функция pthread_create создает поток и сразу завершается.

143

1

#include <pthread.h>

 

10 int main(void) {

2

#include <stdio.h>

 

11

pthread_t threadID1, threadID2;

3 void* printN(void* arg) {

 

12

void* exitStatus;

4

int i;

 

13

int x1 = 1, x2 = 2;

5

for (i = 0; i < 10; i++) {

 

14

pthread_create(&threadID1, NULL, printN, &x1);

6

printf("My ID: %d\n",

 

15

pthread_create(&threadID2, NULL, printN, &x2);

*(int*)arg);

 

16

printf("Started threads.\n");

7

}

 

17

pthread_join(threadID1, &exitStatus);

8

return NULL;

 

18

pthread_join(threadID2, &exitStatus);

9

}

 

19

return 0;

 

 

 

20

}

 

 

 

 

 

Рис. 80. Простая многопоточная программа на Си, использующая Pthreads

Стартовая подпрограмма может не начать выполняться перед выхо-

дом из pthread_create. Строки 17 и 18 используют функцию pthread_join

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

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

Как показано на рис. 80, стартовая подпрограмма может иметь аргумент и возвращать значение. Четвертый аргумент pthread_create является адресом аргумента, который передается в стартовую подпрограмму. Важно понимать модель памяти Си, иначе возможны очень тонкие ошибки. Предположим, создается поток внутри следующей функции:

1pthread_t createThread(int x) {

2pthread_t ID;

3pthread_create(&ID, NULL, printN, &x);

4return ID;

5}

144

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

2.5.2.1. Реализация потоков

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

Кооперативная многозадачность. Эта простая техника не преры-

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

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

145

таймера. Для ОС с системными часами период вызова обработчика прерываний системных часов является «мигом» (тик). Для версий Linux этот период варьируется от 1 до 10 мс.

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

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

2.5.2.2. Взаимное исключение

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

Рассмотрим функцию addListener из примера на рис. 78. Предположим, что она вызывается больше чем в одном потоке. Что может произойти неправильно? Первое – два потока могут одновременно модифицировать список связей структуры данных, что может привести к искажению данных. На рис. 81 приведены результаты моделирования этого асинхронного взаимодействия потоков. Предположим, например, что поток 1 приостановлен перед выполнением оператора tail–>listener = listener. Предположим, что, пока поток 1 приостановлен, другой по-

ток 2 вызывает addListener.

Когда поток 1 снова получает управление, он начинает выполняться с оператора tail–>listener = listener, но значение указателя tail уже изменено потоком 2. Оно больше не является величиной, вычисленной предыдущим оператором tail = tail–>next, до приостановки потока 1. Анализ показывает, что это может закончиться случайным указателем на listener (случайное значение после выделения памяти функцией malloc) в элементе списка i + 1. Второй слушатель, добавленный в список потоком 2, будет перезаписан потоком 1 и таким образом будет утрачен. Когда вызывается функция update, она пытается выполнить действия со случайным адресом listener?, что

146

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

Рис. 81. Результат одновременного изменения связанного списка двумя потоками

Подобные проблемы известны под названием «состояние гонок» (состязания). Две одновременных части кода в предыдущем примере состязались за доступ к одному и тому же ресурсу, и порядок, в котором они получали доступ, влиял на результат. Не все состязания имеют такие катастрофические последствия, как в примере. Один из путей предотвращения этого состоит в использовании замка для взаимного исключения, или мутекса (mutex). В Pthreads мутексы реализуются созданием структуры, называемой pthread mutex. Например, можно модифицировать функцию addListener следующим образом:

147

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void addListener(notifyProcedure* listener) { pthread_mutex_lock(&lock);

if (head == 0) {

...

} else {

...

}

pthread_mutex_unlock(&lock);

}

В первой строке создается и инициализируется глобальная переменная lock. В первой строке функции addListener берет замок. Принцип такой, что только один поток может владеть замком в каждый момент времени. Функция mutex_lock блокирует поток, пока вызывающий поток не получит замок. Итак, когда addListener вызывается потоком и начинает выполняться, pthread mutex не возвращает замок, пока им владеет другой поток. Получив замок, вызывающий поток сохраняет его за собой. Функция pthread_mutex_unlock вызывается в конце для освобождения замка. В многопоточном программировании серьезной ошибкой будет, если не освободить замок.

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

Функция update из примера на рис. 78 не модифицирует список слушателей, она его только читает. Предположим, что поток A вызывает addListener и откладывается после выполнения оператора tail–>next = malloc(sizeof(element_t)). Предположим, что, пока A от-

ложен, другой поток B вызывает update с кодом

element_t* element = head; while (element != 0) {

(*(element->listener))(newx); element = element->next;

}

Что случится при выполнении оператора element = tail–>next? В этой точке поток B будет работать со случайным содержимым, полу-

148

ченным от malloc при работе потока A, вызывая функцию, отсылаемую указателем element–>listener. И снова это приведет к ошибке сегментации.

Мутекс, добавленный в предыдущем примере, не устраняет этих ошибок. Он не защищает поток A от перевода в состояние «Отложен». Таким образом, необходимо защитить все возможные доступы к структуре данных с помощью мутексов. Модифицируем update следующим образом:

void update(int newx) { x = newx;

//оповещение слушателей. pthread_mutex_lock(&lock); element_t* element = head; while (element != 0) { (*(element->listener))(newx); element = element->next;

}

pthread_mutex_unlock(&lock);

}

Это защитит функцию update от чтения списка, пока не закончится его модификация другим потоком.

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

Большое количество мутексов в программах увеличивает риск взаимной блокировки (deadlock). Взаимоблокировка имеет место, когда некоторые потоки становятся постоянно блокированными, пытаясь получить замки. Например, когда поток A сохраняет замок 1 и затем блокируется при попытке получить замок 2, которым владеет поток B, затем блокируется поток B при попытке получить замок 1. От таких «смертельных объятий» не спастись. Программа должна быть прервана.

Предположим, что функции addListener и update на рис. 73 защищены мутексом, как в двух предыдущих примерах. Update содержит строку (*(element–>listener))(newx), которая вызывает функцию, указанную в элементе списка. Разумно для этой функции получить замок мутекса. Предположим, например, что функции слушателя необходимо обновить дисплей. Дисплей – типичный разделяемый ресурс и, следовательно, должен быть защищен собственным замком мутекса. Предположим, что поток A вызывает функцию update, которая достигает оператора (*(element–>listener))(newx) и затем блокируется, так как

149

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

Взаимную блокировку трудно преодолеть. В классической статье [27] даются необходимые условия возникновения взаимной блокировки, некоторые из которых могут быть удалены для преодоления взаимной блокировки. Простая техника состоит в использовании только одного замка для всей многопоточной программы. Эта техника, однако, не приводит к высокомодульной программе. Более того, она может затруднить удовлетворение ограничениям реального времени, так как некоторые разделяемые ресурсы (например, дисплей) могут нуждаться в достаточно длительном удержании, что приведет к просрочке времени исполнения другими потоками.

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

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

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

150

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]