Что быстрее: распределение стека или выделение кучи

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

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

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

Это поражает меня как нечто, что, вероятно, будет очень зависимым от компилятора. Для этого проекта, в частности, я использую компилятор Metrowerks для PPC. Проницательность в этой комбинации была бы наиболее полезной, но, в общем, для GCC и MSVС++, в чем дело? Является ли распределение кучи не столь высоким, как распределение стека? Разве нет разницы? Или это разница, так что минута становится бессмысленной микрооптимизацией.

426
задан Adam 02 окт. '08 в 9:06
источник поделиться
23 ответов

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

Кроме того, стек против кучи не только учитывает производительность; он также много говорит о ожидаемом сроке жизни объектов.

426
ответ дан Torbjörn Gyllebring 02 окт. '08 в 9:09
источник поделиться

Стек намного быстрее. Он в буквальном смысле использует только одну инструкцию для большинства архитектур, в большинстве случаев, например. на x86:

sub esp, 0x10

(Это перемещает указатель стека вниз на 0x10 байт и тем самым "распределяет" эти байты для использования переменной.)

Конечно, размер стека очень, очень конечный, так как вы быстро узнаете, злоупотребляете ли вы распределением стека или пытаетесь выполнить рекурсию: -)

Кроме того, есть небольшая причина для оптимизации производительности кода, который не нуждается в его проверке, например, с помощью профилирования. "Преждевременная оптимизация" часто вызывает больше проблем, чем стоит.

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

145
ответ дан Dan Lenski 02 окт. '08 в 9:16
источник поделиться

Честно говоря, тривиально написать программу для сравнения производительности:

#include <ctime>
#include <iostream>

namespace {
    class empty { }; // even empty classes take up 1 byte of space, minimum
}

int main()
{
    std::clock_t start = std::clock();
    for (int i = 0; i < 100000; ++i)
        empty e;
    std::clock_t duration = std::clock() - start;
    std::cout << "stack allocation took " << duration << " clock ticks\n";
    start = std::clock();
    for (int i = 0; i < 100000; ++i) {
        empty* e = new empty;
        delete e;
    };
    duration = std::clock() - start;
    std::cout << "heap allocation took " << duration << " clock ticks\n";
}

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

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

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

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

Если бы я заботился о наносекундной точности, я бы не использовал std::clock(). Если бы я хотел опубликовать результаты в качестве докторской диссертации, я бы сделал большую сделку по этому поводу, и я бы, вероятно, сравнил GCC, Tendra/Ten15, LLVM, Watcom, Borland, Visual С++, Digital Mars, ICC и другие компиляторы. Как бы то ни было, распределение кучи требуется в сотни раз дольше, чем распределение стека, и я не вижу ничего полезного в дальнейшем изучении вопроса.

У оптимизатора есть задача избавиться от кода, который я тестирую. Я не вижу причин, чтобы сказать, что оптимизатор запускается, а затем попытаться обмануть оптимизатора, фактически не оптимизируя. Но если бы я увидел ценность при этом, я бы сделал одно или несколько из следующего:

  • Добавить элемент данных в empty и получить доступ к этому элементу данных в цикле; но если я только когда-либо прочитал из элемента данных, оптимизатор может делать постоянную фальцовку и удалять петлю; если я только когда-либо напишу члену данных, оптимизатор может пропустить все, кроме самой последней итерации цикла. Кроме того, вопрос заключался не в "распределении стека и доступе к данным против распределения кучи и доступа к данным".

  • Объявить e volatile, но volatile часто компилируется неправильно (PDF).

  • Возьмите адрес e внутри цикла (и, возможно, назначьте его переменной, объявленной extern и определенной в другом файле). Но даже в этом случае компилятор может заметить, что - в стеке по крайней мере - e всегда будет выделяться по одному и тому же адресу памяти, а затем делать постоянную фальцовку, как в (1) выше. Я получаю все итерации цикла, но объект никогда не выделяется.

Помимо очевидного, этот тест является ошибочным в том, что он измеряет как распределение, так и освобождение, а исходный вопрос не спрашивает об освобождении. Конечно, переменные, выделенные в стеке, автоматически освобождаются в конце своей области, поэтому не вызывать delete будет (1) перекосить числа (освобождение стека включено в числа о распределении стека, поэтому справедливо оценивать освобождение кучи ) и (2) вызывают довольно плохую утечку памяти, если мы не сохраним ссылку на новый указатель и не позвоним delete после того, как у нас получится измерение времени.

На моей машине, используя g++ 3.4.4 в Windows, я получаю "0 тактов" для распределения стека и кучи для чего-либо менее 100000 распределений, и даже тогда я получаю "0 тактов времени" для распределения стека и "15 тактов" для распределения кучи. Когда я измеряю 10 000 000 распределений, распределение стека занимает 31 такт, а распределение кучи занимает 1562 такта.


