Как писать скрипты, не приводящие к вылетам и бою сейвов (часть 2)

Материал из Mod Wiki.

Перейти к: навигация, поиск

Начало в первой части статьи



Содержание

Как безопасно использовать коллбэки и таймерные события

В скриптовом движке есть удобный способ реакции на события - использование коллбэков - процедурных вызовов, привязанных к определённым событиям в жизни игрового объекта - получению повреждений, спавну, смерти и т.д. и т.п. Это hit_callback, death_callback из xr_motivator и многие другие... Все моддеры очень широко и совершенно спокойно пользуются ими, совершенно забывая при этом о таком важном факте, что это - обработки реального времени, как и таймерные события. Что это значит? А собственно вот что... Возьмём для примера коллбэк смерти неписей, мою головную боль последнего времени:

xr_motivator.script -
function motivator_binder:death_callback(victim, who)

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

Так вот, обычно, когда в функциях возникает конфликт параметров, бесконечный цикл, попытка индексации nil и т.д., функция вылетает со стандартным логом. Но только не в случае, когда она находится внутри коллбэка. Если функция внутри него, то в случае возникновении в ней любой нештатной ситуации, коллбэк наглухо виснет. Это происходит из-за того, что функции вызываются строго друг за другом, и каждая из них вызывается только тогда, когда её предшественница вернула управление коллбэку. В случае с death_callback опознать такого непися очень просто - в его трупе окажется фонарик, КПК и возможно ещё немного разных "мусорных" вещей, что говорит о том, что обработка его смерти повисла не дойдя даже до спавна лута. В подобной ситуации можно быть на 100% уверенным, что труп этот не был корректно разрегистрирован, и игра всё ещё считает его живым неписем. Кроме того, зависший коллбэк не освобождает стек (а он у Луа-подсистемы единый на все скрипты), что в итоге приводит к вылетам игры с переполнением памяти (вот она, реальная причина этих "родных" вылетов). Но было бы слишком хорошо, если бы всё ограничивалось этим... однако тут всё намного хуже... такие "зависшие" коллбэки, особенно если их произошло несколько подряд, очень серьёзно влияют на работу а-лайфа. В лучшем случае они, забивая, стек, мешают нормально работать схемам логики, в худшем вызывают зависания самого а-лайфа (этот эффект, кстати, производят и сами трупы таких неписей, так как они, как мы помним, не разрегистрировались корректно). Основной итог таких событий - бой сейвов, сделанных после возникновения таких ситуаций. Если повис один коллбэк, то такие сейвы ещё через раз загружаются, если же несколько, и остановилась работа а-лайфа - всё, сейвы бьются наглухо и реанимации не подлежат.

Поэтому, чтобы избежать такого развития событий, каждый раз, когда вы вносите в коллбэк новую функцию - проверьте её самым тщательным образом. Она не должна содержать никаких рекурсивных циклов, в ней обязательно должны быть проверки на валидность обрабатываемых объектов и значений, и обязательно должна быть обработка нештатных ситуаций - т.е. функция должна обязательно, в абсолютно любой ситуации вернуть управление коллбэку, так или иначе. В самом наихудшем случае - делайте как делали разработчики игры - вставляйте принудительный вылет на рабстол функцией abort - она позволяет передавать отладочное сообщение, и это всяко лучше чем незаметный бой сейвов. Если обработка оборвалась в самом начале, а ф-ция обязательно должна вернуть значение - заведите ей "безопасное" возвращаемое значение по-умолчанию, которое она будет выдавать, если всё пошло плохо. И никогда не пренебрегайте пошаговой отладкой коллбэков с выводом в лог, особенно когда пишете схемы логики - это критически важно для стабильности вашего мода.

Основные внутриигровые признаки зависания коллбэков типа hit_callback, death_callback

1. В трупах попадаются фонарики, КПК, разный мусор и общий лут слишком богат.

