Продолжаем изучать планирование маленьких потоков. Я уже рассказала про два средства в ядре Linux, которые часто используются для отложенной обработки прерываний. Сегодня речь пойдет о совсем другой сущности — protothread Adam Dunkels, которые хоть и выбиваются из ряда, но в контексте рассматриваемой темы совсем не лишние.
А также:
- Многозадачность в ядре Linux: прерывания и tasklet’ы
- Многозадачность в ядре Linux: workqueue
- Protothread и кооперативная многозадачность
Что это и зачем все это нужно?
Объединить отложенную обработку прерываний и кооперативную многозадачность в одном цикле статей я решила не случайно. Те сущности, что я рассматриваю в цикле, и идеи, которые они реализуют, имеют большое значение не только в контексте обозначенных задач, но также в целом для систем реального времени.
Главным образом их объединяет вопрос планирования. Tasklet’ы, речь о которых шла в первой статье, например, работают по правилам невытесняющей многозадачности. Кстати, про кооперативную многозадачность я уже довольно подробно рассказывала в одной из предыдущих статьей.
Кооперативная многозадачность и protothread
Protothread’ы — это легковесные бесстековые потоки, реализованные на чистом C. Одно из возможных применений — реализация кооперативной многозадачности, не требующей больших накладных расходов. Например, их можно использовать во встроенных системах с жестким ограничением по памяти или в узлах беспроводной сенсорной сети.
На хабрахабре уже есть хорошая статья ldir про многозадачность на основе protothreads, в ней рассматривается практическая сторона вопроса: возможности библиотеки, как ее применять. Статья сопровождается интересными и показательными примерами.
Здесь же будет больше о том, как это работает. Далее в этом разделе я приведу вольный и несколько переработанных перевод информации с сайта Adam Dunkels, создателя protothreads, где подробно описана как сама библиотека, так и детали реализации, так что желающие могут насладиться оригиналом и перейти к следующему разделу.
Основные особенности и преимущества protothreads:
- Реализация последовательно потока управления без использования сложных машин состояний и полной многопоточности,
- Использование условной блокировки внутри функций на C,
- Protothread’ы очень легкие, так как у них отсутствует стек,
- Protothread’ы могут вызывать другие функции, но не могут блокироваться внутри вызываемой функции, поэтому нужно каждую функцию, использующую блокировку, обернуть в дочерний protothread. Таким образом, используется только явная блокировка.
Protothread’ы реализованы с помощью продолжений (continuation), которые представляют текущее состояние исполнения в конкретном месте в программе, но при этом не поддерживает историю вызовов и локальные переменные. Здесь нужно быть особо внимательными — использовать локальные переменные нужно очень аккуратно! Выделенного стека у protothread’ов нет, локальные переменные хранить негде. Если поток используется лишь однажды, то переменные можно объявить как статические. В противном случае можно, например, обернуть protothread в свою структуру, и хранить переменные прямо там. В общем, я хочу сказать, что нужно отдавать себе отчет в том, что неправильное использование локальных переменных может привести к непредсказуемым результатам.
В рассматриваемой реализации роль состояния играет целое положительное число — текущий номер строки программы. Управление происходит с помощью switch(), наподобие того, как это происходит в устройстве Даффа и со-программах реализации Simon Tatham. Protothread’ы похожи на генераторы в Python, только предназначены они для разных целей: protothread’ы предоставляют блокировку внутри функции на C, тогда как Python предоставляет множественный выход из генератора.
Важное ограничение реализации: код внутри protothread сам не может в полной мере использовать оператор switch(). Впрочем, это ограничение можно с помощью возможностей некоторых компиляторов, например, gcc.
Вся библиотека реализована на макросах, так как макросы, в отличие от простых функций, могут изменять поток управления только лишь средствами стандартных конструкций C.
Основное API включает в себя следующие макросы:
- Макрос инициализации PT_INIT,
- Макросы PT_BEGIN и PT_END, объявляющие начало и конец protothread,
- Макросы ожидания выполнения условия PT_WAIT_UNTIL и PT_WAIT_WHILE,
- Макросы ожидания выполнения дочерних protothread’ов PT_WAIT_THREAD и PT_SPAWN,
- Макросы перезапуска PT_RESTART и выхода PT_EXIT,
- Макрос планирования (по сути, запуска) PT_SCHEDULE,
- Макрос произведения значения PT_YIELD,
- Макросы для использования семафоров PT_SEM_INIT, PT_SEM_WAIT и PT_SEM_SIGNAL.
Разберемся теперь, как работают макросы. Для начала рассмотрим программу, использующую protothread’ы. Protothread example выполняет вечный цикл, где он сначала ждет, пока счетчик достигнет определенного значения, потом выводит сообщение и сбрасывает счетчик.
#include "pt.h"
static int counter;
static struct pt example_pt;
static
PT_THREAD(example(struct pt *pt))
{
PT_BEGIN(pt);
while(1) {
PT_WAIT_UNTIL(pt, counter == 1000);
printf("Threshold reachedn");
counter = 0;
}
PT_END(pt);
}
int main(void)
{
counter = 0;
PT_INIT(&example_pt);
while(1) {
example(&example_pt);
counter++;
}
return 0;
}
Теперь взглянем на упрощенную версию макросов, использующихся в примере:
struct pt { unsigned short lc; };
#define PT_THREAD(name_args) char name_args
#define PT_BEGIN(pt) switch(pt->lc) { case 0:
#define PT_WAIT_UNTIL(pt, c) pt->lc = __LINE__; case __LINE__:
if(!(c)) return 0
#define PT_END(pt) } pt->lc = 0; return 2
#define PT_INIT(pt) pt->lc = 0
Структура pt состоит из единственного поля lc (сокращенно от local continuation). Обратите внимание на PT_BEGIN и PT_END, которые соответственно открывают и закрывают оператор switch, а также на чуть более сложный макрос PT_WAIT_UNTIL. В нем используется встроенный макрос __LINE__, возвращающий номер текущей строки программного файла.
Сравним исходную и развернутую препроцессором версии example:
|
|
Protothread example теперь выглядит как обычная функция на C. Возвращаемое значение используется для того, чтобы определить статус protothread: заблокирован ли он в ожидании чего-то, завершен, вышел, или сгенерировал очередное значение. PT_BEGIN макрос содержит инструкцию case 0, таким образом, код, идущий сразу после этого макроса, будет исполняться первым, ведь начальное значение pt->lc и есть 0.
Посмотрите, во что развернулся макрос PT_WAIT_UNTIL. Полю pt->lc теперь присваивается 12, это номер строки, и тут же появляется case 12 — благодаря этому switch точно знает, куда прыгать. В случае, если условие не выполнено, функция возвращает 0, что означает, что поток ожидает (в самой библиотеки все эти константы вынесены). В следующий раз, когда в main вызовется example(), выполнение, соответственно, продолжится с case 12, то есть с проверки, выполнено ли условие ожидания. Как только счетчик достигнет 1000, условие станет истинным, и example() теперь не вернет 0, а напечатает сообщение и обнулит счетчик. Дальше, как положено, переходим в начало тела цикла.
В одной из предыдущих статей я приводила код примитивного кооперативного планировщика (раздел “Невытесняющий планировщик с сохранением порядка вызовов”). Сделав незначительные изменения, можно адаптировать его под protothread’ы. Это довольно просто, поэтому код я приводить не буду. Но желающим предлагаю поиграться.
Сравнение
Напоследок предлагаю сравнить tasklet, workqueue и protothread. На самом деле, конечно, с одной стороны не очень корректно сравнивать protothread со средствами обработки bottom-half прерываний — все-таки они созданы для разных задач, нельзя так просто подменить одно другим. С другой стороны, workqueue тоже несколько выбивается из тройки: в отличие от остальных, он работает по правилам вытесняющего планирования, область применения у него куда шире.
Сравнение я привожу скорее для того, чтобы извлечь полезные идеи.
А вот и сравнительная таблица:
Tasklet | Workqueue | Protothreads | |
---|---|---|---|
Наличие своего стека | Нет — обрабатываются как softirq (на специально выделенном общем для всех обработчиков стеке, по крайней мере в Linux на x86) | Да — исполняются на стеке worker-потоков, число которых сильно меньше, чем число задач | Нет |
Скорость | Быстрые — минимальная дополнительная обработка | Быстрые, но не как tasklet’ы, требуется переключение контекста, когда worker’ы сменяют друг друга | Очень быстрые — практически нет дополнительной обработки, больше простора для оптимизации компилятором |
Примитивы синхронизации | Отсутствуют (за исключением spinlock) | Присутствуют в полном объеме | Псевдо-семафоры; примитивное ожидание событий |
Концепция планирования | Кооперативный планировщик как softirq; tasklet’ы вытесняются только аппаратными прерываниями | Worker’ы играют роль планировщика для work’ов, сами управляются основным планировщиком | Кооперативный планировщик, реализуется пользователем |
Каждый из этих подходов имеет свои плюсы и минусы, используются они для разных задач, в контексте которых отвечают запросам пользователей.
Например, в Embox у нас возникла идея реализовать свои легкие потоки, у которых не будет своего стека, подобно protothread и tasklet, при этом будут управляться основным планировщикам, как это реализовано в workqueue, а также будут хоть в каком-то виде поддерживать механизм ожидания событий (даже более того — использовать подмножество того же API, что используют и полноценные потоки). У такого подхода есть несколько привлекательных для нас применений:
- Замена механизм отложенной обработки прерываний;
- Использование только легких потоков в планировщике позволит использовать почти полноценную многозадачность даже для ограниченных по ресурсам встроенным системам;
- Предыдущий сценарий хорош ещё кое-чем: при портировании системы на новую процессорную архитектуру сперва хочется получить хоть сколько-нибудь работоспособную систему. Реализация полноценного context_switch на новом ассемблере в этот момент — лишняя головная боль. Для использования же только легковесных потоков этого не требуется.
О том, как это у нас получилось, каких результатов мы добились, я расскажу в следующий раз, через некоторое время.
Автор: LifeV