Windows выделение памяти процессу

Организация памяти процесса


Управление памятью – центральный аспект в работе операционных систем. Он оказывает основополагающее влияние на сферу программирования и системного администрирования. В нескольких последующих постах я коснусь вопросов, связанных с работой памяти. Упор будет сделан на практические аспекты, однако и детали внутреннего устройства игнорировать не будем. Рассматриваемые концепции являются достаточно общими, но проиллюстрированы в основном на примере Linux и Windows, выполняющихся на x86-32 компьютере. Первый пост описывает организацию памяти пользовательских процессов.

Каждый процесс в многозадачной ОС выполняется в собственной “песочнице”. Эта песочница представляет собой виртуальное адресное пространство, которое в 32-битном защищенном режиме всегда имеет размер равный 4 гигабайтам. Соответствие между виртуальным пространством и физической памятью описывается с помощью таблицы страниц (page table). Ядро создает и заполняет таблицы, а процессор обращается к ним при необходимости осуществить трансляцию адреса. Каждый процесс работает со своим набором таблиц. Есть один важный момент — концепция виртуальной адресации распространяется на все выполняемое ПО, включая и само ядро. По этой причине для него резервируется часть виртуального адресного пространства (т.н. kernel space).

Это конечно не значит, что ядро занимает все это пространство, просто данный диапазон адресов может быть использован для мэппирования любой части физического адресного пространства по выбору ядра. Страницы памяти, соответствующие kernel space, помечены в таблицах страниц как доступные исключительно для привилегированного кода (кольцо 2 или более привилегированное). При попытке обращения к этим страницам из user mode кода генерируется page fault. В случае с Linux, kernel space всегда присутствует в памяти процесса, и разные процессы мэппируют kernel space в одну и ту же область физической памяти. Таким образом, код и данные ядра всегда доступны при необходимости обработать прерывание или системный вызов. В противоположность, оперативная память, замэппированная в user mode space, меняется при каждом переключении контекста.

Синим цветом на рисунке отмечены области виртуального адресного пространства, которым в соответствие поставлены участки физической памяти; белым цветом — еще не использованные области. Как видно, Firefox использовал большую часть своего виртуального адресного пространства. Все мы знаем о легендарной прожорливости этой программы в отношении оперативной памяти. Синие полосы на рисунке — это сегменты памяти программы, такие как куча (heap), стек и так далее. Обратите внимание, что в данном случае под сегментами мы подразумеваем просто непрерывные адресные диапазоны. Это не те сегменты, о которых мы говорим при описании сегментации в Intel процессорах. Так или иначе, вот стандартная схема организации памяти процесса в Linux:

Давным давно, когда компьютерная техника находилась в совсем еще младенческом возрасте, начальные виртуальные адреса сегментов были совершенно одинаковыми почти для всех процессов, выполняемых машиной. Из-за этого значительно упрощалось удаленное эксплуатирование уязвимостей. Эксплойту часто необходимо обращаться к памяти по абсолютным адресам, например по некоторому адресу в стеке, по адресу библиотечной функции, и тому подобное. Хакер, рассчитывающий осуществить удаленную атаку, должен выбирать адреса для обращения в слепую в расчете на то, что размещение сегментов программы в памяти на разных машинах будет идентичным. И когда оно действительно идентичное, случается, что людей хакают. По этой причине, приобрел популярность механизм рандомизации расположения сегментов в адресном пространстве процесса. Linux рандомизирует расположение стека, сегмента для memory mapping, и кучи – их стартовый адрес вычисляется путем добавления смещения. К сожалению, 32-битное пространство не очень-то большое, и эффективность рандомизации в известной степени нивелируется.

В верхней части user mode space расположен стековый сегмент. Большинство языков программирования используют его для хранения локальных переменных и аргументов, переданных в функцию. Вызов функции или метода приводит к помещению в стек т.н. стекового фрейма. Когда функция возвращает управление, стековый фрейм уничтожается. Стек устроен достаточно просто — данные обрабатываются в соответствии с принципом «последним пришёл — первым обслужен» (LIFO). По этой причине, для отслеживания содержания стека не нужно сложных управляющих структур – достаточно всего лишь указателя на верхушку стека. Добавление данных в стек и их удаление – быстрая и четко определенная операция. Более того, многократное использование одних и тех же областей стекового сегмента приводит к тому, что они, как правило, находятся в кеше процессора, что еще более ускоряет доступ. Каждый тред в рамках процесса работает с собственным стеком.

