Тесты работы сборщика мусора

Предистория

При работе с неуправляемым кодом очень важно понимать как нельзя передавать данные. Особенно считая что я использую всё чтобы уменьшить траты времени на вызов неуправляемого кода до предела в модулях OpenCL и OpenGL.

Но долгое время я основывался на странном поведении метода System.Diagnostics.Debug.WriteLine, который на старом Win7х64 ноуте с криво установленным .Net Framework почему-то заставлял объекты двигаться в памяти. И только 1 раз после запуска программы.
Ну, у меня были причины верить результатам что я получил с ним, потому что я исключил все остальные возможности что мог придумать. Но ещё тогда я не мог воспроизвести этот же эффект на других машинах, поэтому всё равно была вероятность что я тестирую поведение лично своей среды .Net, а не любой.

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


Пожалуйста, попробуйте у себя

Вот архив с тестом:

1.rar (190.8 КБ)

В архиве сразу запакована C++ библиотека, в которой важна всего одна функция:

extern "C" __declspec(dllexport) BYTE * copy_arr(int* a, void (*punch_gc)(void*))
{
    BYTE* res = new BYTE[20]; // Выделяем 20 байт неуправляемой памяти
    punch_gc(a); // Вызываем ту подпрограмму, чей адрес сюда передали
    memcpy(res, a, 20); // Копируем 20 байт из "a" в "res"
    return res; // Плохо что неуправляемая память не освобождается, но в этом тесте не важно
}

Библиотека уже откомпилирована и .pas должен работать из коробки, но желательно попробовать без и с перекомпиляцией этой библиотеки у вас, если есть студия с C++ пакетом.

Тесты находятся в единственном .pas файле и состоят в следующем:

Есть два массива, a и b.
Разные тесты пытаются по-разному передавать массив a неуправляемому коду.
Тем временем массив b просто лежит. Он нужен только чтобы видеть когда что-то да сдвинулось, если массив a был хорошо заблокирован.
Затем из неуправляемого кода управляемый callback пинает GC, чтобы он сдвинул управляемые объекты (в идеале какие может, но пока надеемся на шанс). И в конце проверяется, есть ли разница между тем, какой адрес хранится в неуправляемом коде и какой реальный адрес содержимого массива после пинания GC.

Для тестирования надо запускать файл 1.pas из корня архива, раскомметируя одну из строк из региона заголовки вызова copy_arr.


Как понимать вывод:

К примеру, если раскомметировать третий заголовок из группы безопасных:

var ptr := copy_arr(a[1],

Вывод будет чем то типа:

begin
$307D500
$307D560
before gc
$307D500
$307D504
$307D560
after gc
$307D500
$307D504
$3017E70
end
$307D500
$3017E70
2 0 0 0 3 0 0 0 4 0 0 0 5 0 0 0 6 0 0 0

Главные разделы на которые надо смотреть это before gc и after gc. Оба устроены одинаково, 3 числа значат:

  • Реальный адрес содержимого массива a.
  • Адрес, который хранится в неуправляемом коде.
  • Реальный адрес содержимого массива b.

Изначально адрес содержимого a, хранимый в неуправляемом коде был на 4 больше реального. Это правильно, потому что заголовок передаёт адрес [1] элемента массива, и каждый элемент занимает по 4 байта.

После пинка GC содержимое a осталось там где было, а содержимое b переместилось с $307D560 на $3017E70. То есть сборка мусора произошла и сдвинула объекты, но массив a остался заблокировать в памяти.

Желательно запускать несколько раз, потому что, как написано выше - пинок GC всё ещё надеется на очень плавающий шанс.


Другой пример - первый из не безопасных заголовков:

var ptr := copy_arr(Marshal.UnsafeAddrOfPinnedArrayElement(a,0),

Вывод:

begin
$2A5D500
$2A5D560
before gc
$2A5D500
$2A5D500
$2A5D560
after gc
$29F7E70
$2A5D500
$29F7ED0
end
$29F7E70
$29F7ED0
1 0 0 0 2 0 0 0 3 0 0 0 4 0 0 0 5 0 0 0

В этом случае посылали адрес начала содержимого a: $2A5D500. После пинка GC реальный адрес поменялся на $29F7E70, но неуправляемый код всё ещё держит старый адрес.

Тут смотреть на b уже нет смысла - состояние массива a уже доказало что метод начинающийся с Marshal.Unsafe таки является не безопасным.


TL;DR

Чтобы протестировать и подтвердить что поведение GC у вас такое же как у меня - надо запустить файл 1.pas из корня архива, пробуя расскомметировать каждую 1 строчку из региона заголовки вызова copy_arr. С каждой из этих строчек запускать несколько раз по следующему принципу:

Если расскоментирована строчка после комментария // Безопастно, первые числа после before gc и после after gc в выводе должны быть одинаковыми, но при этом третьи числа там же должны быть разными.
Тест успешно пройден если при нескольких запусках теста получили разные третьи числа, но при этом ни в при одном из запусков не получили разные первые числа.

Если расскоментирована строчка после комментария // НЕ безопастно, первые числа после before gc и после after gc в выводе должно быть разными.
Тест успешно пройден если хотя бы при одном из запусков теста получили разные первые числа.

Пока что успешно протестировано на:

  • Win10 (новейшая 19044.1566) x64 (2 шт.)
  • Win8.1 (древняя 9600) x64 (1 шт.)
  • Win7 (SP1 Ultimate) x64 (1 шт.)

Это нужно сделать для тестирования вашей библиотеки?

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

Попробовал на arch-linux + mono. mono оказался слишком умным.
Я нашёл как заставить его двигать управляемые объекты так же как в .Net Framework. Но если адрес содержимого объекта (не обязательно начала содержимого) оказывается в передан в неуправляемый код, вообще не важно каким типом - даже указателем или в виде IntPtr - объект перестаёт двигаться.
Ну и Windows10+mono работает так же.

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