Monday, April 1, 2019

Редактирование файлов с параметром in-place

Оригинал


РЕДАКТИРОВАНИЕ ФАЙЛОВ С ПАРАМЕТРОМ IN-PLACE.

Задавая вопросы типа "Как редактировать файлы in-place в sed/awk/perl и т.п.?" или " Я знаю, что ' >> ' добавляет текст в конец файла. Как можно добавить текст в начало файла?", люди обычно имеют в виду "Как изменить содержимое файла, не создавая временный файл?" (по какой-то неизвестной причине).

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

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

IN-PLACE?

Строго говоря, in-place на самом деле должно обозначать редактирование того же самого файла (с тем же инодом). В принципе, это возможно, но:

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

● это опасно: если во время редактирования что-то пойдет не так
(падение системы, переполнение диска и т.д.), файл останется в
недоделанном состоянии.

Ни один обычный редактор этого не делает. Даже если создается видимость
редактирования данного файла, на самом деле за кулисами создаётся
временный файл. Давайте посмотрим, что делают sed и perl, если задаётся
ключ -i.

SED

Во-первых, не все реализации редактора поддерживают эту опцию (GNU-sed поддерживает, POSIX-версия - нет). Это нестандартное расширение, поэтому оно не всегда доступно. В документации написано, что GNU-sed при задании ключа i создает временный файл (с именем исходного + расширение, к-рое надо задать) и направляет вывод в него. По завершении редактирования этот временный файл получает имя исходного ( а к исходному добавляется расширение, таким образом создается бэкап). Это можно проверить с помощью strace. Даже без strace, если посмотреть номера инодов с помощью ls -i, то можно увидеть, что иноды исходного файла и файла, модифицированного с помощью in-place, отличаются (если был задан суффикс, ессно. Если суффикс не задавать, изменяется сам исходный файл).

Используя ключ -i в  sed, убедитесь, что вы задали расширение для файла бэкапа. Только убедившись, что всё отредактировано корректно, можно удалять резервный файл. Sed в BSD и Mac OS X не принимает параметр без суффикса, но это можно обойти, задав пустой суффикс "".

PERL

Perl, как и sed, имеет такую опцию и тоже создает временный файл, но другим способом. Perl открывает файл и сразу вызывает unlink (удаление записи о файле из каталога и, соответственно, удаление его из файловой системы), после чего открывает новый файл (с новым дескриптором и инодом), в который
направляет вывод. После редактирования исходный файл удаляется, а остаётся модифицированный файл с именем оригинала. Это более рискованно, чем вариант sed, потому что если прервать процесс на середине, исходный файл будет утерян (в sed он будет доступен, даже если не задано расширение - там данные хранятся во вспомогательном буфере). Таким образом, в perl ещё более важно не забыть указать суффикс, после чего исходный файл будет переименован, а не отвязан от каталога.

ОЧЕРЕДНОЙ ЛОЖНЫЙ IN-PLACE.

Кстати, вот пример, который обычно приводится в качестве редактирования
in-place:

$ { rm file; command > file; } < file

command - это обычная команда редактирования файла, обычно фильтр или
потоковый редактор.
Пример: { rm file; sed '1 s/YYY/XXX/'>file; }<file

Это работает, потому что это обман. На самом деле там присутствуют 2 файла: файл, из которого берутся данные, не удаляется, так как он открыт в силу перенаправления ввода (<). Внутреннее перенаправление (>) на самом деле пишет в другой файл, хотя операционная система позволяет использовать то же имя, потому что оно больше "официально" не
принадлежит исходному файлу. По завершении процесса исходный файл (существовавший на протяжении действия анонимно, подавая данные для команды), удаляется с диска. Итак, в этом случае так же используется дополнительное пространство на диске, как если бы создавался временный файл (примерно размером с исходный файл). Это похоже на то, что делает
perl, если не задан суффикс, включая хранение исходного файла рискованном "открытом, но удалённом" состоянии. Так что если вы хотите использовать этот способ, то как минимум нужно сделать бэкап:

$ { mv file file.bak; command > file; } < file

Но это мало чем отличается от явного использования временного файла,
так что...

