У меня есть приложение tkinter с примерно 1000 пользователями, которое собирает данные из игры. Каждые 30 секунд оно сохраняет собранные данные в файл json. После того как несколько пользователей столкнулись с повреждением файлов сохранения после отключения электричества, я попытался реализовать функцию безопасного сохранения json, которая всегда обеспечивала бы сохранность файла.

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

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

.
def json_dump(dest_file: str, data: [dict, list]) -> None:
    tmp_file = dest_file[:-5] + '_tmp.json'
    with open(tmp_file, 'w') as fo:
        json.dump(data, fo, indent=2)
        fo.flush()
        os.fsync(fo.fileno())
    os.replace(tmp_file, dest_file)

Ошибка, которую мне прислал пользователь (которую я не могу воспроизвести сам, и с которой не сталкивается большинство пользователей)

19:46:02,481 root ERROR (, PermissionError(13, 'Доступ запрещен'), )
Traceback (последний последний вызов):
Файл "tkinter\__init__.py", строка 1883, in __call__.
Файл "main_frame.py", строка 168, в 
Файл "main_frame.py", строка 432, в notebook_tab_change
Файл "main_frame.py", строка 541, in SaveActiveState
Файл "utils\other_utils.py", строка 60, в json_dump
PermissionError: [WinError 5] Доступ запрещен: 'Profiles/PIT_tmp.json' -> 'Profiles/PIT.json'

Функция сохранения в файл может быть доступна нескольким потокам (запускается через метод tkinters after). Главный поток обрабатывает взаимодействие пользователя с пользовательским интерфейсом, что может привести к вызову функции сохранения (при изменении данных вручную или при выходе из приложения). Другой поток выполняет автосохранение каждые 30 секунд и, таким образом, также вызывает функцию сохранения.

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

oskros

Ответов: 1

Ответы (1)

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

os.replace(tmp_file, dest_file)

Когда два потока одновременно достигают этой строки, первый поток переименует tmp_file так, чтобы он больше не существовал, когда второй поток попытается сделать то же самое. (Btw: Почему вы используете os.replace вместо os.rename? Я переключился на последний и не увидел никакой разницы.)

.

Эту проблему можно воспроизвести, запустив несколько потоков или процессов. Здесь я буду использовать ProcessPoolExecutor, поскольку в моих экспериментах решения для потоков не всегда работали для процессов, а всегда наоборот.

import concurrent.futures

from thread_demo.dumper import json_dump

if __name__ == "__main__":

    with concurrent.futures.ProcessPoolExecutor() as executor:
        futures = {
            executor.submit(json_dump, "demo.json", {"random": f "data {i}"}): i
            for i in range(5)
        }

        done, _ = concurrent.futures.wait(
            futures, return_when=concurrent.futures.ALL_COMPLETED
        )

        for d in done:
            print(f"{d}: successful: {d.result()}")

На моей машине я обычно получаю 1-2 успешных запуска из 5:

fail [Errno 2] No such file or directory: 'demo_tmp.json' -> 'demo.json' for {'random': 'data 0'}
fail [Errno 2] No such file or directory: 'demo_tmp.json' -> 'demo.json' for {'random': 'data 3'}
fail [Errno 2] No such file or directory: 'demo_tmp.json' -> 'demo.json' for {'random': 'data 4'}
: successful: False
: successful: True
: successful: True
: successful: False
: successful: False

Мы видим ошибку "Нет такого файла" - но я работаю под Linux. Ошибка PermissionError под Windows может быть вызвана той же проблемой.

Я добавил дополнительный оператор try/except в json_dump и добавил булево значение для индикации успеха для более красивого вывода.

Вы можете решить вашу проблему (если она действительно вызвана несколькими потоками), используя мьютекс из multiprocessing.Manager(). Однако еще более простым решением может быть использование случайного имени для временного файла.

Вы можете сделать это вручную или использовать NamedTemporaryFile. Обратите внимание, что вам нужно указать аргумент dir, потому что, по крайней мере, в системах Linux, возникает ошибка 'Invalid cross-device link:', когда временный файл создается на другом устройстве.

Ваши обновленные json_dumps становятся:

import json
импортировать os
import tempfile


def json_dump(dest_file: str, data: [dict, list]) -> bool:
    with tempfile.NamedTemporaryFile("w", dir=".", delete=False) as fo:
        json.dump(data, fo, indent=2)
        fo.flush()
        os.fsync(fo.fileno())
    try:
        os.rename(fo.name, dest_file)
    except Exception as e:
        print(f "fail {e} for {data}")
        return False
    return True

Работает на моей машине :)

: successful: True
: successful: True
: successful: True
: successful: True
: successful: True

Вы можете воспроизвести PermissionError с методом ProcessPoolExecutor на машине windows? В этом случае я был бы весьма оптимистичен в том, что мы решили вашу проблему.

Обратите внимание, что try/except вокруг os.rename служит только для отладки. Вы хотите удалить это.

2022 WebDevInsider