2. Частые вылеты во время интенсивных боёв с логами типа

   Sheduler tried to update object...
   smart_terrain:1145(1146)
   LUA: out of memory
   любой_модуль_логики:любая_cтрока - stack overflow

3. Частые "родные" вылеты в момент смерти непися или попадания по нему

4. Произвольно бьются сейвы во время сражений, выброса и других насыщенных действиями событий

Использование защищённого кода в LUA

Периодически случаются такие ситуации, когда мы можем получить вылет при проверке аргумента, и не можем его адекватно заизолировать с помощью предварительной проверки на валидность значения. Вот простой пример: когда я отлаживал death_callback неписей, я периодически сталкивался с тем, что обращение к методу smart_terrain_id() при смерти непися иногда вызывало вылет

smart_terrain:1143 "attempt to index a nil value"

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

Вот код, в котором происходил вылет:

function on_death( obj_id )
--	printf( "on_death obj_id=%d", obj_id )

	local sim = alife()

	if sim then
		local obj     = sim:object( obj_id )
		local strn_id = obj:smart_terrain_id()  --- вылет происходит тут

		if strn_id ~= 65535 then
			sim:object( strn_id ).gulag:clear_dead(obj_id)
		end
	end
end

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

pcall (f, arg1, ···)

Вызывает функцию f с указанными через запятую аргументами в защищённом режиме. Это означает, что любая ошибка, даже критическая, внутри вызыванной функции, не передаётся наружу - вызавшей подсистеме. Вместо этого pcall перехватывает ошибку и возвращает код статуса. Первая возвращаемая переменная это сам код, (true или false) и если всё прошло хорошо, он равен true. В этом случае pcall сразу после статуса возвращает все результаты от работы защищённой им функции. Если же в защищённой функции произошла ошибка, то pcall вернёт false и затем сообщение об ошибке. (Обратите внимание, обработка ошибки присходит БЕЗ вылета! Вместо вылета вы получите вполне адекватную строку с ошибкой, которую можно вывести в лог для последующей обработки)

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

Возвращаясь к нашим смарттеррейнам... вот как в итоге я подавил вылет типа smart_terrain:1143 с помощью pcall:

--- эта функция пытается проверить св-во smart_terrain_id объекта. Именно её мы вызовем в защищённом режиме.
function prot_smt_td(obj)
	if IsStalker(obj) or IsMonster(obj) then
		return obj:smart_terrain_id()
	else	
		return 65535
	end
end


function on_death( obj_id )
--	printf( "on_death obj_id=%d", obj_id )
	local sim = alife()
	if sim then
		local obj = sim:object( obj_id )
		if obj then
			local strn_id = 65535  --- предварительно проинитим переменную, на 
						--- случай если у нас prot_smt_td выдаст ошибку
			local result, smt_id = pcall(prot_smt_td,obj)	--- вызываем prot_smt_td в защищённом режиме 
									--- и сразу присваиваем его вывод переменным
			if result then --- если pcall выдало true
				strn_id = smt_id  --- тогда применям полученное значение
			end
			--- если же обработка выдаст ошибку, то strn_id останется неизменным...
			if strn_id ~= 65535 then
				sim:object(strn_id).gulag:clear_dead(obj_id)
			end
		end
	end
end

В других местах это делается совершенно аналогично. Подробнее об этой и многих других функция для контроля кода, незаслуженно ингорируемых большинством моддеров, можно почитать тут: http://lua-users.org/wiki/FinalizedExceptions - на английском правда, но захотите - разберётесь, там всё просто.

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

Скрытые критические проблемы в обработке вылетов игрой

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

