воскресенье, 20 декабря 2009 г.

Почему я должен волноваться об использовании DLL в моей системе?

Это перевод Why should I bother to use DLL's in my system? Автор: Ларри Остерман.

В конце этого поста я упомянул, что когда я компилирую новую версию winmm.dll на мою машину, мне нужно сделать перезагрузку. Cesar Eduardo Barros спросил:
Зачем нужна перезагрузка? Разве нельзя перезапустить все приложения, которые используют DLL, или перезапустить службу, которая использует её?
Оказывается, что в моем случае, это потому, что winmm перечисляется в "Known DLLs" ("Известные DLL") для Longhorn. А Windows обрабатывает "KnownDLLs" как специальные - если DLL является "KnownDLL", то предполагается, что она будет использоваться множеством процессов, поэтому она не загружается с диска при создании нового процесса - вместо этого уже загруженная DLL просто проецируется в текущий процесс.

Но всё это заставило меня задуматься о DLL вообще. Также это вылезло во время моего последнего обсуждения выбора библиотеки run-time C.

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

Может быть, этот код используются только в одном приложении - одно приложение, 50 экземпляров.

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

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

Во втором случае, однако, у вас есть две возможности - рефакторинг кода в библиотеку (library, lib) (прим. пер.: в Delphi ближайшим аналогом будет, наверное, pas/dcu-модуль или набор модулей) или рефакторинг кода в DLL.

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

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

Видите ли, NT довольно сообразительна, когда речь заходит о DLL (ну, конечно же, это не уникальное свойство NT; большинство других операционных систем, которые поддерживают разделяемые библиотеки, делают похожие вещи). Когда загрузчик проецирует DLL в память, он открывает файл и пытается спроецировать его в адресное пространство по предпочтительному базовому адресу. Когда это происходит, менеджер памяти просто говорит: “память с этого по этот виртуальный адрес должна читаться с этого файла”, и когда страниц касаются, стандартная логика файла подкачки (paging logic) загружает их в память.

Если страницы загружены, то система не обращается к диску за ними; она просто проецирует страницы в новый процесс. Она может сделать это, поскольку все правки адресов (relocation fixups) уже были сделаны (таблица relocation fixup является простой табличкой в исполняемом файле, которая содержит список адресов каждого абсолютного перехода (jump) в коде исполняемого файла – когда исполняемый файл загружается в память, загрузчик ОС исправляет эти адреса, чтобы учесть реальный базовый адрес загрузки исполняемого модуля), так что абсолютные переходы будут работать в новом процессе, так же как они работали в старом. Страницы в памяти поддерживаются (backed) файлом, содержащим DLL - т.е. если страница, содержащая код DLL выгружается из памяти, то она просто отбрасывается, а при следующей загрузке она просто восстанавливается из DLL файла.

Если же предпочтительный базовый адрес для DLL уже занят, то загрузчику ОС придётся делать больше работы. Сначала, он проецирует страницы с DLL в адресное пространство процесса на свободное место. Затем он отмечает все страницы атрибутом Copy-On-Write, так что он может сделать правки адресов, не боясь испортить исходную копию DLL (ну, ему в любом случае не разрешено писать в оригинальную DLL). Затем загрузчик производит перебазирование DLL - применяет все правки адресов, что приводит к созданию частных копий страниц, которые отличаются от оригинальных страниц наличием исправлений адресов. Создание частных страниц означает, что эти страницы имеют привязку к процессу и не могут разделяться между процессами.

Это приводит к увеличению общего потребления памяти в системе. Что ещё хуже, эти правки происходят каждый раз, когда DLL загружается в адресное пространство по адресу, отличному от предпочитаемого, что замедляет создание/запуск процесса.

Одним способом взглянуть на это является следующий пример. У меня есть DLL. Это небольшая DLL; у неё только лишь три страницы. Страница 1 содержит данные DLL, страница 2 содержит строковые таблицы (ресурсы) DLL, а страница 3 содержит код для DLL. Кстати, такие маленькие DLL обычно являются плохой идеей. Недавно меня просветил другой парень из офиса, насколько плохо это в действительности - быть может, я позже напишу что-нибудь по этой теме (предполагая, что Реймонд или Эрик не обставят меня с этим).

Предпочтительный базовый адрес этой DLL равен $40000. Она используется в двух различных приложениях. Оба приложения базируются по адресу $10000, первое занимает $20000 байт памяти для своего образа, а второе использует $40000 байт.

