Как сделать правильную блокировку файлов в NFS?

Я пытаюсь реализовать класс "менеджер записей" в Python 3x и Linux/MacOS. Класс относительно прост и понятен, единственная "трудная" вещь, которую я хочу, - иметь возможность доступа к одному и тому же файлу (где сохраняются результаты) в нескольких процессах.

Концептуально это казалось довольно простым: при сохранении получайте эксклюзивную блокировку файла. Обновите свою информацию, сохраните новую информацию, снимите эксклюзивную блокировку файла. Достаточно просто.

Я использую fcntl.lockf(file, fcntl.LOCK_EX) для получения эксклюзивной блокировки. Проблема в том, что, просматривая Интернет, я нахожу множество различных веб-сайтов, которые говорят, что это ненадежно, что оно не будет работать на Windows, что поддержка NFS ненадежна и что между macOS и линукс.

Я согласился с тем, что код не будет работать на Windows, но я надеялся, что смогу заставить его работать на MacOS (на одной машине) и на Linux (на нескольких серверах с NFS).

Проблема в том, что я не могу заставить это работать; и после некоторого времени отладки и после прохождения тестов на macOS они потерпели неудачу, как только я попробовал их на NFS с linux (ubuntu 16.04). Проблема заключается в несоответствии информации, сохраняемой несколькими процессами - в некоторых процессах отсутствуют их модификации, что означает, что что-то пошло не так в процедуре блокировки и сохранения.

Я уверен, что что-то не так делаю, и подозреваю, что это может быть связано с проблемами, о которых я читал в Интернете. Итак, как правильно организовать множественный доступ к одному и тому же файлу, который работает в macOS и linux через NFS?

редактировать

Вот как выглядит типичный метод записи новой информации на диск:

sf = open(self._save_file_path, 'rb+')
try:
    fcntl.lockf(sf, fcntl.LOCK_EX)  # acquire an exclusive lock - only one writer
    self._raw_update(sf) #updates the records from file (other processes may have modified it)
    self._saved_records[name] = new_info
    self._raw_save() #does not check for locks (but does *not* release the lock on self._save_file_path)
finally:
    sf.flush()
    os.fsync(sf.fileno()) #forcing the OS to write to disk
    sf.close() #release the lock and close

Хотя вот как выглядит типичный метод, который только читает информацию с диска:

sf = open(self._save_file_path, 'rb')
try:
    fcntl.lockf(sf, fcntl.LOCK_SH)  # acquire shared lock - multiple writers
    self._raw_update(sf) #updates the records from file (other processes may have modified it)
    return self._saved_records
finally:
    sf.close() #release the lock and close

Кроме того, вот так выглядит _raw_save:

def _raw_save(self):
    #write to temp file first to avoid accidental corruption of information. 
    #os.replace is guaranteed to be an atomic operation in POSIX
    with open('temp_file', 'wb') as p:
        p.write(self._saved_records)
    os.replace('temp_file', self._save_file_path) #pretty sure this does not release the lock

Сообщение об ошибке

Я написал unit тест, в котором я создал 100 различных процессов, 50 из которых читают, а 50 - пишут в один и тот же файл. Каждый процесс выполняет случайное ожидание, чтобы избежать последовательного доступа к файлам.

Проблема в том, что некоторые записи не хранятся; в итоге пропадает 3-4 случайных записи, поэтому я получаю только 46-47 записей, а не 50.

Редактировать 2

Я изменил приведенный выше код и получаю блокировку не для самого файла, а для отдельного файла блокировки. Это предотвращает проблему, заключающуюся в том, что закрытие файла снимет блокировку (как предложено @janneb), и заставляет код корректно работать на Mac. Тот же код не работает на Linux с NFS, хотя.

Ответ 1

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

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

Другая проблема заключается в том, что fcntl действительно очень глупый API. В частности, блокировки связаны с процессом, а не с дескриптором файла. Это означает, что, например, close() для ЛЮБОГО дескриптора файла, указывающего на файл, снимет блокировку.

Одним из способов будет использование "файла блокировки", например, использование атомарности link(). С http://man7.org/linux/man-pages/man2/open.2.html:

Переносимые программы, которые хотят выполнять атомарную блокировку файлов с использованием файла блокировки и должны избегать использования NFS для поддержки O_EXCL, могут создавать уникальный файл в той же файловой системе (например, включая имя хоста и PID) и использовать ссылку (2), чтобы сделать ссылка на файл блокировки. Если ссылка (2) возвращает 0, блокировка успешна. В противном случае используйте stat (2) для уникального файла, чтобы проверить, увеличилось ли количество ссылок до 2, и в этом случае блокировка также будет успешной.

Если это нормально для чтения немного устаревших данных, то вы можете использовать этот link() dance только для временного файла, который вы используете при обновлении файла, а затем os.replace() "основной" файл, который вы используете для чтения (чтение может быть беззамочные). Если нет, то вам нужно выполнить трюк link() для "основного" файла и забыть о разделяемой/эксклюзивной блокировке, тогда все блокировки являются эксклюзивными.

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

Ответ 2

Использование произвольно названных жестких ссылок и ссылок на эти файлы в качестве файлов блокировок является общей стратегией (например, this), и можно спорить лучше чем использование lockd, но для получения более подробной информации о границах всех видов блокировок над NFS читайте это: http://0pointer.de/blog/projects/locking.html

Вы также обнаружите, что это давняя стандартная проблема для программного обеспечения MTA с использованием файлов Mbox через NFS. Вероятно, лучший ответ заключался в использовании Maildir вместо Mbox, но если вы посмотрите примеры в исходном коде что-то вроде постфикса, это будет близко к лучшей практике. И если они просто не решают эту проблему, это также может быть вашим ответом.

Ответ 3

NFS отлично подходит для совместного использования файлов. Это отстой в качестве среды передачи.

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

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

NFS не является решением для передачи данных. Есть тайники и время. Не говоря уже о подкачке содержимого файла и метаданных файлов (например, атрибут atime). И клиент O/S'es отслеживает состояние локально (например, "where" для добавления данных клиента при записи в конец файла).

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

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