Возможна ситуация, когда пространство, отведенное под стековый сегмент, не может вместить в себя добавляемые данные. В результате, будет сгенерирован page fault, который в Linux обрабатывается функцией expand_stack(). Она, в свою очередь, вызовет другую функцию — acct_stack_growth(), которая отвечает за проверку возможности увеличить стековый сегмент. Если размер стекового сегмента меньше значения константы RLIMIT_STACK (обычно 8 МБ), то он наращивается, и программа продолжает выполняться как ни в чем не бывало. Это стандартный механизм, посредством которого размер стекового сегмента увеличивается в соответствии с потребностями. Однако, если достигнут максимально разрещённый размер стекового сегмента, то происходит переполнение стека (stack overflow), и программе посылается сигнал Segmentation Fault. Стековый сегмент может увеличиваться при необходимости, но никогда не уменьшается, даже если сама стековая структура, содержащаяся в нем, становиться меньше. Подобно федеральному бюджету, стековый сегмент может только расти.

Динамическое наращивание стека – единственная ситуация, когда обращение к «немэппированной» области памяти, может быть расценено как валидная операция. Любое другое обращение приводит к генерации page fault, за которым следует Segmentation Fault. Некоторые используемые области помечены как read-only, и обращение к ним также приводит к Segmentation Fault.

Под стеком располагается сегмент для memory mapping. Ядро использует этот сегмент для мэппирования (отображания в память) содержимого файлов. Любое приложение может воспользоваться данным функционалом посредством системного вызовома mmap() (ссылка на описание реализации вызова mmap) или CreateFileMapping() / MapViewOfFile() в Windows. Отображение файлов в память – удобный и высокопроизводительный метод файлового ввода / вывода, и он используется, например, для загрузки динамических библиотек. Существует возможность осуществить анонимное отображение в память (anonymous memory mapping), в результате чего получим область, в которую не отображен никакой файл, и которая вместо этого используется для размещения разного рода данных, с которыми работает программа. Если в Linux запросить выделение большого блока памяти с помощью malloc(), то вместо того, чтобы выделить память в куче, стандартная библиотека C задействует механизм анонимного отображения. Слово «большой», в данном случае, означает величину в байтах большую, чем значение константы MMAP_THRESHOLD. По умолчанию, это величина равна 128 кБ, и может контролироваться через вызов mallopt().

Кстати о куче. Она идет следующей в нашем описании адресного пространства процесса. Подобно стеку, куча используется для выделения памяти во время выполнения программы. В отличие от стека, память, выделенная в куче, сохранится после того, как функция, вызвавшая выделение этой памяти, завершится. Большинство языков предоставляют средства управления памятью в куче. Таким образом, ядро и среда выполнения языка совместно осуществляют динамическое выделение дополнительной памяти. В языке C, интерфейсом для работы с кучей является семейство функций malloc(), в то время как в языках с поддержкой garbage collection, вроде C#, основной интерфейс – это оператор new.

Читайте также:  Windows agent для java

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

Наконец, мы добрались до сегментов, расположенных в нижней части адресного пространства процесса: BSS, сегмент данных (data segment) и сегмент кода (text segment). BSS и data сегмент хранят данные, соответствующий static переменным в исходном коде на C. Разница в том, что в BSS хранятся данные, соответствующие неинициализированным переменным, чьи значения явно не указаны в исходном коде (в действительности, там хранятся объекты, при создании которых в декларации переменной либо явно указано нулевое значение, либо значение изначально не указано, и в линкуемых файлах нет таких же common символов, с ненулевым значением. – прим. перевод.). Для сегмента BSS используется анонимное отображение в память, т.е. никакой файл в этот сегмент не мэппируется. Если в исходном файле на C использовать int cntActiveUsers, то место под соответствующий объект будет выделено в BSS.

