Я стараюсь чередовать статьи про разработку ОС вообще и специфические для ОС Фантом статьи. Эта статья — общего плана. Хотя, конечно, я буду давать примеры именно из кода Фантома.

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

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

Шедулер — это функция, которая отвечает на вопрос «какой нити отдать процессор прямо сейчас». Всё. Простейший шедулер просто перебирает все нити (но, конечно, готовые к исполнению, не остановленные) по кругу (RR алгоритм). Реальный шедулер учитывает приоритеты, поведение нити (интерактивные получают больше, чем вычислительные), аффинити (на каком процессоре нить работала в прошлый раз) и т.п., при этом умеет сочетать несколько классов приоритетов. Типично это класс реального времени (если есть хотя бы одна нить этого класса — работает она), класс разделения времени и класс idle (получает процессор только если два предыдущих класса пустые, то есть в них нет нитей, готовых к исполнению).

На сём пока про шедулер закончим.

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

Опять же, начнём с простого. Многозадачность бывает кооперативная и преемптивная.

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

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

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

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

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

Ссылка на реализацию для Intel 32 bit

Я немного почистил код для простоты:

// 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();


Запрём общий спинлок переключения контекстов. Вообще-то его можно бы делать per CPU, но для спокойствия — только одно переключение контекста в момент времени. Вынем из структуры описания нити ссылку на спинлок, который надо отпереть после переключения — её нам передал примитив синхронизации. Обнулим ссылку внутри структуры, чтобы спинлок не отперли повторно.

    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;
    }


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

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

    t_dequeue_runq(next);

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


Дальше всё жёстко. Надо чётко понимать, что после вызова phantom_switch_context мы работаем в другой нити, у нас ДРУГИЕ ЗНАЧЕНИЯ ЛОКАЛЬНЫХ ПЕРЕМЕННЫХ. В частности, переменная next, в которой хранится указатель на дескриптор той нити, которую мы запускаем, после запуска нити будет содержать неверное значение. Поэтому глобальную переменную, которая хранит знание о том, какая нить сейчас работает, мы исправим до переключения, а не после. (Вообще-то, после тоже можно, но из другой переменной.:)

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

    // 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);


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

    cpu_tss[ncpu].esp0 = (addr_t)t->kstack_top;


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

Есть и ещё тонкости, которые я не упомянул. Например, традиционно на Интеле при восстановлении состояния процессора не восстанавливают регистры плавающей точки и SSE — вместо этого выставляют флаг запрета доступа к ним. Если код нити реально попытается этими регистрами воспользоваться, произойдёт исключение, которое и восстановит состояние этих регистров. Они довольно весомы, а используются очень не всеми, и такая оптимизация имеет смысл.

Теперь о запуске нити. Чтобы создать новую нить, нужно, чтобы она могла… вернуться из phantom_switch_context()!

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

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

Комментарии (17)


  1. AngelOfSnow
    20.04.2016 17:08
    +5

    Прям глаз режет этот ваш «Шедулер», кто не работал с какой либо ОС вплотную, не поймут, что вы говорите о Планировщике (scheduler).


    1. dzavalishin
      20.04.2016 17:22
      +3

      Спасибо. :) Теперь, дочитав до Вашего комментария, поймут все. :)


    1. QtRoS
      21.04.2016 09:26
      +1

      А по мне довольно устоявшееся понятие, ни секунды не задумывался, о чем речь. И я бы не сказал, что ОС я вплотную занимался, так, общие знания, Танненбаум, как у всех :)


  1. NickViz
    20.04.2016 17:31
    +6

    угу, и «преемптивность» — есть же нормальный термин — вытесняющая многозадачность…


    1. dzavalishin
      20.04.2016 17:36
      +7

      Знаете анекдот? Оживили Сталина. Он очнулся, поглядел и говорит:
      — Давайте расстреляем всех министров и покрасим Кремль в зелёный цвет.
      — А Кремль зачем красить?
      — Я рад, что по первому вопросу возражений нет.

      :)

      Я вот инверсию приоритетов не описал, вот что жалко… ладно, наверное, напишу про Планировщик при случае, там и отмечу.


  1. abondarev
    20.04.2016 18:25

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


    1. dzavalishin
      20.04.2016 19:32

      Да, Вы знаете, я тоже это подумал, когда писал статью. Историческое наслоение.


  1. dzugaru
    20.04.2016 18:52
    -1

    Пожалуйста, не переводите threads как нити, в русских переводах есть устоявшийся термин «потоки».


    1. erlyvideo
      20.04.2016 21:42
      +3

      нити — это как раз очень правильный и удобный перевод.

      Потоков много разных, особенно если дело доходит до какого-нибудь с++, а нити — сразу понятно, о чём речь.


      1. shabanovd
        20.04.2016 23:27
        -2

        Для меня оба термина странны, так что если не код то веще ничего не понял бы =)


    1. bormotov
      21.04.2016 00:14
      +1

      не то, что бы я прям вот много такого читаю по-русски, но мы между собой нити называем нитями. Хотя, сейчас всё больше акторы, но всё равно, когда какие-то разборки в глубь, то так и говорим «какой там шедулер у этого класса акторов, дефолтный? и им кадому по нити jvm дает, или кучу на одну вешает?»


    1. napa3um
      21.04.2016 21:50

      Тогда возникнет проблема в термине «streams».


  1. gena_glot
    21.04.2016 02:48

    А может попробовать 2-steps scheduler:
    1. Select process.
    2. Select thread


    1. dzavalishin
      21.04.2016 17:04

      А Вы какую задачу решаете?


  1. qw1
    24.04.2016 21:54

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


    1. dzavalishin
      25.04.2016 16:02

      Мне кажется, Вы немного смешиваете разные вещи.

      1. Статья описывает реализацию в ядре. user mode в ней не затрагивается почти совсем.

      2. Если вызов функции (yield — функция) портит стек, то компилятор надобно починить или заменить на исправный. Если стек портит реализация — её не надо было писать с ошибками. Если Вы её вызвали, а SP смотрит не на стек — ну, наверное, Вы погорячились.

      3. Как Вы совершенно справедливо заметили (ну и, если Вы внимательно читали, у меня это написано сразу после примера кода «cpu_tss[ncpu].esp0 = (addr_t)t->kstack_top;») — Интеловские, да и все иные процессоры переключают стек при переходе из режима пользователя в режим ядра. Вызов функции yield и фактическое переключение контекста происходит именно в режиме ядра. Эта мера действительно позволяет программисту в юзермоде вытворять со стекпойнтером всякую фигню. Но к теме статьи это не относится.

      4. Ну и, насколько я помню, нигде в моей статье не написано, что «вытесняющая многозадачность эквивалентна вызову yield()». Она эквивалентна таймерному прерыванию+вызов yield().


      1. qw1
        25.04.2016 22:01

        Если вызов функции портит стек, то компилятор надобно починить или заменить на исправный

        Имеется ввиду затирание данных в памяти по адресам, меньшим SP
        Если Вы её вызвали, а SP смотрит не на стек — ну, наверное, Вы погорячились

        Может, мне регистров мало и esp я использую как дополнительный адресный регистр.
        И вот тут yield неприменим…
        Она эквивалентна таймерному прерыванию+вызов yield().

        А вот тут соглашусь, использовать прерывание, чтобы получить стек для yield… Интересный взгляд на вытесняющую многозадачность.