ЯВНОЕ ИСПОЛЬЗОВАНИЕ ВРЕМЕННОГО ФАЙЛА.

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

Обычный метод редактирования файла выглядит примерно так (command - это команда редактирования):

$ command file > tempfile && mv tempfile file
#или, в зависимости от того, как команда считывает входные данные
$ command < file > tempfile && mv tempfile file

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

$ command file > tempfile && cat tempfile > file && rm tempfile
# or
$ cp file tempfile && command tempfile > file && rm tempfile

Эти команды несколько менее эффективны, чем предыдущие, так как происходит 2 перехода между файлами (cat в первом случае и сp во втором). В большинстве случаев обычный способ подходит, и нужды в подобных методах нет. Если вам нужны мелкие подробности этих операций, эта статья на pixelbeat http://www.pixelbeat.org/docs/unix_file_replacement.html перечисляет множество вариантов, как заменить файл, используя временный файл с сохранением или без сохранения метаданных, описывая плюсы и минусы каждого метода.

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

СПОНЖ И ДРУГИЕ ФОКУСЫ.

Существуют альтернативы явному созданию временных файлов, хотя, по мнению автора, они уступают ему. С другой стороны, у них есть преимущество - они (обычно) сохраняют инод и метаданные.
Одна из таких утилит - sponge из пакета moreutils. Её использование очень просто:

command file | sponge file

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

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

Базовая реализация программы, подобной спонжу, на perl выглядела бы примерно так:


#!/usr/bin/perl
# sponge.pl
while(<STDIN>) {
  push @a, $_;
}
# EOF here
open(OUT, ">", $ARGV[0]) or die "Error opening $ARGV[0]: $!";
print OUT @a;
close(OUT);

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

Теперь можно сделать так:

command file | sponge.pl file

Подобный функционал можно реализовать, используя awk:

# sponge.awk
BEGIN {
  outfile = ARGV[1]
  ARGC--
}
{ a[NR] = $0 }
END {
  for(i=1;i<=NR;i++)
    print a[i] > outfile
}

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

СТАРЫЙ ДОБРЫЙ ED.

Если операции по редактированию не слишком сложны, то можно выбрать старый добрый ed. Особенность эда в том, что из stdin он считывает команды редактирования, а не данные. Например, чтобы добавить "ХХХ" к каждой строке файла, его можно использовать следующим образом:

printf '%s\n' ',s/^/XXX/' w q | ed -s file

(ключ -s подавляет вывод информации о том, сколько байт считано/записано, это не влияет на работу ed).

По крайней мере большинство реализаций ed создают временный файл, который используется как вспомогательный при редактировании. Когда редактор получает команду сохранить изменения, он пишет их в исходный файл. Такой метод работы прописан в стандарте posix, где говорится:

"Утилита ed должна работать с копией исходного файла. Изменения, сделанные в копии, не влияют на файл до тех пор, пока не дана команда w - write. Копия текста называется буфером"

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

"НО Я НЕ ХОЧУ ИСПОЛЬЗОВАТЬ ВРЕМЕННЫЙ ФАЙЛ!"

Ну ладно. После всего, что мы сказали, по каким-то загадочным причинам люди всё ещё пытаются избежать использования временного файла и изобретают "творческие" решения. Вот некоторые из них. ОНИ ВСЕ НЕПРАВИЛЬНЫЕ, И НИ ПРИ КАКИХ ОБСТОЯТЕЛЬСТВАХ НЕ ДОЛЖНЫ ИСПОЛЬЗОВАТЬСЯ. Дети, не повторяйте это дома!

КОНВЕЙЕР СМЕРТИ.

Иногда пытаются сделать так:

$ cat file | command > file     # не работает

или так:

$ command file | cat > file     # doesn't work

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

command file | { sleep 10; cat > file; } # НЕ ДЕЛАЙТЕ ТАК

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