В одном из логов нашего бета-тестера я увидел стандартное сообщение о вылете внутри рабочего лога... да-да, то самое которое FATAL ERROR и дальше по тектсу. При этом игра у него НЕ вылетала, это сообщение об ошибке мы обнаружили позже, по случайности. Я заподозрил, что что-то не в порядке, и вставил внутрь этой ф-ции контрольную метку, кидавшую в консоль сообщение, в котором содержался паттерн сообщения об ошибке и само сообщение. Так вот, оказалось, что эта самая ф-ция abort вызывается в игре с завидным постоянством (вы удивитесь насколько часто), когда возникают исключения в схемах логики, звука и т.д., но игра от этого вылетает на рабочий стол максимум только 3 раза из 10 вызовов. Вылет НЕ происходит обычно, когда ф-ции передан паттерн ошибки, а остальные параметры пустые, такое бывает, и частенько. И если не сделать внутри этой ф-ции особой метки для вывода в лог, как сделал это я, её вызовы проходят совершенно незаметно, и игра после критических ошибок продолжается как ни в чём ни бывало. А приводит это вот к чему... Внутри xr_logic в процедуре записи пстора (хранилища логики и флагов) неписей есть вызовы этого самого аборта в случае если на запись в пстор передана некорректная величина. Ну а так как аборт периодически вообще не срабатывает, то часто попадается ситуация, что неписям в пстор пишется полный ахтунг: куски кода из ОЗУ, всякая муть из лтх-ов, куски аллспавна, всё что угодно. Происходит это оттого, что кодер, писавший эту ф-цию (xr_logic.pstor_store(obj, varname, val)), явно и думать не думал что abort может не сработать. У него запись в пстор стояла после провеки, а не внутри неё (very bad idea), и если abort не срабатывал, игра писала в пстор мусор совершенно спокойно и незаметно для игрока. Потом вся эта хрень попадала прямо в сейвы. Вот проблемный код для наглядности:

function pstor_store(obj, varname, val)
	local npc_id = obj:id()
	if db.storage[npc_id].pstor == nil then
		db.storage[npc_id].pstor = {}
	end
	local tv = type(val)
	if not pstor_is_registered_type(tv) then
		abort("xr_logic: pstor_store: not registered type '%s' encountered", tv) --- вот тут мы должны если что вылететь
	end
	db.storage[npc_id].pstor[varname] = val -- а если не вылетели, всё, получим запись в пстор левой мути
end

Разумеется игра этим пстором в итоге давится, и сейвы сделанные после такой милой записи практически лопаются. Результат - "битые" (на самом деле подлежат реанимации) сейвы. Происходит это потому, что игра из сейва грузит неписям псторы сплошным чтением по словам, пока они не закончатся. В случае же если в псторе обнаруживается записанный ранее мусор, то обработка либо вылетает сразу, либо наглухо виснет, пытаясь запихать эдак с миллион слов в пстор особо отличившегося непися. Как вам например непись с размером пстора в 1697451 слова? В результате попытки его обработать игра просто на стадии синхронизации выжрала всю доступную ОЗУ и повисла.

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

Ну и во-вторых модифицировал запись параметров в пстор, просто убрав запись под основание if-else так, чтобы если параметр неверен, он не записывался совсем. Теперь кстати, очень интересно, сохранились ли те же заморочки с ф-цией abort в Чистом Небе, и если да, то останутся ли в Зове Припяти?


Необходимые для стабилизации игры правки в модулях

Эта правка предотвращает запись в пстор если не сработал аборт:

xr_logic.script

Было:

function pstor_store(obj, varname, val)
	local npc_id = obj:id()
	if db.storage[npc_id].pstor == nil then
		db.storage[npc_id].pstor = {}
	end
	local tv = type(val)
	if not pstor_is_registered_type(tv) then
		abort("xr_logic: pstor_store: not registered type '%s' encountered", tv)
	end
	db.storage[npc_id].pstor[varname] = val
end

Стало:

