Подсистема памяти - это часть т.н. C Runtime или CRT, отвечающая за работу с памятью. Библиотека CRT служит посредником между прикладной программой и ядром операционной системы. Соответственно, её внешним интерфейсом является “стандартная библиотека С”, внутренний интерфейс специфичен для конкретной ОС.

А почему нельзя доверить выделение памяти ОС, у неё и аппаратная поддержка есть?Причин несколько:

  • с точки зрения программиста, работа с памятью заключена в системной библиотеке (msvcrt*.dll в Windows, libc.so.* под linux). Возможность подключать разные реализации аллокатора создаёт полезную гибкость в разработке.

  • делегирование работы с памятью ядру приведёт к тому, что каждый вызов malloc\free станет системным и потребует переключения в защищённый режим с соответствующими издержками (сотни тактов). Кроме того, за доступ к ядру начнут конкурировать все процессы в системе, ведь некоторые действия требуют атомарности на уровне системы, а некоторые просто дороги, ex: редактирование page table, чистка TLB при необходимости.

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

  • ОС не будет собирать для вас статистику и предугадывать поведение.

  • никакого (в том числе внутри-поточного (TLS)) кэширования

  • ...

Но мы забежали вперёд, давайте начнём с самого начала.

PDP-11, на котором, собственно появились и UNIX и C (строго говоря, началось всё с PDP-7, но это был еще не совсем C), 16-разрядная система, адресное пространство процесса ограничивалось 64 килобайтами. Это пространство разбито на 8 “сегментов”, которые отражались в физическую память.

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

Фиг.1 упрощенное представление о памяти, 
используемой программой.
Фиг.1 упрощенное представление о памяти, используемой программой.

Несколько секций фиксированного размера и две динамические - стек и куча/heap, растущие навстречу друг другу. Такая архитектура позволяет балансировать нагрузку (задействовать ровно столько сегментов, сколько необходимо) на адресное пространство для самых разных программ - и тех, которым требуется много динамической памяти и тех, что работают преимущественно со стеком.

Что касается именно подсистемы памяти в C, к ней давно и прочно прилипло название куча (heap). Название это по-видимому восходит к одноименной структуре данных. Структура эта была представлена в 1964 г. в составе алгоритма heapsort (алгоритм 232). К 1969-1970 гг, которые можно считать началом C, название куча/heap было вполне устоявшимся и распространённым. Хотя, явного использования алгоритма кучи в стандартном аллокаторе нет.

Для расширения доступного пространства данных в C были введены (ныне изрядно устаревшие) системные функции brk & sbrk, которые позволяли программно управлять границей heap-а.

Алгоритмы.

Изначальный алгоритм раздачи памяти в С использовал стратегию first-fit, т.е. “отдаём первый попавшийся пригодный блок”.

  • При таком скромном адресном пространстве во главе всего стоят компактность и простота. Аллокатор реализован как однонаправленный кольцевой список - самое простое и компактное, что только может быть.

  • Список отсортирован по адресам и включает в себя все блоки - и свободные и занятые.

  • Поскольку в интерфейсе функции free единственный аргумент - указатель, длина освобождаемого блока должна быть где-то рядом с самим блоком. Хранится она неявно - как разница адресов текущего блока и указателя на следующий, смежный блок. Длина указателя - 2 байта и расположен он непосредственно перед клиентскими данными, указатель на которые возвращает malloc.

  • Поскольку блоки выровнены как минимум по 2 байта, младший разряд указателя всегда нулевой и его используют для записи флага, свободен блок или нет.

  • Аллокатор содержит т.н. “блуждающий” (roving) указатель, который глядит на (или за) блок, с которым работали в прошлый раз - освобождали или выделяли.

  • при старте программы:

    • создаём сегмент данных некоторого стандартного размера;

    • собственно аллокатор содержит - размер, сколько свободно, блуждающий указатель смотрит на первый свободный блок;

    • создаём единственный свободный блок размером в сегмент данных.

  • при вызове malloc:

    • стартуем с блуждающего указателя;

    • бежим по списку, пока не найдём свободный блок подходящего размера, возможно, это блок заметно большего размера, откусим от него сколько надо с формированием двух блоков;

    • при движении по списку, сливаем идущие подряд неподходящие (слишком мелкие) свободные блоки;

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

    • возвращаем результат, сдвинутый на 2 байта вперед, не забыв пометить его как занятый и прописать указатель на следующий блок в эти самые два байта;

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

  • при вызове free:

    • помечаем этот блок как свободный.

    • блуждающий указатель теперь смотрит на него.