Смысл здесь такой: "давайте подождём 10 сек., чтобы команда могла прочитать весь файл и выполнить операции до того, как он будет обрезан, и новые данные можно будет записать туда через конвейер." Давайте проигнорируем факт, что 10 секунд может хватить, а может и нет (как и в случае с любым заданным значением). Здесь есть гораздо более серьезная, фундаментальная проблема. Давайте посмотрим, что будет, если файл даже умеренно большого размера. Правая сторона конвейера не получает никаких данных из него в течение 10 (или скольки угодно) секунд. Это значит, что всё, что выдаёт команда, переходит в конвейер и остаётся в нём, по крайней мере до тех пор, пока не закончит выполняться sleep. Разумеется, конвейер не может хранить бесконечное количество данных, так как его размер обычно довольно ограничен (примерно несколько десятков килобайт, хотя это зависит от реализации). Что же происходит, если вывод команды полностью заполняет конвейер до окончания задержки? В какой-то момент времени вывод write(), производимый командой, блокируется. Для большинства команд это означает, что блокируется и сама команда. В частности, она больше ничего не будет читать из файла. Итак, вполне вероятно, особенно если файл с входными данными достаточно большой, и, соответственно, порождает много выходных данных, что команда будет заблокирована, не дочитав исходный файл до конца, и останется в таком состоянии до окончания задержки.

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

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

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

$ cat file | { sleep 10; command > file; }    # DO NOT DO THIS

И снова - cat будет заблокирована, если файл большой, и конвейер заполнен до того, как пройдут 10 секунд.

Вероятно, лучше сформулировать это более точно: код, упомянутый, выше, может привести к краху вашей системы. НЕ ИСПОЛЬЗУЙТЕ ЕГО ни при каких обстоятельствах. Если вы не верите и хотите убедиться в этом сами, попробуйте сделать это в файловой системе, которую можно заполнить до конца (рекомендуется зацикленная - loopback).

# dd if=/dev/zero of=loop.img bs=1M count=100 - создание файла 100Мб.
100+0 records in
100+0 records out
104857600 bytes (105 MB) copied, 2.58083 s, 40.6 MB/s
# mke2fs loop.img  - создание файловой системы на loop- разделе.
mke2fs 1.41.9 (22-Aug-2009)
loop.img is not a block special device.
Proceed anyway? (y,n) y
...
# mount -o loop loop.img /mnt/temp - монтирование устройства.
# cd /mnt/temp

Создание относительно большого файла:
# seq 1 200000 > file

Приступим:

# sed 's/^/XXX/' file | { sleep 10; cat > file; }    # DO NOT DO THIS
cat: write error: No space left on device
# ls -l
total 97611
-rw-r--r-- 1 root root 99549184 2010-04-02 20:08 file
drwx------ 2 root root    12288 2010-04-02 18:48 lost+found
# tail -n 3 file
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX8483
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX8484
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX#
# Uh-oh...

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

БУФЕРЫ И ДЕСКРИПТОРЫ.

Ещё одно "решение", которое встречается время от времени, выглядит примерно так:

$ awk 'BEGIN{print "This is a prepended line"}{print}' file 1<>file     # DO NOT DO THIS

Это не даёт шеллу обрезать файл, так как используется '<>', что означает открытие файла на чтение и запись. Может показаться, что это просто святой грааль редактирования in-place. Так ли это?

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

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

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

awk '{gsub(/foo/, "bar"); print}'

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

awk '{gsub(/foo/, "bar"); print}' file 1<>file     # DO NOT DO THIS

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

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

$ cat > file
100
200
300
400
500
C^d
$ sed 's/00/A/' file 1<>file
$ cat file
1A
2A
3A
4A
5A

500

Это ожидаемо. После того, как были записаны изменения, остались ещё исходные данные, и sed, как и другие подобные утилиты, не вызывает truncate или ftruncate, так как он для этого не предназначен. Так что это не работает.

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

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