function pstor_store(obj, varname, val)
	if not obj then return end
	local npc_id = obj:id()
	if db.storage[npc_id].pstor == nil then
		db.storage[npc_id].pstor = {}
	end
	local tv = type(val)
	if not pstor_is_registered_type(tv) then
		dgblog("xr_logic: pstor_store: not registered type encountered - write in pstor_store cancelled")
		-- abort убран, так как один хрен не работает. Пусть тогда хотя бы в лог что-то валится.
	else
		db.storage[npc_id].pstor[varname] = val
		-- вот так и только так. Если значение не валидно, ничего не происходит.
	end	
end

А эта правка выкинет из пстора весь мусор при загрузке сейва, если он как-то в него попал

Было:

function pstor_load_all(obj, reader)
	local npc_id = obj:id()
	local pstor = db.storage[npc_id].pstor
	if not pstor then
		pstor = {}
		db.storage[npc_id].pstor = pstor
	end
	local ctr = reader:r_u32()
	for i = 1, ctr do
		local varname = reader:r_stringZ()
		local tn = reader:r_u8()
		if tn == pstor_number then
			pstor[varname] = reader:r_float()
		elseif tn == pstor_string then
			pstor[varname] = reader:r_stringZ()
		elseif tn == pstor_boolean then
			pstor[varname] = reader:r_bool()
		else
			abort("xr_logic: pstor_load_all: not registered type N %d encountered", tn)
		end
		printf("_bp: pstor_load_all: loaded [%s]='%s'", varname, utils.to_str(pstor[varname]))
	end
end

Стало:

function pstor_load_all(obj, reader)
	local npc_id = obj:id()
	local pstor = db.storage[npc_id].pstor
	if not pstor then
		pstor = {}
		db.storage[npc_id].pstor = pstor
	end
	local ctr = reader:r_u32()
	if tonumber(ctr) > 20 and tostring(obj:name()) ~= "single_player" and npc_id ~= db.actor:id() then
		-- максимум 20 итераций - это число ещё уточняется, возможно понадобится больше
                -- если у вас в пстор что-то свое пишется, ориентируйтесь на свои значения
		-- и обязательно убираем из проверки актора - у него очень толстый пстор, и к тому же
                -- если уж поврежденным будет его пстор, то тут точно уже ничего не поможет
		dgblog("ОБНАРУЖЕН ОБЪЕКТ С ПОВРЕЖДЕННЫМ PSTOR: "..tostring(obj:name())..
" БУДЕТ ПРОИЗВЕДЕНА ПОПЫТКА ВОССТАНОВЛЕНИЯ")
		ctr = 20 
	end
	for i = 1, ctr do
		local varname = reader:r_stringZ()
		local tn = reader:r_u8()
		if tn == pstor_number then
			pstor[varname] = reader:r_float()
		elseif tn == pstor_string then
			pstor[varname] = reader:r_stringZ()
		elseif tn == pstor_boolean then
			pstor[varname] = reader:r_bool()
		else
			-- не надо пытаться вылетать - просто не пишем поврежденные данные
			-- при этом обязательно удалять саму переменную - в результате записи
 			-- мусора в пстор одно только ее название может повесить загрузку
			pstor[varname] = nil
		end
	end
end

Эти примеры приведены для чистой игры. Единственное в чем не уверен пока, это в том, что максимум полезного размера - 20 слов. Возможно нужно выделить больше, это надо будет проверить ещё экспериментальным путем...

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

Я это сделал вот так:

_g.script

-- Крешнуть игру (после вывода сообщения об ошибке в лог)
function abort(fmt, msg)
	local message = tostring(msg)
	dbglog("ERROR PATTERN: "..tostring(fmt))
	dbglog("ERROR REASON: "..message)
	local reason = string.format(fmt, message)
	assert("ERROR: " .. reason)
	printf("ERROR: " .. reason)
	dbglog("%s", reason)
	printf("%s")
end

--KamikaZze (OGSE Team) 11:04, 3 сентября 2009 (UTC)

Авторы

Статья создана: Kamikazze

Личные инструменты