В отличии от BSS, data cегмент хранит объекты, которым в исходном коде соответствуют декларации static переменных, инициализированных ненулевым значением. Этот сегмент памяти не является анонимным — в него мэппируется часть образа программы. Таким образом, если мы используем static int cntWorkerBees = 10, то место под соответствующий объект будет выделено в data сегменте, и оно будет хранить значение 10. Хотя в data сегмент отображается файл, это т.н. «приватный мэппинг» (private memory mapping). Это значит, что изменения данных в этом сегменте не повлияют на содержание соответствующего файла. Так и должно быть, иначе присвоения значений глобальным переменным привели бы к изменению содержания файла, хранящегося на диске. В данном случае это совсем не нужно!

С указателями все немножко посложнее. В примере из наших диаграмм, содержимое объекта, соответствующего переменной gonzo – это 4-байтовый адрес – размещается в data сегменте. А вот строка, на которую ссылается указатель, не попадет в data сегмент. Строка будет находиться в сегменте кода, который доступен только на чтение и хранит весь Ваш код и такие мелочи, как, например, строковые литералы (в действительности, строка хранится в секции .rodata, которая вместе с другими секциями, содержащими исполняемый код, рассматривается как сегмент, который загружается в память с правами на выполнение кода / чтения данных – прим. перевод.). В сегмент кода также мэппируется часть исполняемого файла. Если Ваша программа попытается осуществить запись в text сегмент, то заработает Segmentation Fault. Это позволяет бороться с «бажными» указателями, хотя самый лучший способ борьбы с ними – это вообще не использовать C. Ниже приведена диаграмма, изображающая сегменты и переменные из наших примеров:

Мы можем посмотреть, как используются области памяти процесса, прочитав содержимое файла /proc/pid_of_process/maps. Обратите внимание, что содержимое самого сегмента может состоять из различных областей. Например, каждой мэппируемой в memory mapping сегмент динамической библиотеке отводится своя область, и в ней можно выделить области для BSS и data сегментов библиотеки. В следующем посте поясним, что конкретно подразумевается под словом “область”. Учтите, что иногда люди говорят “data сегмент”, подразумевая под этим data + BSS + heap.

Можно использовать утилиты nm и objdump для просмотра содержимого бинарных исполняемых образов: символов, их адресов, сегментов и т.д. Наконец, то, что описано в этом посте – это так называемая “гибкая” организация памяти процесса (flexible memory layout), которая вот уже несколько лет используется в Linux по умолчанию. Данная схема предполагает, что у нас определено значение константы RLIMIT_STACK. Когда это не так, Linux использует т.н. классическую организации, которая изображена на рисунке:

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

Отладка вашей ОС: урок по выделению памяти

Всё началось, как и многие другие расследования, с баг-репорта.

Название отчёта было довольно простым: «При HTTP-подключении iter_content медленно работает с чанками большого размера». Подобное название немедленно включило у меня в голове сирену по двум причинам. Во-первых, довольно сложно определить, что здесь означает «медленно»? Насколько медленно? Насколько велик «большой размер»? Во-вторых, если бы описанное проявлялось действительно серьёзно, то мы бы об этом уже знали. Метод iter_content используется давно, и если бы он существенно притормаживал в распространённом пользовательском режиме, то мимо нас такая информация не прошла бы.

Я быстро просмотрел отчёт. Автор предоставил мало подробностей, но написал вот что: «Это приводит к 100%-й загрузке процессора и снижает пропускную способность сети ниже 1 Мб/с». Я ухватился за эту фразу, потому что такого быть не может. Простое скачивание с минимальной обработкой не бывает настолько медленным!

Однако все сообщения о багах до опровержения заслуживают расследования. Пообщавшись с автором отчёта, удалось восстановить сценарий проявления бага: если использовать Requests с PyOpenSSL и запустить следующий код, то процессор будет полностью загружен, а пропускная способность сети упадёт до минимума:

Это просто замечательный воспроизводимый сценарий, потому что он совершенно чётко указывает на стек Requests. Здесь не выполняется пользовательский (user-supplied) код: это часть библиотеки Requests или одной из его зависимостей; нет вероятности, что это пользователь написал дурацкий низкопроизводительный код. Настоящая фантастика. Ещё более фантастично использование публичного URL. Я мог выполнить сценарий! И, сделав это, я столкнулся с багом. При каждом выполнении.

