perf(obj-save): CRC из буфера без перечитывания файла при сохранении предметов (#3368)#3369
Conversation
kvirund
left a comment
There was a problem hiding this comment.
Request changes
Подход в текущем виде требует переработки. Переход с синхронной записи на fire-and-forget через многопоточный пул, без сериализации по игроку, без барьера и без согласования с тем, что реально лежит на диске, ломает несколько инвариантов, которые синхронный код держал бесплатно. Ссылка на «аналог ChestSaver» некорректна: ChestSaver/IngrChestSaver — это параллельно, но синхронно (задачи дедуплицируются по ключу, run() блокируется на f.get() до возврата в главный цикл, лог пишется из главного потока). Здесь же — никакого из этих свойств.
Ниже разделил на явные регрессии (поведение изменилось относительно синхронного кода) и состояния гонки.
Явные регрессии (поведение хуже, чем было)
R1. Молчаливая потеря данных при ошибке записи.
Раньше провал записи файла возвращал false и звал Crash_delete_files. Теперь главный поток уже вернул true, обновил CRC и (для RENT_CRASH) вызвал ClearSaveinfo, а фон при ошибке всего лишь делает log(). Итог: предметы не записаны, но в crc.lst зафиксирован CRC, ретрая нет, ошибка наружу не всплывает. Для системы сохранения шмота это недопустимо.
R2. CRC больше не верифицирует то, что на диске.
Весь смысл FileCRC::check_crc — поймать порчу/несоответствие файла на диске. Теперь CRC считается из in-memory буфера в главном потоке, а файл пишет другой поток. Если фоновая запись частично или полностью провалилась (диск, краш в момент записи), в crc.lst окажется CRC, которому файл не соответствует. На следующем буте check_crc либо поднимет ложную тревогу о «несовпадении контрольной суммы», либо, наоборот, замаскирует реальную порчу. Мы сертифицируем буфер, а не файл.
R3. Текстовый режим + CRC из буфера => рассинхрон на Windows/Cygwin.
.obj пишется текстовым std::ofstream, CRC берётся из буфера. На Linux совпадает, но CONTRIBUTING требует сборки под Windows/Cygwin, где \n→\r\n разойдётся с буфером и CRC не сойдётся. Оригинал перечитывал файл, поэтому совпадал всегда. Минимум — открывать .obj в бинарном режиме.
R4. Формат таймер-файла изменён.
write_timer_file пишет по info.time (т.е. time.size()), а оригинал и CRC-буфер — по rent.n_items. В штатном пути они равны, но при любом расхождении файл перестаёт соответствовать собственной CRC. Надо итерировать по rent.n_items, чтобы остаться побайтово как раньше.
R5. chmod-ошибки больше не логируются (оригинал логировал). Косметика, но это тоже понижение наблюдаемости.
Состояния гонки
C1. Конкурентная запись в один и тот же файл игрока (блокер).
Пул из hardware_concurrency/2 потоков, дедупа нет. Если один персонаж попадает в очередь дважды в пределах окна записи (автосейв RENT_CRASH из Crash_frac_save_all, наложившийся на quit/rent того же чара), два воркера пишут один .obj/.time параллельно → перемешанное/обрезанное содержимое = битый сейв. У ChestSaver этого нет за счёт дедупа + барьера.
C2. Нет гарантии порядка (блокер).
Даже без буквальной одновременности при >1 воркере очередь не FIFO по исполнению: старый сейв может лечь поверх нового (откат состояния / дюп: взял→выбросил за <100мс, на диск попало «взял»).
C3. Read-after-write больше не гарантирован.
Crash_load при входе/возврате из ренты читает файл синхронно в главном потоке; при fire-and-forget ничто не гарантирует, что последняя запись уже сброшена. Человеческий зазор обычно закрывает окно, но инвариант больше ничем не enforced.
C4. log() из воркер-потока.
enqueue зовёт log() прямо внутри фоновой задачи. log()/LogManager::Info написаны под однопоточный игровой цикл и синхронизации не имеют (m_senders обходится без мьютекса). ChestSaver сознательно логирует только из главного потока после f.get(). Нужно либо убрать лог из воркера (возвращать результат и логировать в главном потоке), либо доказать потокобезопасность пути логирования.
Прочее
- Размер пула игнорирует конфиг #3367. Только что смерженный
runtime_config.thread_pools_workers()уже используется вChestSaver; здесь жёстко зашитоhardware_concurrency/2. Каждый глобальный сейвер плодит собственный пул — оверсабскрайб потоков. - Мёртвый защитный
resize. Для write-onlystringstreamtellp() == str().size(), веткаwritten < obj_content.size()недостижима; комментарий про «prealloc оставил нули в хвосте» вводит в заблуждение.
Итог
Память и арифметика CRC сами по себе корректны (глубокие копии obj_content/SaveInfo, паритет need_save), но архитектурно патч меняет контракт подсистемы сохранения: синхронная запись с верификацией файла на диске заменена на асинхронную запись без сериализации, без верификации и с молчаливым проглатыванием ошибок. C1/C2 — блокеры; R1/R2/R3 и C3/C4 — серьёзные. Прошу переработать подход (как минимум: сериализация по uid либо однопоточный пул для игроков; CRC, согласованный с тем, что реально записано; внятная обработка ошибок записи; лог только из главного потока).
Куда движемся: переработка подходаПо итогам ревью отказались от fire-and-forget и идём синхронно. Ниже — что сделано, почему так, и запасной план на случай, если синхронного варианта окажется мало. Почему не fire-and-forgetИсходная реализация — запись в фоновый пул без сериализации по игроку — даёт реальные гонки (конкурентная запись одного файла, отсутствие упорядочивания, чтение до завершения записи на загрузке) и тихо проглатывает ошибки записи. Параллелизм «как у Что сделаноКлючевая оптимизация PR — CRC из буфера в памяти — сохранена, но синхронно и корректно:
Итог: убраны 2 из 4 файловых операций (оба чтения только что записанных файлов). Всё в главном потоке → ни гонок, ни потоков, ни лишних пулов. Про целостность: чтение после записи никогда не проверяло саму запись — оно лишь получало значение CRC. Проверка целостности происходит позже, на загрузке ( ЗамерыДобавлены раздельные таймеры записи Прежние 74мс из описания — это разница между двумя строками лога, а не замер; к тому же у игрока было всего 17 предметов, что намекает на фиксированную задержку на каждую файловую операцию (или даже частично на задержку самого логирования), а не на стоимость, пропорциональную размеру. Поэтому сначала снимаем реальную разбивку с прода. Порядок решений
План Б: fire-and-forget, сделанный правильно (если понадобится)Возвращаемся к фоновой записи, но без «forget»:
То есть кэш отложенной записи (write-back) с синхронизацией чтения. Заходим в это только если данные покажут, что синхронного варианта недостаточно. |
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time для CRC. Два чтения только что записанных файлов убраны. - .obj пишется в бинарном режиме (std::ios::binary), CRC считается из того же буфера через FileCRC::update_from_content. Бинарный режим гарантирует совпадение байт файла и буфера на всех платформах (в текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера разошёлся бы с CRC файла). - Crash_write_timer сериализует таймеры в буфер (rent + n_items записей, как раньше), пишет одним fwrite и считает CRC из буфера. - Обработка ошибок записи прежняя: false + Crash_delete_files. - Всё синхронно в главном потоке -- без гонок и без фоновых тредов. Добавлены раздельные замеры времени записи .obj и .time с логом при total > 10мс -- для снятия разбивки с прода и решения, нужна ли дальнейшая оптимизация (фоновая запись). Refs #3368, #3369
efbdda4 to
7f85ebe
Compare
|
Ветка PR форс-обновлена: асинхронный вариант ( |
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time для CRC. Два чтения только что записанных файлов убраны. - .obj пишется в бинарном режиме (std::ios::binary), CRC считается из того же буфера через FileCRC::update_from_content. Бинарный режим гарантирует совпадение байт файла и буфера на всех платформах (в текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера разошёлся бы с CRC файла). - Crash_write_timer сериализует таймеры в буфер (rent + n_items записей, как раньше), пишет одним fwrite и считает CRC из буфера. - Обработка ошибок записи прежняя: false + Crash_delete_files. - Всё синхронно в главном потоке -- без гонок и без фоновых тредов. Добавлены раздельные замеры времени записи .obj и .time с логом при total > 10мс -- для снятия разбивки с прода и решения, нужна ли дальнейшая оптимизация (фоновая запись). Refs #3368, #3369
7f85ebe to
aadb53d
Compare
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time для CRC. Два чтения только что записанных файлов убраны. - .obj пишется в бинарном режиме (std::ios::binary), CRC считается из того же буфера через FileCRC::update_from_content. Бинарный режим гарантирует совпадение байт файла и буфера на всех платформах (в текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера разошёлся бы с CRC файла). - Crash_write_timer сериализует таймеры в буфер (rent + n_items записей, как раньше), пишет одним fwrite и считает CRC из буфера. - Обработка ошибок записи прежняя: false + Crash_delete_files. - Всё синхронно в главном потоке -- без гонок и без фоновых тредов. Добавлены раздельные замеры времени записи .obj и .time с логом при total > 10мс -- для снятия разбивки с прода и решения, нужна ли дальнейшая оптимизация (фоновая запись). Refs #3368, #3369
aadb53d to
2b7df6f
Compare
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time для CRC. Два чтения только что записанных файлов убраны. - .obj пишется в бинарном режиме (std::ios::binary), CRC считается из того же буфера через FileCRC::update_from_content. Бинарный режим гарантирует совпадение байт файла и буфера на всех платформах (в текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера разошёлся бы с CRC файла). - Crash_write_timer сериализует таймеры в буфер (rent + n_items записей, как раньше), пишет одним fwrite и считает CRC из буфера. - Обработка ошибок записи прежняя: false + Crash_delete_files. - Всё синхронно в главном потоке -- без гонок и без фоновых тредов. Добавлены раздельные замеры времени записи .obj и .time с логом при total > 10мс -- для снятия разбивки с прода и решения, нужна ли дальнейшая оптимизация (фоновая запись). Refs #3368, #3369
2b7df6f to
c434a03
Compare
|
Сузил PR до bare-minimum, закрывающего основную проблему тикета (read-back при сохранении предметов):
Остальная чистка CRC-подсистемы (сверка из буфера на загрузке вместо второго чтения файла, |
Добавлена FileCRC::update_from_content(uid, mode, data, len): считает CRC из готового буфера и обновляет crc_list. Семантика upsert и флага need_save идентична check_crc(UPDATE_*), отличается только источник данных -- буфер вместо повторного чтения файла с диска. Нужна для устранения чтения только что записанного файла при сохранении предметов игрока (см. #3368).
Crash_frac_save_all делал на каждого игрока 4 синхронные файловые операции: запись .obj, чтение .obj для CRC, запись .time, чтение .time для CRC. Два чтения только что записанных файлов убраны. - .obj пишется в бинарном режиме (std::ios::binary), CRC считается из того же буфера через FileCRC::update_from_content. Бинарный режим гарантирует совпадение байт файла и буфера на всех платформах (в текстовом режиме Windows транслировал бы \n -> \r\n и CRC из буфера разошёлся бы с CRC файла). - Crash_write_timer сериализует таймеры в буфер (rent + n_items записей, как раньше), пишет одним fwrite и считает CRC из буфера. - Обработка ошибок записи прежняя: false + Crash_delete_files. - Всё синхронно в главном потоке -- без гонок и без фоновых тредов. Добавлены раздельные замеры времени записи .obj и .time с логом при total > 10мс -- для снятия разбивки с прода и решения, нужна ли дальнейшая оптимизация (фоновая запись). Refs #3368, #3369
c434a03 to
70d967f
Compare
Проблема
Crash_frac_save_allна каждого игрока делал 4 синхронные файловые операции:.obj(предметы).objобратно для CRC.time(таймеры).timeобратно для CRCДва из них — это перечитывание только что записанного файла исключительно ради вычисления CRC.
Решение (синхронное, без фоновых потоков)
FileCRC::update_from_content(uid, mode, data, len)— вычисляет CRC из готового in-memory буфера, не читая файл. Семантикаneed_save/upsert идентичнаcheck_crc(UPDATE_*)..objпишется в бинарном режиме, CRC берётся из того же буфера. Бинарный режим гарантирует, что байты файла == байты буфера на всех платформах (в текстовом режиме Windows транслировал бы\n→\r\n, и CRC из буфера разошёлся бы с CRC файла).Crash_write_timerсериализует таймеры в буфер (rent+n_itemsзаписей, байт-в-байт как раньше), пишет однимfwrite, CRC из буфера.false+Crash_delete_files..obj/.time(лог приtotal > 10мс) — чтобы снять разбивку с прода и решить, нужна ли дальнейшая оптимизация.Итог: 4 файловые операции → 2 (убраны оба чтения только что записанных файлов). Всё в главном потоке — без гонок, без тредов, без лишних пулов.
История
Первоначальный вариант (фоновая запись через пул потоков) заменён синхронным после ревью: fire-and-forget давал гонки записи одного файла, отсутствие упорядочивания, чтение до завершения записи на загрузке и тихое проглатывание ошибок. Подробный разбор, дерево решений и запасной план (fire-and-forget «по-правильному» — при необходимости) — в комментариях к PR.
Closes #3368