Когда запускается первое приложение, загрузчик открывает DLL и проецирует её по предпочтительному базовому адресу. Он может сделать это, потому что первое приложение загружено по $10000 и имеет размер $20000 байт - т.е. занимает адреса с $10000 по $30000, оставляя адрес $40000 свободным. Страницы помечаются атрибутами, согласно указаниям в образе DLL: страница 1 помечается как copy-on-write (поскольку это сегмент данных read/write), страница 2 помечается как read-only (потому что это константные ресурсы), а страница 3 помечается как read+execute (потому что это код). Когда приложение запущено, когда оно начнёт выполнять код в третьей странице, эта страница будет загружена в память. Остальные две страницы также будут загружены в память при первом обращении к ним. В тот момент, когда код DLL попытается записать данные в страницу 1, произойдёт клонирование страницы (fork) – создастся частная (private) копия страницы в памяти и все модификации будут происходить на этой копии страницы.

Если запускается второй экземпляр приложения (или же какого-либо другого приложения, которое позволяет загрузить DLL по адресу $40000), то тогда загрузчик ОС снова спроецирует DLL по предпочтительному адресу. И снова, когда код DLL получит управление, страница с кодом будет подгружена в память. Снова, страница не нуждается в правках, поскольку менеджер памяти просто использует ровно ту же физическую память, которая содержит уже загруженные страницы (от первого экземпляра). Когда DLL пишет в свой сегмент данных, создаётся новая частная копия первой страницы, которая никак не связана с копией в первом экземпляре приложения.

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

Теперь, посмотрим, что произойдёт со вторым приложением (образ которого занимает $40000 байт). Загрузчик ОС не может спроецировать DLL по её предпочитаемому базовому адресу (поскольку образ второго приложения занимает адреса с $10000 по $50000). Так что загрузчик проецирует DLL по адресу, скажем, $50000. Как и в первый раз, он отмечает атрибутами страницы, согласно отметкам в образе DLL, но с одним большим отличием: поскольку страница кода нуждается в перемещении на другой адрес (relocation), она ТАКЖЕ отмечается как copy-on-write. И тогда, поскольку он знает, что он не смог загрузить DLL по предпочитаемому базовому адресу и ей требуется перебазирование, загрузчик ОС исправляет адреса всех переходов. Это приводит к записи в страницу с кодом, поэтому менеджер памяти создаёт частную копию страницы. После завершения правок, загрузчик восстанавливает атрибут защиты на значение, указанное в образе DLL (read+execute). Теперь код в DLL начинает выполнение. Поскольку он уже был загружен в память (когда производили правки), то код просто выполняется, загрузки страницы не происходит. И снова, когда код DLL пытается запись в страницу данных, создаётся новая копия страницы данных.

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

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

Теперь представьте, что произойдёт, если у нас будет запущено 50 копий (экземпляров) первого приложения. У нас будет 52 страницы в памяти, занимаемые DLL - 50 страниц для данных DLL, одна для кода и одна для ресурсов.

А теперь, представьте, что будет при 50-ти экземплярах второго приложения: теперь у нас 101 страница в памяти - и только с одной трёхстраничной DLL! У нас будет 50 страниц для данных DLL, 50 страниц для исправленного кода и одна страница с ресурсами. Практически удвоенное потребление памяти просто потому, что для DLL не был правильно выбран предпочтительный базовый адрес.

Это увеличение потребления физической памяти обычно не является проблемой, если оно случается единожды. Если же, с другой стороны, оно происходит часто, и у вас нет достаточно физической памяти, чтобы выполнить все запросы, то вы начнёте выгружаться в файл подкачки. А это приведёт к “значительному падению производительности” (см. эту запись о том, что может случиться, если вы будете делать подкачку на сервере).

Вот почему так важно устанавливать правильный предпочитаемый базовый адрес для вашей DLL - это гарантирует, что ваша DLL действительно будет разделяться процессами. Это уменьшит время загрузки процесса и будет означать меньший working set (песочницу). Для NT есть и дополнительный бонус – при создании системы мы можем разместить системные DLL рядом, плотно упаковав их в единый блок. Это будет означать, что система будет потреблять меньше пользовательского адресного пространства (нет дырок). А на 32-х разрядных процессорах адресное пространство является ценным ресурсом (я никогда не думал, что я когда-либо напишу, что двух-гигабайтное адресное пространство может рассматриваться ограниченным ресурсом, но...).

Кстати, это не ограничивается только NT. К примеру, команда Exchange имеет скрипт, запускаемый при каждой сборке, который знает какие DLL куда загружены, и перемещает DLL Exchange так, что они влезают в неиспользуемое пространство, вне зависимости от процесса, в котором они используются. Могу поспорить, что у разработчиков SQL сервера тоже есть что-то такое.

Благодарности: спасибо Landy, Rick и Mike для рецензирование поста по технической точности (и вбивание в меня деталей через тонкий череп). Я должен вам, ребята.

Прим. пер.: Рихтер о DLL и базовых адресах.

Читать далее.

Комментариев нет:

Отправить комментарий

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.