Здесь была ещё одна прелестная подробность:

При 10 Мб не отмечается какого-то роста нагрузки на процессор и влияния на пропускную способность. При 1 Гб процессор загружается на 100 %, как и при 100 Мб, но пропускная способность падает ниже 100 Кб/с, в отличие от 1 Мб/с при 100 Мб.

Это очень интересный момент: он намекает на то, что литеральное значение (literal value) размера чанка влияет на рабочую нагрузку. Если учесть, что это происходит только при использовании PyOpenSSL, а также что большую часть времени стек обслуживает вышеприведённый код, проблема становится ясна:

Читайте также:  Sketch для windows открыть

Расследование показало, что стандартное поведение CFFI относительно FFI.new заключается в возвращении обнулённой памяти. Это означало линейное увеличение избыточности в зависимости от размера выделяемой памяти: более крупные объёмы приходилось обнулять дольше. Следовательно, плохое поведение связано с выделением больших объёмов. Мы воспользовались возможностью CFFI отключить обнуление этих буферов, и проблема ушла 1 . Так она решена, верно?

Настоящий баг

Шутки в сторону: это действительно позволило решить проблему. Но несколько дней спустя мне задали очень глубокомысленный вопрос: зачем вообще память активно обнулялась? Чтобы понять суть вопроса, давайте отвлечёмся и поговорим о выделении памяти в POSIX-системах.

malloc и calloc и vmalloc, ох ты ж!

Многим программистам известен стандартный способ запроса памяти у оперативной системы. В этом механизме задействована функция malloc из стандартной библиотеки С (можете почитать документацию о ней для вашей ОС, введя в мануале в поиск man 3 malloc ). Эта функция берёт один аргумент — количество байтов памяти, которое нужно выделить. Стандартная библиотека С выделяет память по одной из нескольких разных методик, но так или иначе она возвращает указатель на участок памяти, по крайней мере такой же большой, как и запрошенный вами объём.

По умолчанию malloc возвращает неинициализированную память. То есть стандартная библиотека С выделяет какой-то объём и немедленно передаёт его вашей программе, без изменения данных, которые уже там находятся. То есть при использовании malloc вашей программе будет возвращаться буфер, в который она уже записывала данные. Это распространённая причина багов в не безопасных по памяти (memory-unsafe) языках, например С. В целом читать из неинициализированной памяти очень рискованно.

Однако у malloc есть друг, задокументированный на той же странице мануала: calloc . Его главное отличие заключается в том, что он принимает два аргумента — счётчик и размер. Используя malloc , вы просите стандартную библиотеку С: «Пожалуйста, выдели мне не менее n байтов». А при вызове calloc вы просите её: «Пожалуйста, выдели достаточно памяти для n объектов размером m байтов». Очевидно, что первичная идея вызова calloc заключалась в безопасном выделении кучи для массивов объектов 2 .

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

Выделенная память заполнена нулевыми байтами.

Это идёт рука об руку с предназначением calloc . К примеру, если вы размещаете в памяти массив значений, то зачастую будет очень полезно, чтобы он изначально имел состояние по умолчанию. В некоторых современных безопасных по памяти языках это уже стало стандартным поведением при создании массивов и структур. Скажем, когда в Go инициализируешь структуру, то все её члены по умолчанию приведены к своим так называемым «нулевым» значениям, эквивалентным «таким значениям, которые были бы, если бы всё сбросили в ноль». Это можно считать обещанием, что все Go-структуры размещаются в памяти с помощью calloc 3 .

Такое поведение означает, что malloc возвращает неинициализированную память, а calloc — инициализированную. А раз так, да ещё и в свете вышеупомянутых строгих обещаний, то операционная система может оптимизировать выделяемую память. И действительно, многие современные ОС так делают.

Воспользуемся calloc

Конечно, простейший способ внедрить calloc — это написать что-то вроде:

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

Для выделения больших объёмов в ОС используется ещё один трюк, имеющий отношение к виртуальной памяти.

Виртуальная память

Здесь мы не будем разбирать всю структуру и работу виртуальной памяти, но я очень рекомендую об этом почитать (тема крайне интересная!). Вкратце: виртуальная память — это ложь ядра ОС процессам о доступной памяти. У каждого выполняемого процесса есть своё представление о памяти, принадлежащей ему и только ему. Это представление косвенно «отображается» (mapped) на физическую память.