Первое - при использовании фильтров или потоковых редакторов чтение, очевидно, происходит раньше, чем запись. Поэтому, допустим, программа может прочитать 10 байт, а записать 20. Кроме того, операции ввода/вывода обычно буферизируются, то есть большинство программ используют буферизированные системные вызовы типа fread или fwrite. Эти вызовы не читают напрямую из файла и не пишут прямо в него, как это делают read и write. Вместо этого используются внутренние буферы (обычно реализованные с помощью библиотеки С), назначение которых - "аккумуляция" данных. Например, когда  приложение читает 10 байт с помощью fread, на самом деле читаются 4096 байт и заносятся в буфер, а 10 возвращаются приложению. Когда приложение пишет 20 байт, они заносятся в буфер вывода и только когда он будет заполнен (опять примерно 4096 байт), данные из него на самом деле пишутся в файл. Если стандартные дескрипторы ввода/вывода приложения не связаны с терминалом (если, разумеется, оно не вызывает read/write напрямую), входные/выходные данные будут буферизированы.

Мы можем удостовериться, что это правда, если, например, посмотрим на вывод strace. Мы увидим, что чтение и запись происходят большими массивами, что не соответствует ожидаемому поведению приложения. Например, в этой системе размер буфера, похоже, 4096 байт. Что это значит для нас? Это значит, что благодаря буферизации, в частности, вывода,  в случае, когда данных на выходе больше, чем на входе, иногда может показаться, что способ работает (но на самом деле ведёт к катастрофе).

Итак, как работает буферизация. Давайте посмотрим на примере awk, с которого мы начали:

$ cat file
This is line1
This is line2
This is line3
This is line4
This is line5
$ awk 'BEGIN{print "This is a prepended line"}{print}' file 1<>file     # DO NOT DO THIS
$ cat file
This is a prepended line
This is line1
This is line2
This is line3
This is line4
This is line5

Этот явно сверхъестественный результат возможен благодаря буферизации ввода/вывода. Давайте взглянем на вывод strace:

$ strace awk 'BEGIN{print "This is a prepended line"}{print}' file 1<>file
...
open("file", O_RDONLY)              = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=70, ...}) = 0
ioctl(3, SNDCTL_TMR_TIMEBASE or TCGETS, 0x7fff555abe30) = -1 ENOTTY (Inappropriate ioctl for device)
fstat(3, {st_mode=S_IFREG|0644, st_size=70, ...}) = 0
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
read(3, "This is line1\nThis is line2\nThis"..., 70) = 70
read(3, "", 70)                         = 0
close(3)                                = 0
write(1, "This is a prepended line\nThis is li"..., 95) = 95
exit_group(0)

Здесь есть нечто странное: почему read вызван раньше, чем write, хотя в коде есть выражение print сразу в блоке BEGIN, которое явно должно было быть выполнено до чтения любых данных? Как мы уже сказали, I/O буферизирован, поэтому когда приложение пишет, данные не записываются в файл, пока в буфере есть свободное место или пока файл не будет закрыт или стерт. Поэтому код " print "This a prepended line" помещает строку в какой-то буфер записи библиотеки С, а не в файл. Это не очевидно из вывода strace, так как процесс полностью происходит в пространстве пользователя без системных вызовов. Затем выполнение кода продолжается, и awk входит в основной цикл, которые запрашивает чтение файла. Тогда буферизированный i/o пытается прочесть весь блок данных (read в strace), который в данном случае является всем файлом, хранящимся где-то в буфере ввода. Затем awk исполняет тело кода которое заключается в копировании входных данных в поток вывода. Обе операции буферизированы, поэтому чтение происходит из буфера ввода, а запись - из буфера вывода (которые уже содержит строку, записанную print в блоке begin, поэтому последующий вывод добавляется к ней.) Ничего этого не видно в strace, поскольку происходит в пространстве пользователя. Наконец, файл закрывается (так как завершается выполнение программы), дескриптор 1 сбрасывается, и, наконец, вызывается write. В результате всего описанного буфер вывода ко времени записи содержит как раз строку, которую мы хотели добавить в начало файла, плюс исходные данные файла, и эти данные пишутся в файл.

Давайте воспользуемся ltrace, которая показывает как системные вызовы, так и вызовы библиотек, чтобы подтвердить нашу догадку (вывод представлен не весь для большей наглядности):

$ ltrace -S -n3 awk 'BEGIN{print "This is a prepended line"}{print}' file 1<>file
...
   121    fileno(0x7f06e270b780)                        = 1
