Pull to refresh

Comments 53

Как только подписался на правильных людей, сразу Хабр стал Торт!!!

Спасибо за высокую оценку! Приятно!

Статья довольно интересная, до этого про такое волшебство только слышал от умных людей, в основном в контексте Делфи (если кто такое помнит).

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

Ещё понравилось простенькое объяснение "асинки и корутины компилятор делает, а файберы простенькие, вот мы их тут".

Это конечно правда, корутины - это сахар, компилятор их реально режет на класс-Стейт машину и запоминает локальные переменные в полях с номером стейта к большому свичу. Посмотрите на декомпилятор .net, там познавательно.

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

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

Но в остальном отличная статья, пишите исчо

Cтранно, всегда их называли нитями, насколько я помню. А про "волокна" впервые слышу. В Novell Netware 3-4 версий, например, их тоже называли "нитями", как ни странно, хотя там была невытесняющая многозадачность.

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

Из контекста Я понял, что автор говорил про честные системные Threads, и их иначе как потоками не зовут. Приложения многопоточные а не многонитяные, вон страница в википедии согласна даже.
Файберы - да, для них разные названия слышал

Про волокна было написано еще у Рихтера, который Джефри.

Первые версии Novell Netware выходили в середине 1980-х. Не факт, что Рихтер тогда писал про волокна. Возможно, что термин появился позже, чтобы различать кооперативность и вытесняемость.

Да, я прочувствовал это приключение: как в студенческие годы, когда хочется возиться со всеми этими механизмами! Разбираешь их на атомы и собираешь обратно

Вы по сути реализовали setjmp() и longjmp() для x64 плюс стек. Во FreeRTOS до 9 версии это называлось Короутинамм (ага!). Очень полезная вещь, когда наперёд просчитал все затраты по времени на выполнение каждой функции.

Все верно, мне просто захотелось сделать что-то такое самостоятельно. Построить велосипед.

Вот и я хотел спросить: почему было не использовать setjmp/longjmp — получился бы сразу кроссплатформенный вариант.

Видимо — в учебно-демонстрационных целях.

Можно было, наверное, и их. Но со стеком пришлось бы всё равно возиться на asm, как мне кажется.

В студенчестве мы на longjmp даже вытесняющую многозадачность делали, под MSDOS)) А вот в Turbo Pascal приходилось примерно то же делать ассемблером.

Есть одна вещь, которую автор не учёл.

Место под стек нового фибера автор разметил где? В массиве, который является частью структуры (класса) FiberDescriptor.

Сама структура FiberDescrptor аллоцируется где? В теории может аллоцироваться где угодно (в том числе и на стеке), однако с учётом того, что автор использует std::make_unique<T>, внутри которой будет вызов new T(...) , T — а в нашем случае FiberDescriptor будет аллоцироваться на дефолтной C++-куче. Или на не-дефолтной, если кто-то решит перегрузить оператор new глобально или только для FiberDescriptor. В любом случае, едва ли даже перегрузкой оператора new можно заставить структуру аллоцироваться где-то кроме как в какой-то куче.

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

И именно в этом месте начинается конфликт: с таким подходом ломается совместимость с SEH (если мы пишем под Windows).

Почему ломается? Потому что SEH устроен так, что когда выбрасывается SEH-исключение, системный код начинает обходить цепочку SEH-фреймов в поисках обработчика, который возьмётся обработать исключений. При этом, продвигаясь по односвязному списку SEH-фреймов, адрес каждого фрейма проверяется на принадлежность стеку текущего потока — границы стека при этом берутся из двух полей TIB (если первым полем TIB является адрес начала цепочки SEH-фреймов, то второе и третье это как раз границы стека). Если при обходе цепочки система натыкается на подозрительный SEH-фрейм, который лежит не на стеке — всё плохо и задуманным образом это работать не будет.

Кусочек RtlDispatchException из ReactOS — для тех, кто не хочет идти дизасмить ntdll или лезть в утёкшие исходники Windows

На 24-й строке — получение границ стека из TIB, 38...40 — сама проверка на принадлежность фрейма стеку потока.

