ZarathustrA писал(а):
Поясняю.
Под вертикальной реентерабельностью я понимаю способность ядра поддерживать возможность корректного прерывания и возобновления исполняющегося потока инструкций более привелигированным (с точки зрения апаратуры) потоком инструкций. Пример: обрабатывается system call в режиме ядра, возникает исключение с приоритетом 5 (например 0x54) и прерывает эту обработку. Во время работы обработчика прерывания (0х54) возникает прерывание с более высоким приоритетом (например 0х86) и прерывает предыдущий обработчик (0х54) и т.д. После чего происходит последовательное возобновление всех прерванных потоков вплоть до первоначального system call-а. При этом, как вы уже заметили, все прерывания происходят незамедлительно сразу же после наступления определенного события и при этом формируется стек прерванных потоков инструкций.
Под горизонтальной реентерабельностью я понимаю способность ядра поддерживать возможность корректного прерывания и возобновления исполняющейся задачи (по терминологии Intel) более привелигированной задачей (с точки зрения операционной системы). Пример: обрабатывается system call в режиме ядра, при этом на каком-то этапе этой обработки освобождается какой-то логический ресурс (например мьютекс), которого ожидает другая, более приоритетная, задача. Обнаруживая это ядро незамедлительно производит переключение на эту более приоритетную задачу, не дожидаясь того момента, когда менее приоритетная задача завершит обработку system call-а. При этом, как вы уже заметили, все прерывания происходят незамедлительно сразу же после наступления освобождения какого-то логического ресурса, ожидаемого более приоритетной задачей и при этом переключения происходят по кольцу задач.
Сначала сделаю важное замечание. Вы
абсолютно неверно используете термин "реентерабельность", из-за чего я и не мог Вас понять. Реентерабельность, или повторная входимость (re-enterable) -- это пригодность кода к повторному его вызову до того момента, как завершилось его предыдущее использование. Чтобы обеспечить это, должны соблюдаться две вещи: 1) код не должен модифицировать сам себя ни прямо, ни косвенно (через вызовы чего-то там ещё); 2) для каждого из параллельных вызовов должен быть полностью свой набор изменяемых данных (т.е. не допускаются статические переменные, только создаваемые динамически в стеке или в динамической памяти). Операционная система в целом в принципе не может быть реентерабельной, поскольку там встречаются участки кода, принципиально не допускающие параллельного выполнения (из-за чего и прибегают к запрету прерываний, а в многопроцессорных системах -- ещё и ко всякого рода средствам синхронизации), хотя большая часть кода системы, если он написан нормально, является реентерабельной (т.е. одна и та же процедура может быть вызвана несколько раз одновременно). К возможности же переключения потоков в произвольный момент времени или к возможности прерывания кода самого ядра реентерабельность прямого отношения не имеет, и уж тем более она не имеет вообще никакого отношения к числу стеков (поскольку конкретная процессорная архитектура может вообще не иметь стека).
Ваша "вертикальная реентерабельность" на самом деле является
вытесняемостью ("знатоки" русского языка временами используют английскую кальку -- преемптивность) обработчиков прерываний. Подавляющее большинство операционных систем её имеют, да и реализуется она легко и просто. Замечу, что сами обработчики прерываний при этом могут быть нереентерабельными. Например, если произошло прерывание А, то до завершения его обработки это же прерывание повторно происходить не должно, иначе такому обработчику "сорвёт крышу", однако вполне могут произойти прерывания Б, В и Г, каждое из которых обслуживается своим собственным обработчиком. Простейший пример нереентерабельного обработчика, наверное, таков: он подсчитывает число прерываний (например, тиков таймера), в некоторой переменной, к которой по тем или иным причинам не имеет атомарного доступа (например, процессор не имеет команды инкремента содержимого ячейки памяти: надо сначала загрузить в регистр, увеличить и записать результат -- АРМ именно таков). Если значение уже было загружено в регистр и в этот момент произошло прерывание, снова вызвавшее этот обработчик, то его повторное исполнение загрузит ещё не модифицированное значение, увеличит, сохранит и вернёт управление первой копии обработчика, которая увеличит ранее загруженное немодифицированное значение, что в итоге даст неверный результат: счётчик будет увеличен на 1, а не на 2.
Ваша "Горизонтальная реентерабельность" -- это вытесняемость ядра прикладным кодом. К числу стеков у ядра она также прямого отношения не имеет, хотя, конечно, обеспечить её в системе, где для каждого потока режима пользователя есть стек режима ядра, действительно проще. Ещё проще, однако, реализовать её в настоящей микроядерной системе (существуют ли такие в природе, не считая всяких экспериментальных -- не знаю; всем известная QNX является микроядерной только в смысле пиара, реально же это система с монолитным ядром, из которого в режим пользователя вынесены драйверы и только драйверы -- весь остальной код системы исполняется в едином адресном пространстве, а значит, лишён единственного преимущества микроядерности -- надёжной защиты компонентов системы друг от друга): в ней весь системный код, за исключением маленького микроядра, исполняется в виде потоков пользовательского режима, а значит, управляется планировщиком и в любой момент может быть изменён.
Цитата:
В случае одного стека, операционная система не способна осуществить передачу управления в любой точке своего кода. И в только что описанном примере, вы будете вынуждены ожидать полного завершения обработки и обработчика исключения и system call-а прежде чем сможете передать управление задаче-обработчику прерывания, хотя задача А может быть пасьянсом косынкой, а задача-обработчик может контролировать какой-нибудь критически важный высокоточный таймер или датчик или звуковую карту. В итоге вы получите либо трудно-контролируемую погрешность в измерении времени, либо трагедию либо прерывный звук из колонок.
Как я уже сказал, и с одним стеком, и вообще без стека решить эту задачу можно, только надо думать при создании кода несколько больше, чем со многими стеками (там думать вообще не надо, по большому счёту). Но взгляните на проблему с другой стороны, а именно: нужна ли такая вытесняемость вообще? Я утверждаю категорически: нет, не нужна, и вот почему.
Действительно, есть очень критичные к времени обработки процессы, ставящие крайне жёсткие временные рамки. На практике, однако, такие события обрабатывают либо специализированными контроллерами, работающими обычно вообще без операционной системы, либо с помощью драйверов режима ядра. Дело в том, что, даже если само ядро будет полностью вытесняемым, за что Вы ратуете, всё равно время входа в обработчик прерывания будет на один-два порядка ниже, чем время активации спящего потока по той простой причине, что вход в обработчик прерывания осуществляется чисто аппаратным способом и не займёт больше времени, чем максимально допустимая длительность работы с запрещёнными прерываниями (а оно в правильно спроектированной системе крайне невелико -- например, в RSX-11 не превышало 100 мкс, и это на машине, делавшей порядка 250 тыс. операций "регистр-регистр" в секунду, т.е. когда даже на простую команду типа MOV R1, R2 уходило примерно 4 мкс), а активация потока требует выполнения приличной последовательности команд по полному сохранению текущего контекста, восстановлению контекста вызываемого потока и т.д. (посчитайте сами: сохранение и восстановление семи регистров у PDP-11 -- это 14 команд формата "регистр-память" с автоинкрементной-автодекрементной адресацией, которые выполнялись примерно вдвое медленнее, чем "регистр-регистр"; соответственно, только они в аналогичных условиях будут выполняться больше тех самых 100 мкс, а ведь надо выполнить целую кучу других действий, чтобы пробудить поток). Таким образом, обработка действительно очень жёстко ограниченных по времени событий кодом режима пользователя обычно попросту неприемлема.
Однако существуют и менее критичные по времени события (те же 100 мкс, что были пределом для техники начала 1970-х, сейчас -- чуть ли не плёвое дело даже для слабых микроконтроллеров). Как быть с ними? Пихать всё подряд в ядро, понятное дело, не хочется, и если для действительно критичного ко времени кода это будет разумным (а то и вообще единственно возможным) шагом, то для чего-нибудь не столь важного хотелось бы подобного избежать. Казалось бы, здесь вытесняемым ядрам будет самое место: сверхбыструю реакцию для потоков они не обеспечат, но просто быструю -- вполне. Однако и здесь более правильным путём станет создание просто быстрого ядра, которое за небольшое процессорное (подчеркну: не астрономическое, а именно процессорное) время способно разделаться с любым запросом прикладного кода. Наихудшее время реакции (т.е. передачи управления высокоприоритетному потоку) будет складываться из времени работы всех обработчиков прерываний (ведь худший случай -- это наличие всех потенциально возможных запросов прерываний одновременно), работающих по наиболее долгому сценарию (вероятно, для каждого -- завершение выполнявшейся операции ввода-вывода и запуск следующей за ней операции, находящейся в очереди) плюс время обработки самого длинного запроса со стороны потока. Это, на первый взгляд, многократно превосходит время, необходимое на вытеснение кода ядра и вызов потока, однако не всё так просто: 1) обработку прерываний, вообще говоря, откладывать нельзя, поскольку может возникнуть переполнение буферов или ещё что-то в этом роде, т.е. время на работу обработчиков можем смело вычитать: их всё равно придётся выплнять до вызова потока; 2) нельзя не выполнять и обработку завершения операций ввода-вывода, поскольку завершения может ожидать ещё более приоритетный поток, и окончательно разобраться, какому ж потоку передавать управление, можно лишь после того, как все физически завершившиеся операции были завершены и логически, а ожидающие их потоки выведены из ожидания; 3) затягивать начало операций ввода-вывода после окончания предыдущих в общем случае тоже нельзя: устройство может быть критичным к времени запуска новых операций (обмен данными по синхронному каналу связи, например). В итоге получается, что безболезненно можно прервать лишь обработку вызова системного сервиса со стороны другого потока. Однако, если эта обработка занимает мало времени, есть ли смысл её прерывать? У приснопоминаемой мною RSX-11, помнится, наихудшее время обработки системного вызова составляло что-то вроде 10 мс -- на той же самой машине с быстродействием порядка 250 тыс. оп/с. (во время оно приходилось видеть подробный расчёт этого и других времён для конкретной версии ОС на конкретном железе -- типа методического пособия, так сказать, но, поскольку сие было лет двадцать тому назад, точные цифры уже не помню). Легко посчитать, что 10 мс для того железа -- это порядка 2500 команд "регистр-регистр" (реально их было меньше 1000, поскольку основную массу составляли команды "регистр-память" и "память-память"). Если перенести на наши дни и предположить, что за счёт более плохой системы команд и более низкого качества кода (использование ЯВУ, а не ассемблера), а также усложнения функций самой системы потребное число команд на обработку аналогичного по сути системного вызова увеличилось в 10 раз (25000 операций "регистр-регистр"), то для 100-МГц несуперскалярного процессора, выполняющего каждый такт по команде, т.е. имеющем быстродействие 100 млн. операций "регистр-регистр" в секунду (таковы все АРМы до версии АРМв7, а также 80486) получим время в 250 мкс. Увеличим его ещё в 10 раз в расчёте на случай, что никаких данных в кэше нет и т.п. -- получим 2,5 мс, что раза в 4 лучше той самой RSX-11, которая, напомню, была системой жёсткого реального времени. Причём замечу, что мы получаем такой расклад на крайне слабом с современной точки зрения процессоре даже для мобильного устройства (например, в моём телефоне установлен Кортех-А9 частотой 1 ГГц, выполняющий две команды за такт, т.е. имеющий пиковое быстродействие 2 млрд. оп/с).
Возвращаясь к исходной теме. Этот расчёт я привёл в качестве примера того, какое время можно реально сэкономить для активации потока, если сделать вытесняемое ядро. Как видите, оно весьма и весьма невелико, что, мягко говоря, заставляет усомниться в ценности этого решения: да, активировать поток можно быстрее, но времени для действительно критичного обработчика всё равно не хватит, и его придётся делать в ядре, ну а не очень критичный может и подождать (т.е. его можно написать таким образом, что он не будет слишком чувствителен к задержкам, не выходящим за разумные пределы). Примером такового является тот же аудиоплеер: что мешает иметь, предположим, три буфера, каждый из которых вмещает по полсекунды звучания? В нормальном случае два буфера заполнены, третий заполняется, а когда он заполнен, поток уходит в ожидание. При опустошении одного из буферов поток пробуждается, однако, если это невозможно сделать сразу (система страдает обработкой чужого запроса), несколько лишних миллисекунд ожидания ничем не повредят: запас-то в целую секунду сделан. Зато отказ от вытесняемости ядра упрощает само ядро и снижает накладные расходы, т.е. больше времени (и памяти) остаётся на работу приложений, а если им делать всё равно нечего, то меньше расходуется энергии (сделав все дела, процессор просто засыпает и перестаёт жрать электричество вагонами). Для мобильных применений последнее более чем критично, да и для настольных экономия не помешает (платить-то меньше, пускай разница и не колоссальная).
Что же касается применения вытесняемых ядер на практике, то все эти случаи -- либо следствие особых требований, предъявляемых к системе, либо банально неграмотное проектирование (ну или смесь того и другого, конечно). Например, ядро ОС/360 половину времени работает при запрещённых прерываниях и посему ни разу не удовлетворяет требованиям реального времени, однако во всю вторую половину времени оно является вытесняемым. Последнее объясняется тем, что почти всё ядро выполнено в виде множества мелких транзитных модулей (размером от 8 байт до 1 Кбайта), чтобы довольно крупная система (суммарный объём кода ядра составляет несколько сотен килобайт) могла работать на машине с малым объёмом памяти (изначально -- от 64 Кбайт). Понятное дело, пока система подгружает с диска свои очередные кишки, процессору простаивать не резон, вот она сама себя и вытесняет. Она могла бы вытеснять ядро и для ускоренной передачи управления потоку (задаче в тамошней терминологии); это не делалось лишь потому, что было лишено реального смысла (изначально система рассчитывалась на пакетную обработку, где требование №1 -- фактическая полезная производительность, а время реакции вообще никакой роли не играет). RSX-11 имела невытесняемое ядро: оно целиком помещалось в памяти и работало очень быстро, в то же время задачи тоже могли либо целиком находиться в памяти, либо целиком быть выгруженными на диск (из-за особенностей системы команд и ММУ, ежели таковое имелось), а поэтому в принципе не возникало ситуаций, когда необходимо было бы приостановить работу кода ядра для подгрузки чего-либо с диска. VAX/VMS умела вытеснять своё ядро на время подгрузки из файла подкачки, а вот вытесняться, чтобы предоставить процессор потоку -- насколько помню, нет (поскольку работала тоже очень быстро, будучи, по большому счёту, расширенным вариантом RSX-11, перенесённым на вычислительную машину другой архитектуры). В Винде вытесняемости потоками как таковой нет (опять-таки, насколько помню), но там она и не нужна: длительная обработка возлагается на потоки режима ядра (что само по себе изврат, но лучше, чем полная вытесняемость ядра), ну а обычные запросы выполняются ядром достаточно быстро, и поэтому нет резона его вытеснять. В Линухе (и вообще во многих, если не во всех, унихах) вытесняемость ядра -- это, судя по всему, костыль, возникший из-за грубейших просчётов при создании системы и её АПИ (в первую очередь, отсутствия асинхронного ввода-вывода, из-за чего пришлось, насколько знаю, не только "усыплять" поток в системе во время обработки запросов ввода-вывода, но ещё и придумывать возможность прерывания уже выполняющихся длительных операций ввода-вывода, чтобы приложение могло получить какой-нибудь срочный сигнал -- ввод-вывод-то синхронный, поэтому послать уведомление человеческим путём невозможно).