...
   213    fwrite("This is a prepended line", 1, 24, 0x7f06e270b780 <unfinished ...>
   214       SYS_fstat(1, 0x7fff663f4050)               = 0
   215       SYS_mmap(0, 4096, 3, 34, 0xffffffff)       = 0x7f06e2daa000
   216    <... fwrite resumed> )                        = 24
   217    __errno_location()                            = 0x7f06e2d9a6a8
   218    fwrite("\n", 1, 1, 0x7f06e270b780)            = 1
...
   226    open("file", 0, 0666 <unfinished ...>
   227       SYS_open("file", 0, 0666)                  = 3
   228    <... open resumed> )                          = 3
...
   246    read(3,  <unfinished ...>
   247       SYS_read(3, "This is line1\nThis is line2\nThis"..., 70) = 70
   248    <... read resumed> "This is line1\nThis is line2\nThis"..., 70) = 70
...
   254    fwrite("This is line1", 1, 13, 0x7f06e270b780) = 13
   255    __errno_location()                            = 0x7f06e2d9a6a8
   256    fwrite("\n", 1, 1, 0x7f06e270b780)            = 1
   257    _setjmp(0x64d650, 0x1d64ceb, 0x1d635b0, 0, 0) = 0
   258    __errno_location()                            = 0x7f06e2d9a6a8
   259    fwrite("This is line2", 1, 13, 0x7f06e270b780) = 13
   260    __errno_location()                            = 0x7f06e2d9a6a8
   261    fwrite("\n", 1, 1, 0x7f06e270b780)            = 1
   262    _setjmp(0x64d650, 0x1d64cf9, 0x1d635b0, 0, 0) = 0
   263    __errno_location()                            = 0x7f06e2d9a6a8
   264    fwrite("This is line3", 1, 13, 0x7f06e270b780) = 13
   265    __errno_location()                            = 0x7f06e2d9a6a8
   266    fwrite("\n", 1, 1, 0x7f06e270b780)            = 1
   267    _setjmp(0x64d650, 0x1d64d07, 0x1d635b0, 0, 0) = 0
   268    __errno_location()                            = 0x7f06e2d9a6a8
   269    fwrite("This is line4", 1, 13, 0x7f06e270b780) = 13
   270    __errno_location()                            = 0x7f06e2d9a6a8
   271    fwrite("\n", 1, 1, 0x7f06e270b780)            = 1
   272    _setjmp(0x64d650, 0x1d64d15, 0x1d635b0, 0, 0) = 0
   273    __errno_location()                            = 0x7f06e2d9a6a8
   274    fwrite("This is line5", 1, 13, 0x7f06e270b780) = 13
   275    __errno_location()                            = 0x7f06e2d9a6a8
   276    fwrite("\n", 1, 1, 0x7f06e270b780)            = 1
   277    read(3,  <unfinished ...>
   278       SYS_read(3, "", 70)                        = 0
   279    <... read resumed> "", 70)                    = 0
   280    __errno_location()                            = 0x7f06e2d9a6a8
