Ograniczenie pamięci i zasobów w piaskownicy Lua w C++
Podczas integrowania Lua z aplikacjami C++ często pojawia się potrzeba izolowania wykonywania skryptów od głównego systemu. Jest to szczególnie krytyczne w scenariuszach, w których kod zewnętrzny może być złośliwy lub niestabilny — na przykład w silnikach gier, systemach wtyczek lub środowiskach sandbox. Jednym z kluczowych aspektów takiej izolacji jest kontrola zużycia pamięci. W tym artykule omówiono, jak zaimplementować ścisły limit użycia pamięci w Lua za pomocą niestandardowego alokatora zintegrowanego ze stanem interpretera.
Jak Lua zarządza pamięcią
Lua deleguje wszystkie operacje z pamięcią zewnętrznemu alokatorowi. Domyślnie używany jest standardowy realloc/free, ale język pozwala zastąpić go własną implementacją. Osiąga się to poprzez przekazanie wskaźnika do funkcji podczas tworzenia lua_State. Ważne: wszystkie żądania alokacji, zmiany rozmiaru i zwalniania pamięci przechodzą przez ten alokator — w tym pracę kolektora śmieci, ładowanie modułów i tworzenie tabel, ciągów znaków, funkcji itp.
Funkcja alokatora ma następującą sygnaturę:
void *luaAlloc(void *ud, void *ptr, size_t currSize, size_t newSize);
ud— dane użytkownika (zwykle wskaźnik na stan kontrolera pamięci);ptr— wskaźnik na aktualny blok (może byćNULLprzy alokacji nowego);currSize— aktualny rozmiar bloku lub typ obiektu (w Lua 5.2+ przyptr == NULL);newSize— żądany nowy rozmiar (0 oznacza zwolnienie).
To daje pełną kontrolę nad dystrybucją pamięci, co pozwala na jej ewidencję i ograniczenie użycia.
Implementacja ograniczonego alokatora
Do śledzenia objętości zaalokowanej pamięci wprowadza się strukturę stanu:
struct LimitedAllocatorState {
size_t used {};
size_t limit {1 * 1024 * 1024}; // 1 MB domyślnie
bool limitReached {false};
bool overflow {false};
bool isLimitEnabled() { return limit > 0; }
void disableLimit() { limit = 0; }
void resetErrorFlags() { limitReached = overflow = false; }
};
Alokator sprawdza, czy nowe żądanie (used - currSize + newSize) nie przekracza ustalonego limitu. W przypadku przekroczenia zwraca nullptr, co powoduje błąd w Lua (np. not enough memory).
Oto kluczowe elementy implementacji:
- Przy
newSize == 0— zwalnia się pamięć, ausedzmniejsza się ocurrSize; - Przy
ptr == NULL— alokuje się nowy blok,currSizejest ignorowane; - Ochrona przed przepełnieniem całkowitym jest obowiązkowa;
- Stan alokatora musi przetrwać dłużej niż
lua_State.
Przykład funkcji:
void *limitedAlloc(void *ud, void *ptr, size_t currSize, size_t newSize) {
auto *allocState = static_cast<LimitedAllocatorState*>(ud);
if (!allocState) return nullptr;
if (ptr == nullptr) currSize = 0;
if (newSize == 0) {
if (ptr != nullptr) {
allocState->used = (allocState->used >= currSize)
? allocState->used - currSize : 0;
}
std::free(ptr);
return nullptr;
}
const size_t usedBase = (allocState->used >= currSize)
? allocState->used - currSize : 0;
if (newSize > SIZE_MAX - usedBase) {
allocState->overflow = true;
return nullptr;
}
const size_t newUsed = usedBase + newSize;
if (allocState->isLimitEnabled() && newUsed > allocState->limit) {
allocState->limitReached = true;
return nullptr;
}
void *newPtr = std::realloc(ptr, newSize);
if (newPtr) allocState->used = newUsed;
return newPtr;
}
Integracja z runtime'em C++
Podczas używania biblioteki sol2 (popularna biblioteka opakowująca Lua C API) tworzenie stanu z niestandardowym alokatorem wygląda następująco:
sol::state lua(sol::default_at_panic, limitedAlloc, &allocState);
Ważne jest zachowanie kolejności deklaracji pól w klasie: stan alokatora musi być niszczony po sol::state, w przeciwnym razie podczas destrukcji stanu dojdzie do odwołania do już zwolnionej pamięci.
Zaleca się opakowanie całej logiki w klasę LuaRuntime, która:
- Zarządza cyklem życia stanu;
- Dostarcza metody do resetowania błędów i zmiany limitu w locie;
- Umożliwia sprawdzanie, czy quota została przekroczona.
Przykład interfejsu:
class LuaRuntime {
private:
lua::memory::LimitedAllocatorState allocatorState;
sol::state state;
public:
LuaRuntime(size_t memoryLimit)
: allocatorState({.limit = memoryLimit}),
state(sol::default_at_panic, lua::memory::limitedAlloc, &allocatorState) {}
bool hasAllocError() {
return allocatorState.limitReached || allocatorState.overflow;
}
void resetAllocErrors() {
allocatorState.resetErrorFlags();
}
bool setMemoryLimit(size_t limit) {
allocatorState.limit = limit;
return true;
}
};
Ograniczenia i praktyczne zalecenia
Kilka ważnych uwag przy pracy z ograniczonym alokatorem:
- Limit jest wspólny dla całego stanu. Jeśli używasz kilku piaskownic na jednym
lua_State, dzielą one budżet pamięci. Dla ścisłej izolacji — jeden stan na jedną piaskownicę. - Kolektor śmieci również zużywa pamięć. Nawet po usunięciu obiektów w Lua pamięć może tymczasowo rosnąć podczas pracy GC.
- Błędy alokacji nie zawsze powodują panikę. Lua generuje wyjątek, który można przechwycić za pomocą
pcalllub obsługę paniki. - Nie zapominaj resetować flag błędów. Po obsłużeniu błędu
limitReachedpozostaje aktywny do jawnego zresetowania.
Warto też uwzględnić, że niektóre operacje (np. konkatenacja długich ciągów lub głęboka rekurencja) mogą powodować gwałtowne skoki zużycia pamięci. Testowanie z realistycznymi obciążeniami jest obowiązkowe.
Co ważne
- Lua pozwala na pełną kontrolę alokacji pamięci za pomocą niestandardowego alokatora.
- Limit pamięci dotyczy całego stanu, a nie pojedynczych skryptów.
- Przy przekroczeniu limitu alokator zwraca
nullptr, co powoduje błąd w Lua. - Stan alokatora musi przetrwać
lua_Statepod względem czasu życia. - Przepełnienie całkowite i poprawna arytmetyka to obowiązkowe elementy solidnej implementacji.
— Editorial Team
Brak komentarzy.