В результате ОС может прокручивать всевозможные хитрые трюки. Чаще всего она выдаёт за память специальные файлы, отображаемые в неё (memory-mapped file). Они используются для выгрузки содержимого памяти на диск, а также для отображения в них памяти. В последнем случае программа просит ОС: «Пожалуйста, выдели мне n байтов памяти и сохрани их в файле на диске, так, чтобы, когда я буду писать в память, все записи выполнялись бы в этот файл, а когда считываю из памяти, то данные считывались бы из него же».

На уровне ядра это работает так: когда процесс пытается считать из такой памяти, процессор уведомляет, что память не существует, ставит процесс на паузу и бросает «ошибку страницы» (page fault). Ядро помещает в память актуальные данные, чтобы приложение могло их считать. Затем процесс снимается с паузы и находит в соответствующем месте волшебным образом появившиеся данные. С точки зрения процесса всё произошло мгновенно, без паузы.

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

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

Чтобы избежать очень больших затрат при запуске приложений, операционные системы начали врать приложениям. В большинстве ОС, если вы попытаетесь выделить более 128 Кб в рамках одного вызова, стандартная библиотека С напрямую попросит у ОС совершенно новые страницы виртуальной памяти, которые покроют запрошенные объёмы. Но главное: такое выделение почти ничего не стоит. Ведь на самом деле ОС ничего не делает: она лишь перенастраивает схему виртуальной памяти. Так что при использовании malloc расходы получаются мизерными.

Память не была «приписана» к процессу, и, как только приложение пытается использовать её на самом деле, возникает ошибка страницы памяти. Здесь вмешивается ОС, находит нужную страницу и помещает её туда, куда обращается процесс, так же, как и в случае с ошибкой памяти и файлом отображаемой памяти. Только разница в том, что виртуальная память обеспечивается памятью физической, а не файлом.

Читайте также:  Net user добавить администратора windows 10

В результате, если вызвать malloc(1024 * 1024 * 1024) для выделения 1 Гб памяти, это произойдёт почти мгновенно, потому что на самом деле процессу память не выделяется. Зато программы могут моментально «выделять» для себя многие гигабайты, хотя в реальности это происходило бы далеко не быстро.

Но ещё удивительнее то, что такая же оптимизация доступна и для calloc . ОС может отображать совершенно новую страницу на так называемую «нулевую страницу»: это страница памяти, доступная только для чтения, причём считываются из неё одни лишь нули. Изначально такое отображение представляет собой копирование при записи (copy-on-write): когда ваш процесс пытается записать данные в эту новую память — вмешивается ядро, копирует все нули в новую страницу, а затем разрешает вам выполнить запись.

Благодаря такому ухищрению со стороны ОС calloc может при выделении больших объёмов делать то же самое, что и malloc , запрашивая новые страницы виртуальной памяти. Это будет происходить бесплатно до тех пор, пока память не начнёт использоваться. Подобная оптимизация означает, что стоимость calloc(1024 * 1024 * 1024, 1) будет равна вызову malloc для такого же объёма памяти, несмотря на то что calloc ещё и обещает заполнять память нулями. Умно!

Вернёмся к нашему багу

Итак: если CFFI использовал calloc , то почему память обнулялась?

Для начала: calloc использовался не всегда. Но я подозревал, что в данном случае могу воспроизвести замедление напрямую с помощью calloc , поэтому снова накидал программу:

Очень простая программа на C, выделяющая и освобождающая 100 Мб посредством вызова calloc десять тысяч раз. Затем выполняется выход. Далее — два варианта 5 :

  1. calloc может использовать вышеописанную хитрость с виртуальной памятью. В таком случае программа должна работать быстро: выделяемая память на самом деле не используется, не разбивается на страницы, а страницы не становятся «грязными» (dirty). ОС врёт нам насчёт выделения, а мы не ловим её за руку, так что всё работает прекрасно.
  2. calloc может привлечь malloc и вручную обнулить память с помощью memset . Это должно делаться очень-очень медленно: в сумме нам надо обнулить терабайт памяти (десять тысяч циклов по 100 Мб), что очень непросто.

