Threadsafe и отказоустойчивые записи файлов

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

filename = 'whatever'
tmpname = 'whatever' + str(time.time())

with open(tmpname, 'wb') as fp:
    fp.write(stuff)
    fp.write(more stuff)

if os.path.exists(filename):
    os.unlink(filename)
os.rename(tmpname, filename)

Я не доволен этим по нескольким причинам:

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

Любые предложения по улучшению кода? Есть ли библиотека, которая может мне помочь?

Ответ 1

Вы можете использовать модуль Python tempfile, чтобы дать вам временное имя файла. Он может создать временный файл в потокобезопасном режиме, а не использовать time.time(), который может возвращать одно и то же имя, если он используется одновременно в нескольких потоках.

Как указано в комментарии к вашему вопросу, это может быть связано с использованием диспетчера контекстов. Вы можете получить некоторые идеи о том, как реализовать то, что вы хотите сделать, посмотрев на источники Python tempfile.py.

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

  • Создание временных файлов является потокобезопасным.
  • Переименование файлов при успешном завершении является атомарным, по крайней мере, в Linux. Между os.path.exists() и os.rename() нет отдельной проверки, которая могла бы привести к состоянию гонки. Для атомного переименования в Linux источник и адресаты должны находиться в одной файловой системе, поэтому этот код помещает временный файл в тот же каталог, что и целевой файл.
  • Класс RenamedTemporaryFile должен вести себя как a NamedTemporaryFile для большинства целей, кроме случаев, когда он закрыт с помощью диспетчера контекстов, файл переименовывается.

Пример:

import tempfile
import os

class RenamedTemporaryFile(object):
    """
    A temporary file object which will be renamed to the specified
    path on exit.
    """
    def __init__(self, final_path, **kwargs):
        tmpfile_dir = kwargs.pop('dir', None)

        # Put temporary file in the same directory as the location for the
        # final file so that an atomic move into place can occur.

        if tmpfile_dir is None:
            tmpfile_dir = os.path.dirname(final_path)

        self.tmpfile = tempfile.NamedTemporaryFile(dir=tmpfile_dir, **kwargs)
        self.final_path = final_path

    def __getattr__(self, attr):
        """
        Delegate attribute access to the underlying temporary file object.
        """
        return getattr(self.tmpfile, attr)

    def __enter__(self):
        self.tmpfile.__enter__()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            self.tmpfile.delete = False
            result = self.tmpfile.__exit__(exc_type, exc_val, exc_tb)
            os.rename(self.tmpfile.name, self.final_path)
        else:
            result = self.tmpfile.__exit__(exc_type, exc_val, exc_tb)

        return result

Затем вы можете использовать его следующим образом:

with RenamedTemporaryFile('whatever') as f:
    f.write('stuff')

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

Ответ 2

Чтобы написать все или ничего в файл надежно:

import os
from contextlib import contextmanager
from tempfile   import NamedTemporaryFile

if not hasattr(os, 'replace'):
    os.replace = os.rename #NOTE: it won't work for existing files on Windows

@contextmanager
def FaultTolerantFile(name):
    dirpath, filename = os.path.split(name)
    # use the same dir for os.rename() to work
    with NamedTemporaryFile(dir=dirpath, prefix=filename, suffix='.tmp') as f:
        yield f
        f.flush()   # libc -> OS
        os.fsync(f) # OS -> disc (note: on OSX it is not enough)
        f.delete = False # don't delete tmp file if `replace()` fails
        f.close()
        os.replace(f.name, name)

См. также Переименован() без fsync() безопасен? (упоминается @Mihai Stan)

Использование

with FaultTolerantFile('very_important_file') as file:
    file.write('either all ')
    file.write('or nothing is written')

Чтобы реализовать отсутствующий os.replace(), вы можете вызвать MoveFileExW(src, dst, MOVEFILE_REPLACE_EXISTING) (через модули win32file или ctypes) в Windows.

В случае нескольких потоков вы можете вызвать queue.put(data) из разные потоки и записывать в файл в выделенном потоке:

 for data in iter(queue.get, None):
     file.write(data)

queue.put(None) прерывает цикл.

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

def write(self, data):
    with self.lock:
        self.file.write(data)

Ответ 3

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

from lockfile import FileLock
with FileLock(filename):
    #open your file here....

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

Ответ 4

Конструкция with полезна для очистки при выходе, но не для системы фиксации/отката, которую вы хотите. Для этого может использоваться блок try/except/else.

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

И помните fsync перед переименованием

Ниже приведен полный код:

import time, os, tempfile

def begin_file(filepath):
    (filedir, filename) = os.path.split(filepath)
    tmpfilepath = tempfile.mktemp(prefix=filename+'_', dir=filedir)
    return open(os.path.join(filedir, tmpfilepath), 'wb') 

def commit_file(f):
    tmppath = f.name
    (filedir, tmpname) = os.path.split(tmppath)
    origpath = os.path.join(filedir,tmpname.split('_')[0])

    os.fsync(f.fileno())
    f.close()

    if os.path.exists(origpath):
        os.unlink(origpath)
    os.rename(tmppath, origpath)

def rollback_file(f):
    tmppath = f.name
    f.close()
    os.unlink(tmppath)


fp = begin_file('whatever')
try:
    fp.write('stuff')
except:
    rollback_file(fp)
    raise
else:
    commit_file(fp)