Обратим внимание, что free не пытается объединять соседние свободные блоки, этим занимается malloc. Что касается левого (от освобождаемого) блока, мы просто не знаем его размер в силу однонаправленности списка блоков, чтобы найти его начало, придётся просмотреть весь (ну, хорошо, его половину) список. Насчет правого тоже переживать не стоит, поскольку блуждающий указатель теперь смотрит на свеже-освобожденный блок, их по возможности сольёт следующий вызов malloc.

Причем здесь “heap”? Можно только догадываться. Не исключено, кстати, что название “куча/heap” прицепилось к аллокатору позже во время многочисленных экспериментов.

Такая незатейливая структура, как ни странно, оказалась довольно живучей. Д.Корн (David Korn) в 1985 на конференции USENIX говорит о ней как о всё еще распространённой. Проблем две - невысокая производительность и внешняя фрагментация. Быстро накапливаются мелкие свободные блоки, при этом при формально большом количестве свободной памяти сколь-нибудь большой кусок выделить невозможно - просто нет такой непрерывной свободной области. Слияние блоков во время malloc недостаточно эффективно.

Фиг.2 типичная фрагментированная карта памяти, Д.Кнут, И.П.,т.1, гл. 2.5
Фиг.2 типичная фрагментированная карта памяти, Д.Кнут, И.П.,т.1, гл. 2.5

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

Алгоритм двойных меток

Алгоритм двойных меток (метод граничного дескриптора) разработан Д.Кнутом в 1962 при работе над Burroughs 5000 (Д.Кнут, И.П.,т.1, гл. 2.6).

На границе между блоками находится т.н. дескриптор. В нём записана информация об обоих смежных блоках - и их длины и их статус (занято/свободно).

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

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

Алгоритм двойников / близнецов

Алгоритм двойников / близнецов (buddy allocator) открыт в 1963 Г.Марковицем (H.Markowitz), открыт заново и опубликован в 1965 (CACM 8, 1965, 623...625) К.Ноултоном (K.Knowlton).

  • все выделяемые блоки имеют размер в степень двойки, 4, 8, 16, 32, … байт;

  • размер изначального единого свободного блока (при старте программы) тоже равен степени 2;

  • свободные блоки одного размера объединены в двунаправленный список;

  • когда требуется выделить блок размера S:

    • находим минимальную степень 2 больше S;

    • проверяем наличие свободных страниц такого размера, отдаём если есть;

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

  • при освобождении блока:

    • находим размер блока;

    • проверяем, свободен ли второй блок (buddy) и если свободен, объединяем их и рекурсивно проверяем вышестоящий (уже объединённый) блок.

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

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

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