...
   284    close(3 <unfinished ...>
   285       SYS_close(3)                               = 0
   286    <... close resumed> )                         = 0
   287    free(0x1d64cd0)                               = <void>
   288    __errno_location()                            = 0x7f06e2d9a6a8
   289    fflush(0x7f06e270b780 <unfinished ...>
   290       SYS_write(1, "This is a prepended line\nThis is"..., 95) = 95
   291    <... fflush resumed> )                        = 0
   292    fflush(0x7f06e270b860)                        = 0
   293    exit(0 <unfinished ...>
   294       SYS_exit_group(0 <no return ...>
   295 +++ exited (status 0) +++

Awk использует буферизированный ввод/вывод (fread/fwrite), и в строке 121 файлу, соответствующему объекту по адресу  0x7f06e270b860 (предположительно указатель на объект FILE для stdout), присваивается дескриптор 1, т.е. stdout.

В строках 213-218 выполняется выражение print блока BEGIN. Заметьте, что системные вызовы записи отсутствуют, так что данные заносятся в буфер библиотеки С, а не в файл.
В строках 226-228 файл открывается на чтение, что является обычным поведением awk перед началом выполнения цикла, 246-248 - чтение содержимого файла (так как ввод данных буферизирован, вызов fread запускает системный вызов read, который считывает весь файл из буфера ввода). Начиная с 254 строки выполняется основное тело программы (т.е. {print}). Все данные поступают в буфер библиотеки С, где уже содержится вывод из блока BEGIN.
284 строка закрывает дескриптор, использованный для чтения файла. До этого момента файл всё ещё не модифицирован. В строке 289 очищается стандартный вывод, и только в 290 данные пишутся в файл.

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

 stdbuf -o0 awk 'BEGIN{print "This is a prepended line"}{print}' file 1<>file
# hangs, press ctrl-C
^C
$ ls -l file
-rw-r--r-- 1 waldner waldner 10298496 Jan 28 15:04 file
$ head -n 20 file
This is a prepended line
This is a prepended line
e2
This is line3
This is line4
This is line5
s is line4
This is line5
s is line4
This is line5
s is line4
This is line5
s is line4
This is line5
s is line4
This is line5
s is line4
This is line5
s is line4
This is line5

Это, наконец, демонстрирует, что, как и ожидалось, вывод большего количества данных, чем было введено, не работает, а если кажется, что работает, то только благодаря буферизированному выводу данных. Очевидно, что мы не знаем заранее, будет ли использоваться экранный буфер: это зависит от кода программы и других факторов. Даже если стандартом Posix предписывается, чтобы некоторые функции использовали буферизированый ввод/вывод данных, если они не обращаются к "интерактивному устройству", то нет гарантий, что приложение будет использовать эти функции ( fread/fwrite). Оно может использовать read и write напрямую, а они не имеют буферов. Даже при использовании буферизированных функций, ничто не помешает приложению вызвать fflush, когда ему заблагорассудится, или даже вообще отключить буферизацию. Если это произойдёт, начнётся ад.

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

Очевидно, буфер вывода является временным хранилищем данных, размер которых меньше или равен размеру самого буфера (например, 4096 байт). Когда буфер заполняется,  его содержимое записывается в файл. Это означает, что и без того слабая защита, обеспечиваемая буфером, исчезает, когда разница между входными и выходными данными больше, чем размер буфера. В этом случае данные из буфера вывода записываются на диск поверх исходных данных, которые ещё не были прочитаны, из чего снова вытекает катастрофа (как минимум - потеря данных, а возможно и вход в бесконечных цикл к увеличением размеров файла, в зависимости от того, как именно программа преобразует данные). Это легко проверить. Давайте снова используем awk.

# Let's prepend more than 4096 bytes to our file
$ awk 'BEGIN{for(i=1;i<=1100;i++)print i}1' file 1<>file
# after a while...
awk: write error: No space left on device
# Let's recreate the file
$ printf 'This is line 1\nThis is line 2\nThis is line 3\nThis is line 4\nThis is line 5\n' > file
# Let's try writing 5000 bytes at once now
$ awk 'BEGIN{printf "%05000d\n", 1}1' file 1<>file
$ cat file
[snip]
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
$ wc -l file
2 file

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

ЗАЛ ПОЗОРА.

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

$ sed 's/foo/longer/g' file 1<>file   # DO NOT DO THIS

# prepend data to a file. Some smart cats detect this and complain
$ { command; cat file; } 1<>file   # DO NOT DO THIS

# let's throw pipes into the mix

# prepend a file, bash
$ cat <(cat file1 file2) 1<>file2   # DO NOT DO THIS

# prepend a file, POSIX sh
$ cat file1 file2 | cat 1<>file2   # DO NOT DO THIS

# prepend text, "fooling" cat
$ { command; cat file; } | cat 1<>file   # DO NOT DO THIS

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

$ seq 100000 105000 > file
$ wc -l file
5001 file
$ { seq 1 2000; cat file; } | cat 1<>file
$ wc -l file
208229 file       # should be 7001
$ seq 100000 105000 > file
$ wc -l file
5001 file
$ { seq 1 2000; cat file; } | cat 1<>file
$ wc -l file
194630 file       # should be 7001
$ seq 100000 105000 > file
$ wc -l file
5001 file
# now let's add more data
$ { seq 1 20000; cat file; } | cat 1<>file
^C
$ ls -l file
-rw-r--r-- 1 waldner users 788046226 2010-05-09 15:26 file
# etc.

ЗАКЛЮЧЕНИЕ.

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

Update 28/12/2012:

Мне сообщили, что есть ещё способ записи в файл без создание временного файла. Прежде чем продемонстрировать его, повторю, что это плохая идея, разве что вы ДЕЙСТВИТЕЛЬНО знаете, что делаете (даже в этом случае подумайте 10 раз, прежде чем это делать).

Итак, по крайней мере в bash, подстановки, которые выполняет интерпретатор (раскрытие переменных, подстановки команд и т.п.) происходят до того, как действуют перенаправления. Поэтому можно сделать так:

mycommand > "$somefile"

Переменная $somefile должна быть раскрыта до перенаправления вывода. Как можно это использовать для редактирования in-place (в этом случае действительно без временного файла)? Просто:

printf '%s\n' "$(sed 's/foo/bar/g' file)" > file #Очередной пример для зала позора

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

$ printf '%s\n' "$(sed 's/foo/bar/g' bigfile)" > bigfile
-bash: xrealloc: ../bash/subst.c:658: cannot allocate 378889344 bytes (1308979200 bytes allocated)
Connection to piggy closed.

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

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

Update 2 23/02/2013:

Для тех, кто хочет реального редактирования in-place, существует модуль Perl Tie::File, который позволяет модифицировать сам исходный файл с тем же инодом, а также делает всю грязную работу по расширению/обрезанию файла. Он представляет файл в виде массива, и код просто модифицирует массив, после чего изменения трансформируются в реальные изменения на диске. Разумеется, все предостережения остаются в силе: файл находится в промежуточном состоянии во время редактирования, а кроме того, по мере увеличения размера файла или количества модификаций, будет снижаться производительность. Как говорится, нельзя и рыбку съесть, и ....

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

Вот простой пример (хотя возможности этим не ограничиваются)

#!/usr/bin/perl

use Tie::File;
use warnings;
use strict;

my $filename = $ARGV[0];
my @array;

tie @array, 'Tie::File', $filename or die "Cannot tie $filename";

$array[9] = 'newline10';      # change value of line 10
splice (@array, 0, 5);        # removes first 5 lines
for (@array) {
  s/foo/longerbar/g;         # sed-like replacement
}

# etcetera; anything that can be done with an array can be done
# (but see the CAVEATS section in the documentation)

untie @array;

Тестовый прогон:

cat -n file.txt
     1 this line will be deleted 1
     2 this line will be deleted 2
     3 this line will be deleted 3
     4 this line will be deleted 4
     5 this line will be deleted 5
     6 this line will not be deleted foo
     7 foo foo abc def
     8 hello world
     9 something normal
    10 something weird
    11 something foobar
$ ls -i file.txt
11672298 file.txt
$ ./tiefile_test.pl file.txt
$ cat -n file.txt
     1 this line will not be deleted longerbar
     2 longerbar longerbar abc def
     3 hello world
     4 something normal
     5 newline10
     6 something longerbarbar
$ ls -i file.txt
11672298 file.txt

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

Update 3 17/03/2015:

Если (и только если)

・размер изменяемых и замещающих данных одинаков;
・вы точно знаете позицию в файле, в которую будут записаны изменённые данные;
・вы храбры,
ещё одной возможностью является древняя утилита dd. Фокус в том, что можно сказать программе не уничтожать файл, куда будет производиться запись, с помощью опции conv=notrunc. Таким образом, если нам известно, что текст, который мы хотим изменить, начинается с байта 200, можно сделать так:

$ printf "newtext" | dd of=myfile seek=199 bs=1 conv=notrunc
7+0 records in
7+0 records out
7 bytes (7 B) copied, 3.4565e-05 s, 86.8 kB/s

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

No comments:

Post a Comment