Это сильно превышает стандартный порог ОС для использования первого варианта, так что можно ожидать именно такого поведения. Действительно, Linux так и поступает: если скомпилировать код с помощью GCC и запустить, то он выполнится крайне быстро, сгенерирует несколько ошибок страницы и почти не приведёт к нагрузке на память. Но если ту же программу запустить на MacOS, то она будет выполняться крайне долго: у меня это заняло почти восемь минут.

Более того, если увеличить ALLOCATION_SIZE (например, 1000 * 1024 * 1024 ), то на MacOS эта программа станет работать почти мгновенно! Что за чертовщина?

Что тут вообще происходит?

Углублённый разбор

В MacOS есть утилита sample (см. man 1 sample ), которая может многое рассказать о выполняемом процессе, регистрируя его состояние. Для нашего кода sample выдаёт такое:

Здесь мы ясно видим, что куча времени тратится на метод _platform_bzero$VARIANT$Haswell . Он используется для обнуления буферов. То есть MacOS их обнуляет. Почему?

Спустя какое-то время после релиза Apple делает открытым большую часть основного кода своей ОС. И можно увидеть, что эта программа тратит немало времени в libsystem_malloc . Я отправился на opensource.apple.com, скачал архив libmalloc-116 с нужным мне исходным кодом и принялся исследовать.

Похоже, вся магия происходит в large_malloc. Эта ветка нужна для выделения памяти крупнее 127 Кб, она использует трюк с виртуальной памятью. Так почему у нас всё медленно работает?

Похоже, дело в том, что Apple слишком перемудрила. В large_malloc за константой #define спрятана куча кода, CONFIG_LARGE_CACHE . В основном весь этот код сводится к «списку свободных» (free-list) страниц больших объёмов памяти, выделенных для программы. Если MacOS выделяет смежный буфер размером от 127 Кб до LARGE_CACHE_SIZE_ENTRY_LIMIT (примерно 125 Мб), тогда libsystem_malloc попытается снова пустить в ход эти страницы, если ими может воспользоваться другой процесс выделения памяти. Благодаря этому ему не приходится просить страницы у ядра Darwin, что экономит переключение контекста и системный вызов: в принципе, нетривиальная экономия.

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

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

Однако кеш страницы полностью лишает нас преимуществ использования calloc для предоставления обнулённых страниц памяти. Это было бы не так уж плохо, если бы делалось только для «грязных» страниц. Если приложение записывает в обнуляемую страницу, то та, вероятно, не будет обнулена. Но MacOS выполняет это безоговорочно. Это значит, что даже если вызвать alloc , free и calloc , вообще не трогая память, то при втором вызове calloc будут взяты страницы, выделенные во время первого вызова и ни разу не поддержанные физической памятью. Поэтому ОС приходится загрузить (page-in) всю эту память, чтобы обнулить её, хотя она уже была обнулена. Этого мы и хотим избежать с помощью средства распределения на базе виртуальной памяти, когда доходит до выделения больших объёмов: ни разу не использовавшаяся память становится использованной «списком свободных» страниц.

В результате на MacOS стоимость calloc линейно возрастает в зависимости от размера выделяемой памяти вплоть до 125 Мб, несмотря на то что другие ОС демонстрируют поведение O(1) начиная со 127 Кб. После 125 Мб MacOS перестаёт кешировать страницы, так что скорость волшебным образом взлетает.

Я не ожидал найти такой баг из программы на Python, и у меня возник ряд вопросов. Например, сколько процессорных циклов теряется на обнуление памяти, которая уже обнулена? Сколько переключений контекста уходит на то, чтобы заставлять приложения загружать (page-in) память, которую они не использовали (и не собираются), чтобы ОС могла бессмысленно её обнулить?

Мне кажется, всё это подтверждает верность старой поговорки: утечки есть во всех абстракциях (all abstractions are leaky). Вы не можете забывать об этом лишь потому, что программируете на Python. Ваша программа выполняется на машине, использующей память и всякие трюки для её управления. Однажды, совершенно неожиданно, написанный вами код станет работать очень медленно. И разобраться с этим удастся, лишь разобравшись во внутренностях ОС.

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

Оцените статью