По мере “взросления” UNIX/C, развития аппаратных возможностей и клиентских требований возникла потребность в более производительных и устойчивых алгоритмах раздачи памяти. Было разработано и испытано огромное их количество, вот некоторые из упоминавшегося доклада Д.Корра:

  • first-fit с free-list, BSD 4.1 (1981). Развитие стандартного first-fit, отличается наличием дополнительного отсортированного по адресам однонаправленного циклического списка свободных блоков. Блуждающий указатель ходит только по свободным блокам. Слияние страниц происходит во время освобождения памяти.

  • недо-buddy аллокатор, BSD 4.2 (1983), написан Крисом Кингсли (Chris Kingsley) из CalTech.  Аллокатор состоит из набора двунаправленных списков - на каждую степень двойки размера выделяемых блоков. При выделении памяти размер всегда округляется до ближайшей степени двойки, но не делается попыток сливать блоки. Исключительно быстрый, но потребляющий память вдвое больше конкурентов, по-видимому из-за издержек на маленьких блоках.

  • first-fit с двунаправленным free-list, SYS V r2 (1984). Список свободных блоков двунаправленный и не сортированный. Двухступенчатая аллокация мелких блоков - выделение происходит пачками, внутри пачки - битовыми масками. При освобождении памяти происходит слияние с правыми соседями, при malloc - всё что найдётся. Для управления пачками мелких блоков хорошо подойдёт куча/heap - как раз востребована операция “дай пачку с минимальным адресом” за константное время.

  • first-fit со сбалансированным деревом свободных блоков, ключ - адрес. Структуры самого дерева расположены в пользовательском пространстве блока.

  • better-fit c декартовым деревом свободных блоков. Ключ дерева - адрес, размер (остроумно, не правда ли). Декартово дерево, кстати, ведёт себя и как дерево и как куча/heap.

  • best-fit со сбалансированным деревом свободных блоков, ключ - размер, поиск в дереве приводит к списку свободных блоков заданного размера

  • множественные списки свободных блоков, размеры блоков делятся на диапазоны в соответствии с числами Фибоначчи, внутри каждого диапазона алгоритм аналогичен first-fit с двунаправленным free-list. Если не удался поиск, берется следующий диапазон, если диапазоны кончились, sbrk.

    Здесь заложена вот какая идея. Элемент ряда Фибоначчи равен сумме двух своих предшественников - 1,2,3,5,8,13,21,34,55,89,144... Алгоритм аналогичен методу близнецов, но участок делится не пополам, а так, что длины двух под-интервалов являются предыдущими элементами ряда. Для этого, очевидно, изначальный размер выделенного ОС блока памяти должен входить в ряд Фибоначчи. Как и в случае метода двойников, имея указатель и его сдвиг от начала исходного блока можно вычислить размер освобождаемого блока, а также размеры его соседа (buddy) и слиться с ним по возможности.

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

  • ...

Освобождение памяти.

Следует различать освобождение памяти программой и аллокатором. Суть аллокатора в получении больших кусков памяти из ОС и "раздаче" всем желающим. Когда программа вызывает free, освобождаемый блок всего-лишь помечается как свободный. Если повезёт, этот блок будет объединён с одним из своих так же свободных соседей. Если очень повезёт, то с обоими.

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

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

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

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

Итого

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

  1. изрядно выросла производительность компьютеров, это позволяет использовать достаточно дорогостоящие алгоритмы.

  2. повсеместно распространённая многопоточность (multithreading), если раньше работа программы шла в единственном потоке (коим был процесс), сейчас их десятки, иногда сотни.

  3. на 4-5 порядков выросли объемы располагаемой памяти,
    простым циклом её не обойти.

  4. виртуальная память стала сначала 32, а затем 64-разрядной. Можно создать сегмент(ы) любого (в пределах разумного) размера, для этого даже не надо сдвигать границу существующего через sbrk.

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

  6. Неоднородная память (NUMA). В такого рода системах процессоры объединены в группы (узлы, ноды), в каждой группе есть своя память, обращения к которой быстры, как в обычном компьютере. Но обращение к памяти из чужой ноды в разы дороже. В результате ОС старается запускать потоки (threads) как минимум в той же ноде, что и перед вытеснением. Соответственно, и память лучше выделять в той же ноде.

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

Итак, что мы увидим, если заглянем “под капот” более  менее современным malloc-у от glibc и/или mi-malloc-у от Microsoft ?

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

  • Расщепление (sharding). Поскольку потоков (threads) теперь несколько, работа с кучей требует синхронизации. Когда потоков много, возникает узкое место, где они “толкаются локтями”. В современных аллокаторах куч несколько и разные потоки (по возможности) работают с разными кучами.

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

  • Крупные блоки (> 128K glibc) аллокаторы предпочитают брать не из кучи, а выпрашивать отдельно  у операционной системы (mmap, MapViewOfFile). Это помогает бороться с фрагментацией, ведь если такой блок выделить из кучи, то при освобождении он очень быстро будет разобран на маленькие блоки и когда еще раз потребуется блок такого размера, его придётся выделять заново. В результате занятое процессом адресное пространство растёт, а его утилизация (КПД) снижается. Это выгодно еще и потому, что некоторые архитектуры допускают физические страницы разного размера, например, x86-64: 4 килобайта и 2 мегабайта (+1 гигабайт). Может оказаться полезным выделять большие объемы большими страницами.

  • Конструкция собственно кучи принципиально не отличается от описанных выше

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

    • свободные блоки “популярных” размеров имеют выделенные списки

    • списки свободных блоков для других размеров имеют древовидную структуру