BOOLEAN
NTAPI
RtlDispatchException(IN PEXCEPTION_RECORD ExceptionRecord,
                     IN PCONTEXT Context)
{
    PEXCEPTION_REGISTRATION_RECORD RegistrationFrame, NestedFrame = NULL;
    DISPATCHER_CONTEXT DispatcherContext;
    EXCEPTION_RECORD ExceptionRecord2;
    EXCEPTION_DISPOSITION Disposition;
    ULONG_PTR StackLow, StackHigh;
    ULONG_PTR RegistrationFrameEnd;
 
    /* Perform vectored exception handling for user mode */
    if (RtlCallVectoredExceptionHandlers(ExceptionRecord, Context))
    {
        /* Exception handled, now call vectored continue handlers */
        RtlCallVectoredContinueHandlers(ExceptionRecord, Context);
 
        /* Continue execution */
        return TRUE;
    }
 
    /* Get the current stack limits and registration frame */
    RtlpGetStackLimits(&StackLow, &StackHigh);
    RegistrationFrame = RtlpGetExceptionList();
 
    /* Now loop every frame */
    while (RegistrationFrame != EXCEPTION_CHAIN_END)
    {
        /* Registration chain entries are never NULL */
        ASSERT(RegistrationFrame != NULL);
 
        /* Find out where it ends */
        RegistrationFrameEnd = (ULONG_PTR)RegistrationFrame +
                                sizeof(EXCEPTION_REGISTRATION_RECORD);
 
        /* Make sure the registration frame is located within the stack */
        if ((RegistrationFrameEnd > StackHigh) ||
            ((ULONG_PTR)RegistrationFrame < StackLow) ||
            ((ULONG_PTR)RegistrationFrame & 0x3))
        {
            /* Check if this happened in the DPC Stack */
            if (RtlpHandleDpcStackException(RegistrationFrame,
                                            RegistrationFrameEnd,
                                            &StackLow,
                                            &StackHigh))
            {
                /* Use DPC Stack Limits and restart */
                continue;
            }
 
            /* Set invalid stack and bail out */
            ExceptionRecord->ExceptionFlags |= EXCEPTION_STACK_INVALID;
            return FALSE;
        }

Тут автор может сказать: ну так мы не будем использовать SEH из фиберов, и вообще, у нас тут C++ и мы будем использовать C++-исключения.

Не вы используете SEH, а SEH использует вас. Вы-то в своём коде вполне можете не использовать SEH, но вы можете вызывать WinAPI, а WinAPI за милую душу используют SEH внутри себя.

Поэтому вызывая WinAPI из фибера, вы «зайдёте» в WinAPI с ESP/RSP, указывающим не на стек потока, а на какое-то место в самодельном стеке. Код внутри вызванной вами WinAPI сконструирует новый SEH-фрейм и спокойно поставит его в начало цепочки (mov fs:[0], esp или 64-битный эквивалент этого), дальше в ходе работы WinAPI-произойдёт исключение и при попытке штатно обработать его произойдёт глобальный облом.

А SEH внутри себя используют очень многие WinAPI. Из банального: IsGoodReadPtr, IsGoodWritePtr, IsGoodCodePtr устанавливают SEH-фрейм и пытаются, например, прочитать из запрошенного адреса.

Поэтому, какой выход?

  1. Патчить поля TIB при переключении фиберов. Именно так делает сама kernel32.dll, когда переключает фиберы. Но это рискованный способ, просто потому, что кто вам гарантировал неизменность лэйаута TIB от версии к версии Windows?

  2. С помощью #ifdef...#endif при компиляции под Windows начинка методов классов должна меняться на такую, которая просто является переходниками на WinAPI-функции по работе с фиберами.

и вообще, у нас тут C++ и мы будем использовать C++-исключения.

Емнип в Win32 C++ исключения обычно делаются на SEH машинерии, что все отягощает.

кто вам гарантировал неизменность лэйаута TIB от версии к версии Windows?

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

Правда, -1 - это не совсем хорошо, так как в Windows Server 2008 есть штука под названием SEHOP (https://msrc.microsoft.com/blog/2009/02/preventing-the-exploitation-of-structured-exception-handler-seh-overwrites-with-sehop/), которая требует в конце списка иметь специальный canary элемент. Поэтому хорошо бы этот заградительный элемент воссоздать в новосозданной нити.

С помощью #ifdef...#endif

Это зачастую не вариант. Например, если нити используются для превращения 3rd party кода в pull-стиле в код в push-стиле (например, libavformat делает блокирующие вызовы read - обернуть его в API типа "скушай еще этих вкусных данных и отдай мне результат" можно только через корутины/нити или через полноценные потоки).

Согласен. Спасибо за дополнение :)

Спасибо за важное замечание. В моём примере не учтено множество подобных моментов. Что-то можно допилить, но вряд ли кому-то это нужно для практических целей.

Почему ломается? Потому что SEH устроен так, что когда выбрасывается SEH-исключение, системный код начинает обходить цепочку SEH-фреймов в поисках обработчика, который возьмётся обработать исключений

Не актуально для x64. Код не создаёт seh-фреймы. Адреса обработчиков исключений прописаны статически в PE-файле.

В LabVIEW в общем всё тоже самое можно устроить, но, пожалуй чуть проще.

Вот смотите, допустим у меня два for цикла, я сделаю ровно такой же вывод, как у вас, только всё же назову это дело потоками, поскольку тут два потока и используется, и вот:

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

А теперь смотрите, я кладу оба эти потока в однопоточный Timed Loop (и заметтьте, что бонусом я могу ещё и ядро проца указать, на котором этот поток должен исполняться), и вот:

Теперь они честно отрабатывают в одном потоке c ID 13116. При этом они друг друга не тормозят, если я сделаю второе "волокно" сильно задумчивым, то первое его ждать не будет, а отработает разом все свои пять итераций:

В GetThreadID () я бросил небольшую задержку, она заставляет вставать "волокно" на ощутимое время, тогда эффект "поочерёдности" при примерно равной скорости исполнения волокон нагляднее получается, вот код, там честный GetCurrentThreadId() из WinAPI:

#include <Windows.h>
#include "Fibers.h"

int GetThreadID ()
{
	Sleep(10);
	return GetCurrentThreadId();
}

Как-то так. NI называет параллельно исполняющиеся несвязанные участки кода "чанками", (chunks), они всегда исполняются в параллельных потоках, пока не исчерпается пул, а дальше будут отрабатывать как "волокна". Эх, было б круто, если б вы вообще всё на чистом асме, включая вывод в консоль. Может в отпуске сделаю, когда заняться будет нечем. И да, спасибо!

Спасибо вам за идею! Действительно, одну задачу можно рассматривать и решать по-разному.

Hermoso e inspirador!!! Lo estudiaré y haré mis comentarios.. Saludos desde México :)

Микрософт детектед... :)
Хотелось бы посмотреть на ассемблер-64 в Linux из-под g++

