Как стать автором
Обновить

Любителям x86-64 assembler посвящается: DIY волокна в C++

Уровень сложностиСложный
Время на прочтение26 мин
Количество просмотров8.4K
Всего голосов 97: ↑97 и ↓0+123
Комментарии35

Комментарии 35

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

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

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

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

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

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

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

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

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

Спасибо :)

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

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

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

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

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

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

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

Место под стек нового фибера автор разметил где? В массиве, который является частью структуры (класса) 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 типа "скушай еще этих вкусных данных и отдай мне результат" можно только через корутины/нити или через полноценные потоки).

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

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

В 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 :)

Saludos desde Serbia!

Микрософт детектед... :)
Хотелось бы посмотреть на ассемблер-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 с выставлением времени оставшегося ожидания в описатель нити. И например обрабатывать это ожидание на уровне самого шедулера, а не после переключения в контекст нити. Вообще на неё не переключаться например. Или запускать её с большим приоритетом по истечении таймаута.

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

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

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

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации