Давайте Работать В Режиме Многозадачности

Я стараюсь чередовать статьи о разработке ОС в целом и статьи, посвященные Phantom OS. Эта статья носит общий характер.

Хотя примеры я, конечно, буду приводить именно из кода Фантома.

В принципе, реализация самого механизма многозадачности — вещь достаточно простая.

Сама по себе.

Но, во-первых, есть тонкости, а во-вторых, оно должно взаимодействовать с какими-то другими подсистемами.

Например, та же реализация примитивов синхронизации очень тесно связана с реализацией многозадачности.

Также имеется уникальная связь с подсистемой обслуживания прерываний и выполнения.

Но об этом позже.

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

О втором мы сегодня говорить не будем, просто опишем его вкратце.

Планировщик — это функция, отвечающая на вопрос «какому потоку отдать процессор прямо сейчас».

Все.

Простейший планировщик просто перебирает все потоки (но, конечно, готовые к выполнению, а не остановленные) по кругу (алгоритм RR).

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

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

Давайте пока закончим разговор о планировщике.

Перейдем к самой подсистеме, которая может отобрать процессор у одного потока и передать его другому.

Опять же, начнем с простого.

Многозадачность может быть совместной или вытесняющей.

Кооперативный предельно прост — каждый поток время от времени честно и осознанно «сдает» процессор, вызывая функцию yield().

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

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

Кооперативная многозадачность сама по себе основана всего на одной функции.

Функция, вызываемая всеми потоками одновременно.

Это место требует немного размышлений.

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

В самой середине.

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

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

(Тот, который выбран планировщиком).

Чуть позже мы рассмотрим весь код переключения контекста, а сейчас — самое ядро, собственно переключение между потоками.

Ссылка на реализацию для 32-разрядной версии Intel Я немного почистил код для простоты:

  
  
  
  
  
  
  
   

// called and returns with interrupts disabled /* void phantom_switch_context( phantom_thread_t *from, phantom_thread_t *to, int *unlock ); */ ENTRY(phantom_switch_context) movl 4(%esp),%eax // sw from (store to) movl (%esp),%ecx // IP movl %ecx, CSTATE_EIP(%eax) movl %ebp, CSTATE_EBP(%eax) // we saved ebp, can use it. movl %esp, %ebp // params are on bp now pushl %ebx pushl %edi pushl %esi movl %cr2, %esi pushl %esi movl %esp, CSTATE_ESP(%eax) // saved ok, now load movl 8(%ebp),%eax // sw to (load from) movl CSTATE_ESP(%eax), %esp popl %esi movl %esi, %cr2 popl %esi popl %edi popl %ebx movl CSTATE_EIP(%eax), %ecx movl %ecx, (%esp) // IP // now move original params ptr to ecx, as we will use and restore ebp movl %ebp, %ecx movl CSTATE_EBP(%eax), %ebp // Done, unlock the spinlock given movl 12(%ecx),%ecx // Lock ptr pushl %ecx call EXT(hal_spin_unlock) popl %ecx // now we have in eax (which is int ret val) old lock value ret

Функция принимает три аргумента — из какого потока переключаемся, на какой поток переключаемся, какую спинблокировку разблокировать после переключения.

Смысл его чрезвычайно прост. Добавим все (важные) состояния процессора в стек, затем запишем положение указателя стека в специальное поле структуры, описывающей текущий поток.

Затем мы удаляем положение указателя стека из структуры для нового потока, восстанавливаем указатель стека и извлекаем из стека состояние процессора нового потока.

Все, после этого функция вернется в новый поток.

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

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

) То, что мы рассмотрели, является самой сутью реализации.

Но эта функция не вызывается напрямую (она обычно реализует только архитектурно-зависимую часть кода), а вызывается из обёртки phantom_thread_switch().

См.

код phantom_thread_switch().

Что происходит в обертке.

Давайте идти шаг за шагом.

Убедимся, что мы не находимся в контексте прерывания (прерывания нельзя поставить на паузу, будет нарушена целостность аппаратного состояния процессора) и что потоковая подсистема вообще активирована.

Отключим прерывания гарантированно.

Это именно то, что нам сейчас не нужно.



assert_not_interrupt(); assert(threads_inited); int ie = hal_save_cli();

Давайте заблокируем общую спин-блокировку переключения контекста.

На самом деле это можно было бы сделать на каждый процессор, но для спокойствия — только одно переключение контекста за раз.

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