Увидите :) С Микрософт тоже не всё просто, вот там специалисты про сложности с SEH верно написали, а я вообще этот момент никак не учёл.

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

Была идея сделать пример именно на базе "голого" микроконтроллера без ОС. Хотя бы того же Atmega328+Arduino. Но идея дать возможность всем желающим скачать и запустить пример без дополнительного оборудования определила целевую платформу. Согласен, что на МК это выглядит более органично.

Хотелось бы для МК STM32 без Ардуино и HAL, просто на CMSIS и C++, ну и asm конечно.

И непонял для себя, что будет если Нить зависла, как она передаст дальше управление, если сама висит в ожидании или цикле?

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

Как верно отметили коллеги firehacker и arteast, пример получился не дружественным к Windows SEH. Плюс к этим замечаниям, была высказана мысль использовать setjmp/longjmp.

С поддержкой SEH попробую разобраться, но есть подозрение, что это только усложнит пример и уменьшит его учебную ценность. Если получится решить эту проблему как-то не слишком сложно, например, поселив стеки волокон в стеке самой "родительской" thread, напишу статью по итогу с примерами, как оно было и как стало.

Что касается setjmp/longjmp, то там не очень понятно, откуда появится новый стек для fiber. Возможно, я что-то упускаю из виду и есть способ относительно безболезненно это уладить. Если так, то буду рад любым полезным рекомендациям на этот счёт.

Огромное спасибо все отозвавшимся за обратную связь и ценные дополнения!

Про SEH стоит отметить, что проблема с этим всем относится только к Win32 приложениям. SEH в x64 реализован совершенно по-другому, и там такой проблемы быть не должно (если только unwinder не дойдет насквозь сквозь весь стек нити до самого донышка - но это будет проблемой, обычно фатальной проблемой, и в обычном потоке ОС). Казалось бы. Но есть нюансы с C++ исключениями. Во-первых, MSVC-шная реализация C++ исключений прикапывает текущее исключение(я) в per-fiber data. Поскольку FLS не меняется, то могут быть неясные мне нюансы, если переключать нити изнутри catch блоков (а это очень легко может получиться при написании кода в RAII стиле, если вспомнить про неявные catch блоки для уничтожения локальных переменных). Во-вторых, если в приложении включена защита CFG, то в обработке тех же C++-ных исключениях от MS в функции __except_validate_context_record проверяется, что RSP в контексте исключения находится в границах стека (т.е. строго та же фигня, что и в Win32, только уже не в самой винде, а в стандартном рантайме MSVC). Но только если CFG.

Про setjmp... Стек из ниоткуда не появится. Его надо выделить точно так же, как это делается сейчас. И потом еще как-то запихнуть информацию о нем в jmp_buf. То есть либо наплевать на портабельность и лезть в неописанные кишочки jmp_buf, либо опять же непортабельным образом временно переключать стек руками a la lowLevelEnqueueFiberи уже оттуда делать первоначальныйsetjmp. Я вообще не уверен, есть ли в этом смысл. С одной стороны, оно надежнее. Например, вы сохраняете регистры XMM, но забыли сохранить регистр MXCSR; setjmp/longjmp не забудет. Но, с другой стороны, setjmp несовместим с shadow stack (/CETCOMPAT) - а руками можно было бы и замутить поддерживающий вариант; кроме того, если включена защита CFG, то функция __except_validate_jump_buffer проверит RSP, сохраненный внутри jmp_buf, на его положение внутри стека...

Ситуация интересная и требует дополнительного изучения. Постараюсь копнуть поглубже. Отдельно благодарю вас за разъяснения на счёт MXCSR. Действительно, я этот момент не учёл.

Про seh можно отдельной секцией, как расширение базового примера, так как с ним всё кристально ясно и понятно в текущем виде, и портить это не нужно. Но и исправленный вариант дать.

Скорее всего, так и сделаю :) Спасибо :)

Статья - огонь. Прочитал на одном дыхании. Комменты не отстают. К слову, есть такая чудная вещь, как gnu pth, с очень близким функционалом.

Спасибо за тёплые слова. В статье я хотел показать именно сам принцип. Конечно, я не сделал какое-то production-ready решение, а только демо. С gnu pth я не имел дела, посмотрю, что это такое и как работает. Вам плюсики :)

Очень интересно. Век живи, век учись. Про волокна не слышал раньше.
Ждем следующую статью - типа э... "манипулируем электронами в процессоре".

Спасибо за идею! "В процессоре" - это я вряд-ли осилю, но взвесить атом - почему бы и да... Хотите? Там всё просто, на уровне химии 9 класса.

Спасибо за отличную статью!

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

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

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

Идею понял. Интересно. Есть над чем подумать. Благодарю за добрые слова, они приятны не только кошке, но и мне :)

Отличная статья!

Даже для "непосвященного" было очень познавательно и местами даже понятно)

О, короутины. Еще прототреды из контики напоминает, но только напоминает.

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

видимо думал о чем то вечно:) они так последовательно переключаются ....

Это и есть "конь в вакууме". Демо-пример "как оно там внутри может быть реализовано" чтобы начинающим студентам проще понимать как этот чёрный ящик всё делает. Замысел был прост: вызвать интерес, желание пробовать и экспериментировать.

Когда-то (в 2014 году) я делал что-то подобное, но не для C++, а для Delphi: https://www.pvsm.ru/delphi/73959

И там только Win32, никакого Win64 нет (специфика тогдашней работы, а я саблюдал прицип YAGNI и не делал "на будущее" то что реально не было нужно).

Если все волокна (или единственное) крутят спинлок "пока время не достигло таймаута", - это просто разогрев процессора.

По-хорошему, нужен планировщик с вот таким апи:

  • yield() - просто отдать управление, у текущего волокна состояние из "active" переходит в "ready"

  • sleep_until(timestamp) - пусть планировщик сам вернёт в список ready, когда надо будет

  • sleep_for(duration) = sleep_until(now + duration)

А потом туда же прикрутить разные условия - poll/select, condition variable, и т.п.

Всё верно. Но для демонстрационного примера это будет избыточно. По-феншую нужно использовать системное API.

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

Учту на будущее. Я редко читаю русскоязычную документацию, поэтому у меня в сознании нет устоявшихся терминов на родном русском языке. Thread и "поток" у меня не складываются во что-то общее по смыслу.

Sign up to leave a comment.

Articles