Подтвердить что ты не робот

Weird stackoverflow в С# при распределении ссылочных типов

Выполняя некоторые причудливые генерации кода, я столкнулся с переполнением стека, которое я не понимаю.

Мой код в основном такой:

static Tuple<string, int>[] DoWork() 
{
    // [ call some methods ]
    Tuple<string, int>[] tmp = new Tuple<string, int>[100];
    tmp[0] = new Tuple<string, int>("blah 1", 0);
    tmp[1] = new Tuple<string, int>("blah 2", 1);
    tmp[2] = new Tuple<string, int>("blah 3", 2);
    // ...
    tmp[99] = new Tuple<string, int>("blah 99", 99);
    return tmp;
}

Если вы используете небольшие цифры, как здесь (100), все работает нормально. Если цифры большие, то случаются странные вещи. В моем случае я попытался испустить примерно 10K строк кода, подобных этому, что вызвало исключение.

Итак... почему я думаю, что это странно:

  • tmp является локальным ссылочным типом, поэтому я ожидаю, что в куче будет выделен только указатель.
  • Кортежи являются ссылочными типами и выделяются в куче.
  • Нет рекурсии или другой странности; afaik требования к хранению в куче должны быть ограничены.

Воспроизведение странности...

Я не могу воспроизвести stackoverflow в минимальном тестовом сценарии, но я заметил, что он запускается на 64-разрядной версии .NET 4.5. Я могу дать некоторые доказательства, демонстрирующие, что происходит.

Также обратите внимание, что в реальном коде используется код Reflection.Emit, который генерирует это генерирование кода... он не похож на сам код, который имеет все эти строки кода... Испускаемый код IL правильный BTW.

В Visual Studio - поставить точку останова на последней строке. Обратите внимание на использование указателя стека при разборке (ASM, а не IL).

Теперь добавьте новую строку в код - например. tmp[100] = // the usuals. Поместите здесь точку останова и обратите внимание, что используемое пространство стека растет.

Что касается попытки воспроизвести использование минимального тестового примера с помощью Reflection.Emit, это код (который НЕ воспроизводит проблему как ни странно), но очень близок к тому, что я сделал, чтобы вызвать переполнение стека... это должно дать немного картины, что я пытаюсь сделать, и, возможно, кто-то другой может создать жизнеспособный тестовый пример, используя это). Здесь:

public static void Foo()
{
    Console.WriteLine("Foo!");
}

static void Main(string[] args)
{
    // all this just to invoke one opcode with no arguments!
    var assemblyName = new AssemblyName("MyAssembly");

    var assemblyBuilder =
        AppDomain.CurrentDomain.DefineDynamicAssembly(assemblyName,
        AssemblyBuilderAccess.RunAndCollect);

    // Create module
    var moduleBuilder = assemblyBuilder.DefineDynamicModule("MyModule");

    var type = moduleBuilder.DefineType("MyType", TypeAttributes.Public, typeof(object));

    var method = type.DefineMethod("Test", System.Reflection.MethodAttributes.Public | System.Reflection.MethodAttributes.Static, System.Reflection.CallingConventions.Standard, typeof(Tuple<string, int>[]), new Type[0]);

    ILGenerator gen = method.GetILGenerator();
    int count = 0x10000;

    gen.Emit(OpCodes.Call, typeof(StackOverflowGenerator).GetMethod("Foo"));

    var loc = gen.DeclareLocal(typeof(Tuple<string, int>[]));
    gen.Emit(OpCodes.Ldc_I4, count);
    gen.Emit(OpCodes.Newarr, typeof(Tuple<string, int>));
    gen.Emit(OpCodes.Stloc, loc);

    for (int i = 0; i < count; ++i)
    {
        // Load array
        gen.Emit(OpCodes.Ldloc, loc);
        gen.Emit(OpCodes.Ldc_I4, i);

        // Construct tuple:
        gen.Emit(OpCodes.Ldstr, "This is the string");
        gen.Emit(OpCodes.Ldc_I4, i);
        gen.Emit(OpCodes.Newobj, typeof(Tuple<string, int>).GetConstructor(new[] { typeof(string), typeof(int) }));

        // Store in the array
        gen.Emit(OpCodes.Stelem_Ref);
    }

    // Return the result
    gen.Emit(OpCodes.Ldloc, loc);
    gen.Emit(OpCodes.Ret);

    var materialized = type.CreateType();

    var tmp = checked((Tuple<string, int>[])materialized.GetMethod("Test").Invoke(null, new object[0]));

    int total = 0;
    foreach (var item in tmp)
    {
        total += item.Item1.Length + item.Item2;
    }
    Console.WriteLine("Total: {0}", total);
    Console.ReadLine();
}

Мой вопрос

Как это может произойти, например, SOE? Что здесь происходит? Почему в этом контексте все вещи помещаются в стек?

4b9b3361

Ответ 1

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

TL;DR

