Перейду сразу к делу =) Задача: в любой точке кода путем вызова спец. метода создать второй поток, который начнет выполнение с точки вызова этого метода в родительском потоке, сохранив возможность отладки и значения всех локальных переменных на всех уровнях вызовов методов.
Реализация не зависит от конечной платформы (.Net/Java), т.к. написана на C++/Asm, однако пользовательский код сделан на C#, т.к. на нем пишу я.
Теперь, когда я наконец стабилизировал пример для 32-разрядных систем, набравшись храбрости, готов показать его общественности как полностью готовый. И, да, повторюсь: при адаптации будет работать на любой платформе
Цели
Целью работы является построение функционала, связанного с потоками, которое не предусмотрено операционной системой. Для примера был взят метод Fork() операционной системы Linux, исправленный для реалий OC Windows.
Итак, если у нас есть метод Original, внутри которого в некоторой его части вызывается метод Fork.CloneThread(), должен возникнуть второй поток исполнения, начало которого будет равным точке вызова метода Fork.CloneThread() и исполнение которого будет закончено на выходе из метода Original таким образом, чтобы во втором потоке исполнения были сохранены все значения локальных переменных исходного потока. Другими словами, чтобы вызов CloneThread() разделил текущий поток на два.
Что требуется от читателя
- Отсутствие страха читать ассемблер. Это просто =) Там где что-то не понятно, use google
- Понимание что стек потока — по одному на поток. Понимание, для чего он есть
Материалы на подготовку:
Клонирование потока
Что у нас есть изначально? Есть наш поток. Также есть возможность создать новый поток либо запланировать задачу в пул потоков, выполнив там свой код. Также мы понимаем, что информация по вложенным вызовам хранится в стеке вызовов и что при желании мы можем ей манипулировать (например, используя C++/CLI). Причем, если следовать соглашениям и вписать в верхушку стека значение регистра EBP, адрес возврата для иснтрукции ret и выделить место под локальные (если необходимо), таким образом можно имитировать вызов метода.
Что же необходимо сделать чтобы склонировать поток?
- Сохранение
- Внутри метода CloneThread (C#) получаем адрес любой локальной переменной
- Делаем вызов C++ метода, передав ей этот адрес. На данном этапе стек вызовов выглядит так:
Ну, или в сокращенной манере, так:
- Внутри — получаем значение EBP — указателя на свой фрейм вызова и по цепочке, разыменовывая указатель идем к методу CloneThread, сверяя текущий EBP с адресом локальной переменной в CloneThread. Это необходимо для того, чтобы пройти все прокси-вызовы между C# и C++, которые генерируюся JITter'ом.
- Прибавляем 1 чтобы выйти из фрейма CloneThread и попасть в вызывающий нашу библиотечную функцию, код. Все от полученного адреса до ESP — это цепочка вызовов от пользовательского кода. Сохраняем ее в буфер, создаем поток (или берем из пула) и передаем ему адрес этого буфера — копии стека.
- Восстановление. Для того чтобы новый поток продолжил работу с точки копирования в родительском, необходимо имитировать вызов CloneThread() из пользовательского метода, который был вызван в новом потоке (который в реальности никто не вызывал). Для этого мы должны в верхушку нашего стека вызовов дописать сохраненный кусок стека родительского потока, поправить цепочку EBP, образующую стек фреймы и запустить код.
- Изначально, когда наш код только начал работать во втором потоке, мы имеем такой вид стеков вызовов:
- Получаем адрес ESP.
- Пушим в стек адрес тела текущего метода — для возврата из пользовательского метода, имитация вызова которого будет произведена
- Пушим EBP — для поддержки целостности стекфреймов. Вместе с копией стека на куче, мы имеем следующий вид стеков вызовов:
- Исправляем сохраненные цепочки EBP в копии стека (на месте делать нельзя) перед копированием
- Командами push вставляем в текущий стек копию стека родительского потока (имитируем вызов пользовательского метода, который вызвал CloneThread, который вызвал множество прокси и C++ метод как итог)
- Делаем дальний JMP в C++ метод CloneThread, в котором обеспечиваем запуск return
- Это приводит к выходу в CloneThread (C#), который выходит в пользовательский метод
- Вуаля — в обоих потоках код исполняется с одной и той же точки. Ветвление потока закончено.
- Изначально, когда наш код только начал работать во втором потоке, мы имеем такой вид стеков вызовов:
Для чего это делать
Самое важное, для чего это делается — для закрепления понимания, как все работает и что если знать, можно этим начать манипулировать.
Ресурсы
- GitHub проект DotNetEx: проект в нем называется AdvancedThreadingLibrary, для запуска использовать RocketScience /01-forkingThread. Кстати, в этой же библиотеке есть примеры с sizeof(ReferenceType), IoC с отгружаемой сборкой и пулом объектов в своем хипе
Автор: sidristij