Да, оптимизирующий компилятор может ускорить создание пустых объектов. Если я правильно понимаю, он может даже превысить весь первый цикл. Когда я натолкнулся на итерации до 10 000 000 распределений стека, ушло 31 такт, а распределение кучи заняло 1562 такта. Я с уверенностью могу сказать, что, не указав g++ для оптимизации исполняемого файла, g++ не исключил конструкторы.


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

Использование слегка измененной версии эталона (для того, чтобы адресовать действительную точку, в которой исходная программа не выделяла что-либо в стеке каждый раз через цикл) и компиляции без оптимизации, но связываясь с релизными библиотеками (для обращения к действительной точке что мы не хотим включать замедление, вызванное связыванием с библиотеками отладки):

#include <cstdio>
#include <chrono>

namespace {
    void on_stack()
    {
        int i;
    }

    void on_heap()
    {
        int* i = new int;
        delete i;
    }
}

int main()
{
    auto begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_stack();
    auto end = std::chrono::system_clock::now();

    std::printf("on_stack took %f seconds\n", std::chrono::duration<double>(end - begin).count());

    begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_heap();
    end = std::chrono::system_clock::now();

    std::printf("on_heap took %f seconds\n", std::chrono::duration<double>(end - begin).count());
    return 0;
}

отображается:

on_stack took 2.070003 seconds
on_heap took 57.980081 seconds

в моей системе при компиляции с командной строкой cl foo.cc /Od /MT /EHsc.

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

on_stack took 0.000000 seconds
on_heap took 51.608723 seconds

Не потому, что распределение стека фактически мгновенно, но потому, что любой полупристойный компилятор может заметить, что on_stack не делает ничего полезного и может быть оптимизирован. GCC на моем ноутбуке Linux также замечает, что on_heap не делает ничего полезного и оптимизирует его:

on_stack took 0.000003 seconds
on_heap took 0.000002 seconds
on_stack took 0.000003 seconds
on_heap took 0.000002 seconds
104
ответ дан Max Lybbert 02 окт. '08 в 21:11
источник поделиться

Интересная вещь, которую я узнал о Stack vs. Heap Allocation на Xbox 360 Xenon-процессоре, который также может применяться к другим многоядерным системам, заключается в том, что выделение в куче вызывает критический раздел для остановки всех остальных ядер, так что это не конфликтует. Таким образом, в замкнутой петле, Stack Allocation был способом пойти для массивов фиксированного размера, поскольку это предотвращало ларьки.

Это может быть еще одно ускорение для рассмотрения, если вы кодируете multicore/multiproc, поскольку выделение стека будет доступно только для ядра, использующего вашу ограниченную функцию, и это не повлияет на другие ядра/процессоры.

26
ответ дан Furious Coder 02 марта '09 в 4:55
источник поделиться

Вы можете написать специальный распределитель кучи для конкретных размеров объектов, которые очень эффективны. Однако общий распределитель кучи не особенно эффективен.

Также я согласен с Torbjörn Gyllebring о ожидаемом сроке жизни объектов. Хорошая точка!

16
ответ дан Chris Jester-Young 02 окт. '08 в 9:08
источник поделиться

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

6
ответ дан Jay 26 окт. '09 в 20:36
источник поделиться

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

Иногда для распределения стека требуется добавить страницы (-и) виртуальной памяти. Добавление новой страницы обнуленной памяти не требует чтения страницы с диска, поэтому обычно это будет на несколько тонн быстрее, чем поиск кучи (особенно если часть кучи выгружалась тоже). В редкой ситуации, и вы могли бы построить такой пример, достаточно места, просто оказывается доступным в части кучи, которая уже находится в ОЗУ, но выделение новой страницы для стека должно ждать, когда какая-нибудь другая страница будет выписана на диск. В этой редкой ситуации куча быстрее.

5
ответ дан Windows programmer 02 окт. '08 в 9:18
источник поделиться

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

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

В 32-разрядных операционных системах, которые имеют несколько потоков, стеки часто довольно ограничены (хотя обычно, по крайней мере, несколько мб), поскольку адресное пространство должно быть вырезано, и рано или поздно один поток стека будет запущен в другой, В однопоточных системах (Linux glibc однопоточно) ограничение намного меньше, потому что стек может просто расти и расти.

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

5
ответ дан MarkR 02 окт. '08 в 9:12
источник поделиться

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

3
ответ дан MSalters 03 окт. '08 в 18:35
источник поделиться

Стек имеет ограниченную емкость, а куча - нет. Типичный стек для процесса или потока составляет около 8K. Вы не можете изменить размер после его выделения.

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

Самое главное, вы не можете заранее предсказать общую цепочку вызовов функций. Таким образом, выделение всего 200 байтов с вашей стороны может привести к переполнению стека. Это особенно важно, если вы пишете библиотеку, а не приложение.

3
ответ дан yogman 02 окт. '08 в 19:57
источник поделиться

Выделение стека - это пара инструкций, тогда как самый быстрый известный мне распределитель кучи rtos (TLSF) использует в среднем порядка 150 инструкций. Кроме того, для распределения стека не требуется блокировка, потому что они используют локальное хранилище потоков, что является еще одним огромным выигрышем в производительности. Таким образом, распределение стека может быть на 2-3 порядка быстрее в зависимости от того, насколько сильно многопоточная среда.

В общем случае распределение кучи является вашим последним средством, если вы заботитесь о производительности. Жизнеспособный промежуточный вариант может быть фиксированным распределителем пула, который также является лишь инструкциями пары и имеет очень мало ресурсов для распределения, поэтому он отлично подходит для небольших объектов фиксированного размера. С другой стороны, он работает только с объектами фиксированного размера, по своей сути не является потокобезопасным и имеет проблемы фрагментации блоков.

3
ответ дан Andrei Pokrovsky 17 авг. '10 в 23:22
источник поделиться

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

Тем не менее, существуют большие проблемы при работе с общей производительностью стека и распределения на основе кучи (или в несколько лучших условиях, локальное и внешнее распределение). Обычно распределение кучи (внешнего) происходит медленно, поскольку оно имеет дело со многими различными типами распределения и шаблонами распределения. Уменьшение объема используемого вами распределителя (что делает его локальным для алгоритма/кода) будет способствовать повышению производительности без каких-либо серьезных изменений. Добавление лучшей структуры к вашим шаблонам распределения, например, принудительное упорядочение LIFO по парам распределения и освобождения может также улучшить производительность распределителя, используя распределитель более простым и структурированным способом. Или вы можете использовать или написать распределитель, настроенный для вашего конкретного шаблона распределения; большинство программ часто выделяют несколько дискретных размеров, поэтому куча, основанная на буфере просмотра нескольких фиксированных (предпочтительно известных) размеров, будет работать очень хорошо. По этой причине Windows использует свою низкоразрушающую кучу.

С другой стороны, распределение на основе стека в 32-битном диапазоне памяти также чревато опасностью, если у вас слишком много потоков. Для стеков требуется непрерывный диапазон памяти, поэтому чем больше потоков у вас есть, тем больше виртуального пространства адресов вам потребуется для запуска без. Это не будет проблемой (на данный момент) с 64-разрядной версией, но это может привести к хаосу в длинных программах с большим количеством потоков. Запуск виртуального адресного пространства из-за фрагментации - это всегда боль, с которой приходится иметь дело.

3
ответ дан MSN 10 авг. '10 в 19:27
источник поделиться

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

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

3
ответ дан larsivi 02 окт. '08 в 11:34
источник поделиться

Я думаю, что жизненное время имеет решающее значение, и нужно ли строить сложную вещь. Например, при моделировании, основанном на транзакциях, вам обычно необходимо заполнить и передать структуру транзакций с кучей полей для функций работы. Посмотрите на стандарт OSCI SystemC TLM-2.0 для примера.

Выделение их в стеке близко к вызову операции приводит к огромным накладным расходам, поскольку строительство дорого. Хороший способ состоит в том, чтобы выделять кучу и повторно использовать объекты транзакции путем объединения или простой политики, например, "для этого модуля требуется только один объект транзакции".

Это во много раз быстрее, чем выделение объекта при каждом вызове операции.

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

Я бы сказал: попробуйте оба и посмотрите, что лучше всего работает в вашем случае, потому что это действительно может зависеть от поведения вашего кода.

3
ответ дан jakobengblom2 02 окт. '08 в 9:43
источник поделиться

Существует общая точка зрения о таких оптимизациях.

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

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

Только если вы обнаружите, что он тратит много времени на выделение кучи ваших объектов, будет заметно быстрее их размещение в стеке.

2
ответ дан Mike Dunlavey 27 янв. '09 в 23:41
источник поделиться
class Foo {
public:
    Foo(int a) {

    }
}
int func() {
    int a1, a2;
    std::cin >> a1;
    std::cin >> a2;

    Foo f1(a1);
    __asm push a1;
    __asm lea ecx, [this];
    __asm call Foo::Foo(int);

    Foo* f2 = new Foo(a2);
    __asm push sizeof(Foo);
    __asm call operator new;//there a lot instruction here(depends on system)
    __asm push a2;
    __asm call Foo::Foo(int);

    delete f2;
}

Это было бы так в asm. Когда вы находитесь в func, f1 и указатель f2 были выделены в стеке (автоматическое хранилище). И, кстати, Foo f1(a1) не имеет эффектов для команд на указателе стека (esp), он был выделен, если func хочет получить член f1, то инструкция выглядит примерно так: lea ecx [ebp+f1], call Foo::SomeFunc(). Другая вещь, которую выделяет стек, может заставить кого-то подумать, что память похожа на FIFO, FIFO только что произошло, когда вы переходите к какой-либо функции, если вы находитесь в функции и выделяете что-то вроде int i = 0, никакого нажатия не произошло.

2
ответ дан bitnick 05 мая '15 в 5:42
источник поделиться

распределение стека выполняется намного быстрее.

2
ответ дан Master Yoda 17 авг. '10 в 23:56
источник поделиться

Ранее упоминалось, что распределение стека просто перемещает указатель стека, то есть одну инструкцию на большинстве архитектур. Сравните это с тем, что обычно происходит в случае выделения кучи.

Операционная система поддерживает части свободной памяти как связанный список с данными полезной нагрузки, состоящими из указателя на начальный адрес свободной части и размера свободной части. Чтобы выделить X-байты памяти, список ссылок перемещается, и каждая заметка посещается в последовательности, проверяя, является ли ее размер как минимум X. Когда найдена часть с размером P >= X, P разбивается на две части с размеры X и PX. Связанный список обновляется, и возвращается указатель на первую часть.

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

1
ответ дан Nikhil 02 марта '09 в 3:46
источник поделиться

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

Теперь для примера, где стек нельзя использовать:

Proc P
{
  pointer x;
  Proc S
  {
    pointer y;
    y = allocate_some_data();
    x = y;
  }
}

Если вы выберете некоторую память в процедуре S и поместите ее в стек, а затем выйдете из S, выделенные данные будут удалены из стека. Но переменная x в P также указывала на эти данные, поэтому x теперь указывает на какое-то место под указателем стека (предположим, что стек растет вниз) с неизвестным контентом. Содержимое может все еще присутствовать, если указатель стека просто перемещается вверх, не очищая данные под ним, но если вы начнете выделять новые данные в стеке, указатель x может фактически указывать на эти новые данные.

1
ответ дан Kent Munthe Caspersen 07 июня '13 в 11:24
источник поделиться

Как говорили другие, распределение стека обычно намного быстрее.

Однако, если ваши объекты дорого копировать, выделение в стеке может привести к огромной производительности, которую вы получите позже, когда используете объекты, если вы не будете осторожны.

Например, если вы выделяете что-то в стеке, а затем помещаете его в контейнер, было бы лучше выделить в куче и сохранить указатель в контейнере (например, с помощью std:: shared_ptr < > ), То же самое верно, если вы передаете или возвращаете объекты по значению и другие подобные сценарии.

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

1
ответ дан wjl 05 июня '11 в 18:46
источник поделиться

В общем случае распределение стека выполняется быстрее, чем распределение кучи, как упоминалось почти в каждом ответе выше. Выталкивание или выпадение стека O (1), тогда как выделение или освобождение от кучи может потребовать перехода предыдущих распределений. Однако вы не должны выделяться в жестких, интенсивных циклах, поэтому выбор обычно сводится к другим факторам.

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

Поскольку вы упомянули Metrowerks и PPC, я предполагаю, что вы имеете в виду Wii. В этом случае память имеет премиум-память и, используя метод распределения стека, гарантирует, что вы не тратите память на фрагменты. Конечно, для этого требуется гораздо больше внимания, чем "обычные" методы распределения кучи. Разумно оценить компромиссы для каждой ситуации.

1
ответ дан Dan Olson 02 марта '09 в 4:36
источник поделиться

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

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

Кетан

0
ответ дан Ketan 04 февр. '09 в 20:21
источник поделиться

Я бы хотел сказать, что на самом деле генерируется код GCC (я также помню VS). не имеет накладных расходов для размещения стека.

Произнесите следующую функцию:

  int f(int i)
  {
      if (i > 0)
      {   
          int array[1000];
      }   
  }

Ниже приводится генерация кода:

  __Z1fi:
  Leh_func_begin1:
      pushq   %rbp
  Ltmp0:
      movq    %rsp, %rbp
  Ltmp1:
      subq    $**3880**, %rsp <--- here we have the array allocated, even the if doesn't excited.
  Ltmp2:
      movl    %edi, -4(%rbp)
      movl    -8(%rbp), %eax
      addq    $3880, %rsp
      popq    %rbp
      ret 
  Leh_func_end1:

Итак, сколько у вас локальной переменной (даже внутри if или switch), только 3880 изменится на другое значение. Если у вас не было локальной переменной, эту инструкцию просто нужно выполнить. Поэтому выделение локальной переменной не имеет накладных расходов.

-1
ответ дан ZijingWu 24 июля '13 в 10:04
источник поделиться

Другие вопросы по меткам или Задайте вопрос