Каждому оператору new в функции требуется DWORD в стеке, даже new object(), который будет присутствовать независимо от режима оптимизации и выпуска/отладки! Это фактически означает, что вы ограничены в количестве раз, когда ключевое слово new присутствует в функции, в зависимости от вашего размера стека.

Что вызывает проблему?

SOF вызван тем, что JIT генерирует код, который пытается выделить слишком много места в стеке (используя sub esp <number>). JIT выбирает, сколько нужно выделить при проверке использования стека в функции. Если у вас много локальных переменных, ваша функция должна будет использовать больше памяти в стеке, а JIT не может знать, насколько большой будет стек во время выполнения, поэтому он вылетает во время выполнения. Временное решение может заключаться в том, чтобы сделать стек более крупным, используя флаги компилятора или такие.

Кто это виноват?

Ваш код не использует много переменных в стеке, на самом деле, вы явно используете только один указатель на массив.

Однако ваш код (при использовании без оптимизации) создает много "временных одноразовых" переменных, каждый для каждого string и каждого integer, который вы используете в new Tuple<...>. Они исчезнут с включенной оптимизацией.

i.e, а не что-то вроде этого:

var x = new Tuple<string, int>("blah 1", 0);
tmp[0] = x;
x = new Tuple<string, int>("blah 2", 1);
tmp[1] = x;

В итоге вы получите что-то вроде этого:

var str1 = "blah 1";
var int1 = 0;
var x = new Tuple<string, int>(str1, int1);
tmp[0] = x;
var str2 = "blah 2";
var int2 = 1;
var x2 = new Tuple<string, int>(str2, int2);
tmp[1] = x2;

Как вы можете видеть в этой разборке:

            tmp[0] = new Tuple<string, int>("blah 1", 0);
00FB26AE  mov         ecx,6D5203BCh  
00FB26B3  call        00F32100  
00FB26B8  mov         dword ptr [ebp-48h],eax  
00FB26BB  push        0  
00FB26BD  mov         edx,dword ptr ds:[3B721F0h]  
00FB26C3  mov         ecx,dword ptr [ebp-48h]  
00FB26C6  call        6D47C0DC  
00FB26CB  push        dword ptr [ebp-48h]  
00FB26CE  mov         ecx,dword ptr [ebp-3Ch]   // ecx = (ebp - 0x3C) [ == tmp ]
00FB26D1  xor         edx,edx  
00FB26D3  call        6E2883FF                  // ecx.setElement(0, ebp - 0x48) 
            tmp[1] = new Tuple<string, int>("blah 2", 1);
00FB26D8  mov         ecx,6D5203BCh  
00FB26DD  call        00F32100  
00FB26E2  mov         dword ptr [ebp-4Ch],eax  
00FB26E5  push        1  
00FB26E7  mov         edx,dword ptr ds:[3B721F4h]  
00FB26ED  mov         ecx,dword ptr [ebp-4Ch]  
00FB26F0  call        6D47C0DC  
00FB26F5  push        dword ptr [ebp-4Ch]
00FB26F8  mov         ecx,dword ptr [ebp-3Ch]  // ecx = (ebp - 0x3C) [ == tmp ]
00FB26FB  mov         edx,1  
00FB2700  call        6E2883FF                 // ecx.setElement = (1, ebp - 0x4C)

Давайте изменим ваш код на что-то вроде этого:

Tuple<string, int>[] tmp = new Tuple<string, int>[10000];
var str = "blah 1";
var i = 0;
var x = new Tuple<string, int>(str, i);
tmp[0] = x;

str = "blah 2";
i = 1;
x = new Tuple<string, int>(str, i);
tmp[1] = x;

Этот код создает функцию, которая использует меньше памяти в стеке стека. Однако при более глубокой проверке этот код также будет создавать переменную "один раз" в стеке для каждого new Tuple, поэтому, увеличивая количество назначений, вы также увеличиваете использование стека.

            str = "blah 2";
008A26E9  mov         eax,dword ptr ds:[32421F4h]  
008A26EF  mov         dword ptr [ebp-10h],eax  
            i = 1;
008A26F2  mov         dword ptr [ebp-8],1  
            x = new Tuple<string, int>(str, i);
008A26F9  mov         ecx,6D5203BCh  
008A26FE  call        006C2100  
008A2703  mov         dword ptr [ebp-20h],eax           // this is the one-time variable
008A2706  push        dword ptr [ebp-8]  
008A2709  mov         ecx,dword ptr [ebp-20h]  
008A270C  mov         edx,dword ptr [ebp-10h]  
008A270F  call        6D47C0DC  
008A2714  mov         eax,dword ptr [ebp-20h]  
008A2717  mov         dword ptr [ebp-14h],eax  
            tmp[1] = x;
008A271A  push        dword ptr [ebp-14h]  
008A271D  mov         ecx,dword ptr [ebp-0Ch]  
008A2720  mov         edx,1  
008A2725  call        6E2883FF  

            str = "blah 3";
008A272A  mov         eax,dword ptr ds:[32421F8h]  

            str = "blah 3";
008A2730  mov         dword ptr [ebp-10h],eax  
            i = 2;
008A2733  mov         dword ptr [ebp-8],2  
            x = new Tuple<string, int>(str, i);
008A273A  mov         ecx,6D5203BCh  
008A273F  call        006C2100  
008A2744  mov         dword ptr [ebp-24h],eax           // this is the one-time variable
008A2747  push        dword ptr [ebp-8]  
008A274A  mov         ecx,dword ptr [ebp-24h]  
008A274D  mov         edx,dword ptr [ebp-10h]  
008A2750  call        6D47C0DC  
008A2755  mov         eax,dword ptr [ebp-24h]  
008A2758  mov         dword ptr [ebp-14h],eax  
            tmp[2] = x;
008A275B  push        dword ptr [ebp-14h]  
008A275E  mov         ecx,dword ptr [ebp-0Ch]  
008A2761  mov         edx,2  
008A2766  call        6E2883FF  

Что еще хуже, так это то, что он будет генерировать эту переменную "один раз" в стеке даже в режиме выпуска с включенными оптимизациями!

Это заставляет меня поверить, что это проблема либо для механизма JIT, либо для самого компилятора. Поэтому давайте проверим MSIL, который дал нам компилятор:

ldstr    aBlah2         // "blah 2"
stloc.1                 // Pop value from stack into local variable 1
ldc.i4.1                // Push 1 onto the stack as I4
stloc.2                 // Pop value from stack into local variable 2
ldloc.1                 // Load local variable 1 onto stack
ldloc.2                 // Load local variable 2 onto stack
newobj   instance void class [mscorlib]System.Tuple`2<string, int32>::.ctor(var<u1>, !!T0) // Create a new object
stloc.3                 // Pop value from stack into local variable 3
ldloc.0                 // Load local variable 0 onto stack
ldc.i4.1                // Push 1 onto the stack as I4
ldloc.3                 // Load local variable 3 onto stack
stelem.ref              // Replace array element at index with the ref value on the s

Что при комментировании:

push "blah 2"
local_str = pop // "blah 2"
push 1
local_int = pop
push local_str // "blah 2"
push local_int // 1

push new Tuple(...)
local_tuple = pop
push local_array
push 0
push local_tuple
pop[pop] = pop (i.e arr[indx] = value)

Таким образом, код JIT выглядит нормально.

Поэтому я заключаю, что это проблема в движке JIT

Как правило, это означает, что для каждой конструкции класса Tuple в стеке используется ненужный DWORD, что очень плохо для таких случаев, как у вас, но ничего не значит для программ, которые этого не делают очень много "ручных" назначений, таких как ваш код.

Это происходит даже для небольших функций, и это действительно странно!

В x64-бит следующий код С#:

var a = new object();
a = new object();
a = new object();
a = new object();
a = new object();
a = new object();
a = new object();

Скомпилировано и JIT:

            a = new object();
00007FFAD0033B5F  call        00007FFB2F662300  
00007FFAD0033B64  mov         qword ptr [rsp+40h],rax  
00007FFAD0033B69  mov         rax,qword ptr [rsp+40h]  
00007FFAD0033B6E  mov         qword ptr [rsp+48h],rax  
00007FFAD0033B73  mov         rcx,qword ptr [rsp+48h]  
00007FFAD0033B78  call        00007FFB2E455BC0  
00007FFAD0033B7D  nop  
            a = new object();
00007FFAD0033B7E  lea         rcx,[7FFB2E6611B8h]  
00007FFAD0033B85  call        00007FFB2F662300  
00007FFAD0033B8A  mov         qword ptr [rsp+50h],rax  
00007FFAD0033B8F  mov         rax,qword ptr [rsp+50h]  
00007FFAD0033B94  mov         qword ptr [rsp+58h],rax  
00007FFAD0033B99  mov         rcx,qword ptr [rsp+58h]  
00007FFAD0033B9E  call        00007FFB2E455BC0  
00007FFAD0033BA3  nop  
// and so on....

И создает много неиспользуемых QWORD s.

В x86 код выглядит так:

            a = new object();
00882687  mov         ecx,6D512554h  
0088268C  call        00652100  
00882691  mov         dword ptr [ebp-0Ch],eax  
00882694  mov         ecx,dword ptr [ebp-0Ch]  
00882697  call        6D410B40  
0088269C  nop  
            a = new object();
0088269D  mov         ecx,6D512554h  
008826A2  call        00652100  
008826A7  mov         dword ptr [ebp-10h],eax  
008826AA  mov         ecx,dword ptr [ebp-10h]  
008826AD  call        6D410B40  
008826B2  nop  
// and so on...

Что гораздо эффективнее, но все же "отходы" много DWORDS.

Что вы можете сделать?

Собственно, не так много. Корень проблемы заключается в том, что JIT должен выделять DWORD в стеке для каждого оператора new (может быть, поэтому он может отслеживать их? Я не могу сказать). Ваше единственное решение (без фиксированного) состоит в том, чтобы сделать несколько функций, каждая из которых будет обрабатывать часть необходимых вам назначений.