Фиг.3 общее устройство кучи в mi-malloc
Фиг.3 общее устройство кучи в mi-malloc

Чем же приходится платить за все эти нововведения?

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

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

Вы освободили всю память, которую ранее выделили, но она не вернулась в ОС? Придётся подождать, пока CRT адаптируется к новой ситуации.

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

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

PS Титульная картинка взята отсюда.

PPS Спасибо @Dmitria за помощь в подготовке, не поленитесь отсыпать ему кармы.

Комментарии (28)


  1. CodeRush
    10.01.2023 08:11
    +5

    Добавлю еще, что современные аллокаторы пришлось научить в дополнению ко всему остальному еще и предотвращению эксплуатации типичных для небезопасных ЯП проблем вроде double free, use-after-free и т.п., вот прекрасная статья Саара Амара о нынешнем состоянии memory safety.


    1. Kelbon
      10.01.2023 08:19
      +3

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


      1. RedPandaHere
        10.01.2023 15:21

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


        1. Kelbon
          10.01.2023 15:50
          +2

          и что же вы будете делать с ошибкой double free? Как собираетесь её обрабатывать? И кто так и где делает?


        1. staticmain
          10.01.2023 16:16
          +7

          работа приложения не прервется.

          Зачем продолжать работу приложения, в котором кто-то написал free(p); free(p); ?


          1. vkni
            10.01.2023 18:59
            -3

            Потому, что им может пользоваться человек, который вообще ни сном ни духом об этом. Вот, представьте, что вы — секретарша, работающая в MS Word, который внутри себя сделал double free...


            1. staticmain
              10.01.2023 23:45
              +2

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


              1. vkni
                10.01.2023 23:51

                Ну я ниже привёл вариант, когда ничего не произойдёт. В общем, тут надо как-то очень аккуратно соображать, я бы предпочёл, чтобы программе был выслан сигнал аля SIGSEGV, после которого она постаралась бы сохранить всё, что можно, а потом перезапустилась бы и восстановила состояние.

                Короче, убивание по любому чиху в некоторых случаях — "да, да, да" (особенно при отладке), а в некоторых и "нет, не надо". Вот представьте себе вариант какого-нибудь прохождения глючной игры: там уж лучше бы, чтобы она хоть иногда пропустила на след. уровень, чем 146% вылетала. Ситуации бывают разные.


          1. vkni
            10.01.2023 21:23

            То есть, у меня есть доводы и за, и против продолжения. С одной стороны, программа в некорректном состоянии. С другой стороны, может это состояние и не настолько уж некорректное, чтобы не дать пользователю сохранить данные. В конце-концов, если в коде действительно написано free(p) подряд 144 раза, то эта ошибка легко чинится без последствий, и смысла вырубать всё, выбрасывая на помойку пользовательские данные, нет.


          1. Rio
            11.01.2023 09:58
            -2

            Это приложение может оказаться прошивкой, управляющей, например, самолётом. Вы уверены, что предпочли бы в этом случае именно завершение приложения? Да, конечно, в таких случаях требования к коду обычно жёстче, а динамическую память часто и вовсе использовать запрещено. Но по факту она используется, пусть даже и не в самом прикладном коде, но в библиотеках, которые им используются, и в операционной системе. А ошибки в них тоже есть.


            1. staticmain
              11.01.2023 10:29
              +3

               Вы уверены, что предпочли бы в этом случае именно завершение приложения? 

              Да, потому что для таких сценариев есть программный и аппаратный watchdog. А программа, в которой что-то дважды очистилось будет выдавать плохие данные - датчики будут врать, пилоты, ориентируясь на них, совершат действия, которые приведут к катастрофе.


              1. Rio
                11.01.2023 12:28

                Из моей практики. Watchdog, конечно, есть. И вычислители дублируются физически. Но время перезагрузки программы по нормативу - 30 сек. Мы укладываемся, но запас остаётся не большой. Если вычислитель не будет функционировать даже 20 секунд, это, вообще говоря, очень плохо, слишком много всего может случиться. Поэтому, если можно не падать, предпочитаем не падать. Естественно, все ошибки и потенциальные неисправности при этом логгируются, куда нужно отправляются, и ведётся сбор статистики. При достижении определённых условий может приниматься решение, что данному вычислителю доверять нельзя, и управление перейдёт резервному. Но это будет именно процедура, а не просто перезагрузка вычислителя в рандомный момент времени.

                программа, в которой что-то дважды очистилось будет выдавать плохие данные

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


                1. staticmain
                  11.01.2023 12:52
                  +3

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

                  "Ну вы знаете, наш датчик высоты иногда делает double free, но, скорее всего, ничего плохого не произойдет, я гарантирую это" (с) инженер boing max

                  Если вычислитель не будет функционировать даже 20 секунд, это, вообще говоря, очень плохо, слишком много всего может случиться. Поэтому, если можно не падать, предпочитаем не падать.

                  Если у вас такие жесткие требования, то на кой черт вы вообще используете кучу? MISRA прямо запрещает динамическую аллокацию именно потому, что это unsafe операция и

                  • память может не выделиться

                  • память может выделиться, но не по настоящему (привет optimistic linux)

                  • память может не вернуться обратно в систему

                  • память может фрагментироваться (см. п.1)

                  • double free

                  • use after free

                  • еще с десяток пунктов, почему за динамику на встройках и критикалах надо бить раскаленным ломом

                  Если у вас неизвестен размер данных и позарез нужна куча - то делается свой аллокатор, который потребляет заранее выделенный где-нибудь в BSS буфер за пределы которого он гарантированно не выйдет, а выделение самого буфера гарантировано загрузчиком железа (в случае bare metal) или программы-загрузчика (в случае какого-нибудь rtos)

                   куда нужно отправляются, и ведётся сбор статистики.

                  Если у вас образовался потенциальный намек на то, что у вас double free, use after free или банальное деление на ноль - это сигнал, что программой дальше пользоваться нельзя. Это значит, что проявилось событие, которое не было покрыто при тестировании и here be dragons - неизведанная земля, воркфлоу которой может и отправить неверные координаты в систему наводки артиллерии и вырубить всю электронику на самолете (см. полет ниже уровня моря) и облучить не той дозой радиации.


                  1. Rio
                    11.01.2023 13:45
                    +3

                    Если у вас такие жесткие требования, то на кой черт вы вообще используете кучу?

                    Мы не используем кучу в своём прикладном коде.

                    Если у вас неизвестен размер данных и позарез нужна куча - то делается свой аллокатор

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

                    Если у вас образовался потенциальный намек на то, что у вас double free, use after free или банальное деление на ноль - это сигнал, что программой дальше пользоваться нельзя.

                    Всё так. Такое отлавливается при тестировании, если косяк именно в нашем коде. Но он не всегда именно там.

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

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

                    1. Комплекс исправно работал на самолёте, но в какой-то момент появилась информация, что вычислители стали перезагружаться в процессе работы. Логи показали, что в какой-то момент ОСь не может выделить ресурс (создать семафор).

                      Оказалось, перед этим пилоты обновили полётную базу (xml- файл). И в этот раз база стала сильно больше размером. Соответственно, парсер XML (это был MiniXML) выделил в этот раз сильно больше памяти чем ранее. ПО кучу не использует, но остатка свободного места не хватило оси под свои объекты, в реалтайме.

                      Решение - сначала доработали MiniXML, чтобы аллоцировал из отдельного пула, не трогая системный. А потом написали свой парсер, который динамическую память не использует вовсе (а код оказался на порядок проще и быстрее, что очень кстати, т.к. MiniXML был крайне тормозной и сильно увеличивал время загрузки).

                    2. Аналогично, стала кончаться куча. Поймать утечку долго не удавалось. Оказалось, косяк был в самой ОС (в её штатной библиотеке поддержки SPE для PowerPC). Потокам, которые работают с floating-point, вешаются хуки на события переключения потоков, которые должны записывать и восстанавливать состояние FP-регистров. Для этого, при создании потока, система выделяет структуру. А вот на завершение потока хук просто не вешался, и, в итоге, структура оставалась не освобождённой. Оказалось, что временные сервисные потоки (создаваемые самой осью) забирали себе весь системный пул.

                      До сих пор не понимаю, как производители ОС такой косяк могли пропустить. Могу только предполагать, что аппаратную плавающую точку на SPE прочие заказчики не использовали, довольствуясь soft-fp, и мы просто оказались первыми.


  1. homm
    10.01.2023 13:44

    Для расширения доступного пространства данных в C были введены (ныне изрядно устаревшие) системные функции brk & sbrk,

    Не понятно, в каком смысле вы их считаете их устаревшими. В GNU libc это всё ещё самый распространенный способ выделения памяти.


    1. zzeng Автор
      10.01.2023 13:53
      +7

      sbrk and brk were considered legacy even by 1997 standards (Single UNIX Specification v2 or POSIX.1-1998).They were removed in POSIX.1-2001 (отсюда)


  1. vkni
    10.01.2023 21:19

    Спасибо, очень познавательная штука. У меня как-то не доходили руки до того, чтобы наконец-то разобраться и понять, где же в malloc'е эта самая куча. То есть, было примерно понятно, как его его в простейшем случае реализовать, но хотелось проверить свои мысли.


  1. insecto
    11.01.2023 05:08

    По-моему по аллокаторам можно написать цикл книг, и ещё останется. Всякие jemalloc, tcmalloc, slab allocator, потом bohem, потом дело доходит до джавы и начинается второй том...


  1. Andruh
    11.01.2023 10:45
    +1

    Получается, что алгоритм близнецов не обладает никакими недостатками кроме большего потребления памяти (почему-то мне кажется, что где-то в sqrt(2) больше)? И вся возня с другими - это попытки убрать этот оверхед?

    Но ведь проще потратить немного денег на увеличение памяти на сервере, чем полжизни на борьбу с аллокаторами?

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

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


    1. zzeng Автор
      11.01.2023 11:10

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


      1. Andruh
        11.01.2023 11:41

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


        1. zzeng Автор
          11.01.2023 12:19

          а как насчет защиты от недобросовестного пользователя?
          double free того же ?


          1. Andruh
            11.01.2023 17:52
            +2

            От этого нет защиты, как и от free от невыделенного ранее. Но я на C++ пишу, там сложно два раза освободить, а внешние библиотеки обычно более-менее протестированы сообществом. Ни разу из-за этого проблем не было.


        1. zzeng Автор
          11.01.2023 13:41

          Вот, кстати, поэтому недо-buddy аллокатор в BSD 4.2 и работал так быстро, что не пытался слить соседей во время free. В самом деле, как узнать без дополнительной памяти, что твой сосед тоже свободен? Пробежать по списку?


          1. Andruh
            11.01.2023 17:50
            +1

            Я в начало блока записываю степень двойки, обозначающую его размер (когда он выделен). Когда свободен - можно 0 писать, например. А уже дальше интрузивный указатель для списка. Вобщем, можно как-то выкрутиться, тем более 1 бит нужен всего для обозначения свободности - его и в указатель можно вставить, в младший бит.


            1. zzeng Автор
              11.01.2023 18:34

              Т.е. дополнительная память в виде заголовка блока.
              То же самое но чуть компактнее можно сделать масками.
              Плюс бонус - защита от double free/невыделенная память.


              1. Andruh
                11.01.2023 21:29
                +1

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

                У меня требования к аллокатору такие:

                • Скорость (достигается за счёт: универсальности - нет лишних сравнений, константной сложности операций, lifo, локальности - служебная информация в начале блока, там же где пользователь данные размещает)

                • Универсальность - один аллокатор для любого размера блока (у меня до 2^28, выше - выделяется уже внешним аллокатором)

                • Память: оверхед до 2x не страшен, очень важно чтобы не было постоянной внешней фрагментации (которая выглядит как утечка и требует рестарта сервера периодически).

                • Защита от дурака не нужна

                Я сейчас осознал, что счастливый человек, что могу позволить такие требования себе. Аллокаторы (и сборщики мусора) - это неисчерпаемая головная боль. Особенно, когда не можешь позволить себе просто удвоить требования к памяти или отадаёшь аллокатор любителям, которые могут двойным free всё сломать.


            1. zzeng Автор
              11.01.2023 18:42

              На всякий случай, я уже как-то выкладывал
              исходники BuddyAllocator под BSD лицензией