Сбросим ссылку внутри структуры, чтобы спинлок снова не разблокировался.



hal_spin_lock(&schedlock); toUnlock = GET_CURRENT_THREAD()->sw_unlock; GET_CURRENT_THREAD()->sw_unlock = 0;

Возьмем последний «тик» текущего потока — интервал, запланированный для его работы на процессоре.

Это для планировщика, поэтому не буду сейчас вдаваться в подробности.

Спросим у планировщика, кому отдать процессор.

Давайте вспомним, кто был в текущей теме.

Для порядка убедимся, что планировщик не сошел с ума и не предложил нам запустить поток, не имеющий права на работу (ненулевой Sleep_flags).



// Eat rest of tick GET_CURRENT_THREAD()->ticks_left--; phantom_thread_t *next = phantom_scheduler_select_thread_to_run(); phantom_thread_t *old = GET_CURRENT_THREAD(); assert( !next->sleep_flags );

Чтобы не заниматься ерундой, давайте проверим, нужно ли запускать тот же поток; если то же самое, просто закончим упражнение, отметив это событие в статистике.



if(next == old) { STAT_INC_CNT(STAT_CNT_THREAD_SAME); goto exit; }

Удалим новый поток из очереди готовых к исполнению потоков (именно в этом потоке планировщик ищет кандидатов для установки на процессор).

На самом деле очередей несколько, но это детали — со всех удалим.

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

(Если он заблокирован, то тот, кто его разблокирует, вернет его в очередь.

)

t_dequeue_runq(next); if(!old->sleep_flags) t_enqueue_runq(old);

Тогда все жестко.

Мы должны четко понимать, что после вызова phantom_switch_context мы работаем в другом потоке, у нас есть ДРУГИЕ ЗНАЧЕНИЯ ЛОКАЛЬНЫХ ПЕРЕМЕННЫХ .

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

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

до переключение, а не после.

(На самом деле, after тоже возможно, но из другой переменной.

:) Далее мы разрешаем программные прерывания до фактического переключения и отключаем их после.

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

Я вам это скажу отдельно, момент действительно деликатный.



// do it before - after we will have stack switched and can't access // correct 'next' SET_CURRENT_THREAD(next); hal_enable_softirq(); phantom_switch_context(old, next, toUnlock ); hal_disable_softirq();

Далее напомню, что все локальные переменные изменили свое значение.

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

Во-первых, я сообщаю ему, на каком процессоре он «проснулся», а во-вторых, специфичную для архитектуры функцию восстановления контекста после вызова переключателя.



phantom_thread_t *t = GET_CURRENT_THREAD(); t->cpu_id = GET_CPU_ID(); arch_adjust_after_thread_switch(t);

Для Intel эта конкретная функция восстанавливает настройку вершины стека при переключении в режим ядра:

cpu_tss[ncpu].

esp0 = (addr_t)t->kstack_top;

Он нужен, если поток после переключения контекста возвращается в пользовательский режим — прерывания и системные вызовы из пользовательского режима приводят к аппаратному переключению стека процессором, и для каждого потока такой стек, естественно, индивидуален.

Есть еще тонкости, о которых я не упомянул.

Например, традиционно на Intel при восстановлении состояния процессора не восстанавливаются регистры с плавающей запятой и SSE — вместо этого устанавливается флаг, запрещающий доступ к ним.

Если код потока действительно попытается использовать эти регистры, возникнет исключение, которое восстановит состояние этих регистров.

Они весьма значительны, но не все ими пользуются, и такая оптимизация имеет смысл.

Теперь о начале темы.

Чтобы создать новый поток, он должен иметь возможность.

вернуться из phantom_switch_context()! Это значит, что нам нужно «собрать» на стеке нового потока картинку, которая появляется, когда она находится внутри phantom_switch_context() в том месте, где мы переключили указатель стека на новый поток.

В этом случае адрес возврата из phantom_switch_context() должен быть адресом функции, которая, во-первых, сделает то, что делает phantom_thread_switch() после переключения потока, во-вторых, завершит инициализацию потока и, наконец, вызовет нужную функцию.

для выполнения в новом потоке.

В этой статье мы не рассматривали саму вытеснение — как именно «отбирается» процессор у старого потока, и кто и когда вызывает phantom_thread_switch().

Но это уже отдельная статья .

Теги: #многопоточность #многозадачность #Системное программирование #фантом #фантомос #фантомная ОС #Ненормальное программирование #Системное программирование #Программирование микроконтроллеров

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.