Object pooling class

I just wanted to see if anyone may see any edge cases with this. I tested it a bit, but I may have missed something. I also am not sure if I am using weak table correctly. I assume that by using weak tables, if the part is somehow destroyed somewhere else, instead of staying in the pool, it will be removed.

--!strict

export type Pool<T, A...> = {
	acquire: (A...) -> T,
	release: (T) -> (),
	prefill: (number) -> (),
	clear: () -> (),
	idle: () -> number,
	isCheckedOut: (T) -> boolean,
}

type Config<T, A...> = {
	name: string?,
	create: () -> T,
	onAcquire: ((T, A...) -> ())?,
	onRelease: ((T) -> ())?,
	max: number?,
	destroy: ((T) -> ())?,
}

local Pool = {}

function Pool.new<T, A...>(config: Config<T, A...>): Pool<T, A...>
	local name = config.name or "unnamed"
	local create = config.create
	local max = config.max or 32

	local onAcquire: (T, A...) -> () = config.onAcquire or function() end
	local onRelease: (T) -> () = config.onRelease or function() end

	local destroy: (T) -> () = config.destroy
		or function(item: T)
			if typeof(item) == "Instance" then
				(item :: any):Destroy()
			end
		end

	local idle: { T } = {}
	local inUse: { [any]: boolean } = setmetatable({}, { __mode = "k" }) :: any
	local warnedOverflow = false

	local pool = {}

	function pool.acquire(...: A...): T
		local item: T? = table.remove(idle)
		if item == nil then
			item = create()
		end
		local value = item :: T

		inUse[value] = true
		onAcquire(value, ...)
		return value
	end

	function pool.release(item: T)
		if not inUse[item] then
			warn(`[Pool:{name}] release() called on an item that is not checked out (double release?)`)
			return
		end

		inUse[item] = nil
		onRelease(item)

		if #idle >= max then
			if not warnedOverflow then
				warnedOverflow = true
				warn(`[Pool:{name}] idle count hit max ({max}); objects are being destroyed instead of reused. Raise max.`)
			end
			destroy(item)
			return
		end

		table.insert(idle, item)
	end

	function pool.prefill(count: number)
		for _ = 1, count do
			local item: T = create()
			onRelease(item)
			table.insert(idle, item)
		end
	end

	function pool.clear()
		for _, item in idle do
			destroy(item)
		end
		table.clear(idle)
	end

	function pool.idle(): number
		return #idle
	end

	function pool.isCheckedOut(item: T): boolean
		return inUse[item] == true
	end

	return pool
end

return Pool

I had AI make a little documentation so you can test it out:

const Pool = require(path.to.Pool)

local dropPool = Pool.new({
	name = "Drops",
	create = function()
		return dropTemplate:Clone()
	end,
	onAcquire = function(part: BasePart, cframe: CFrame, parent: Instance)
		part.CFrame = cframe
		part.Parent = parent
	end,
	onRelease = function(part: BasePart)
		part.Parent = nil
		part.AssemblyLinearVelocity = Vector3.zero
		part.CanCollide = true
	end,
	max = 64, -- please make sure this is set to a good amount. If you don't it will delete the overflow
})

-- get one
local drop = dropPool.acquire(attachment.WorldCFrame, dropsFolder)

-- ...later, when done with it (e.g. on Touched, or pickup)...
dropPool.release(drop)

dropPool.prefill(20)      -- pre-warm 20 idle objects up front (e.g. on server start) so the
                          -- first wave of acquires doesn't pay `create()`'s cost mid-gameplay

dropPool.idle()           -- how many objects are currently sitting idle, ready to hand out

dropPool.clear()          -- destroys every idle object (checked-out ones are untouched) —
                          -- e.g. round reset, or tearing the whole system down

dropPool.isCheckedOut(drop) -- true if `drop` is currently out of the pool
                            -- calls out that `.Parent` is unreliable when something like
                            -- `Touched` can fire more than once for the same object.
1 Like

holy reinventing the wheel
Why do you even need OOP here?
It just ruins all optimization of pooling with deoptimization of OOP in luau :skull:
You can just make it much more clear + scalable and optimized
Here is example how i do pooling in luau:

If you are using live roblox instances then replace buffer logic for array one

2 Likes

Thanks for the suggestions. I am using live instances, so if I understand what you are doing this implementation would be good?


--!strict

export type Pool<T> = {
	acquire: () -> T,
	release: (T) -> (),
	prefill: (number) -> (),
	clear: () -> (),
}

type Config<T> = {
	create: () -> T,
	reset: (T) -> (), 
	destroy: (T) -> (),
	max: number?,
}

local function new<T>(config: Config<T>): Pool<T>
	local create = config.create
	local reset = config.reset
	local destroy = config.destroy
	local max = config.max or 64

	local idle: { T } = {}
	local active: { [T]: boolean } = {}

	local pool = {}

	function pool.acquire(): T
		local item = table.remove(idle)
		if item == nil then
			item = create()
		end
		active[item :: T] = true
		return item :: T
	end

	function pool.release(item: T)
		if not active[item] then
			return
		end
		active[item] = nil
		reset(item)

		if #idle >= max then
			destroy(item)
			return
		end
		table.insert(idle, item)
	end

	function pool.prefill(count: number)
		for _ = 1, count do
			table.insert(idle, create())
		end
	end

	function pool.clear()
		for _, item in idle do
			destroy(item)
		end
		table.clear(idle)
	end

	return pool
end

return { new = new }

Also, if the part were to be somehow destroyed without it being released via my own code, then wouldn’t that cause a memory leak? I was just trying to wing it with weak keys since it was a solution (to my knowledge of how they work). I could have each object be connected to a destroying event, but that is potentially a lot of events. Do you have a solution to this?

This is definitely much better than the previous version.
One thing I would change is separating the allocation policy from the allocator itself.
I would avoid wrapping the whole thing inside closures or making it look like OOP. A pool is fundamentally just two pieces of state:

  • allocated objects
  • free slots

That’s very similar to how memory allocators work. They don’t shift the entire memory block every allocation - they simply track which slots are free.

For example:

local allocate = 32
local stack = table.create(allocate)
local free = table.create(allocate,true)


--fill
for i=1,allocate do
	local claim = next(free)
	free[claim]=nil--claimed
	stack[claim]=Instance.new("Part")
end
warn(stack,free)

--free
for unclaim=1,allocate do
	stack[unclaim]:Destroy()
	stack[unclaim]=nil
	free[unclaim]=true
end

warn(stack,free)

I would also avoid putting a max check inside the pool itself. That is application-specific policy, not allocation logic.

If one system wants a maximum of 32 objects and another wants 512, I’d rather have the caller decide whether to allocate or destroy instead of making every claim/free go through that branch.

This keeps the allocator self-contained and reusable.

Sometimes the simplest solution really is just a couple of tables and a few lines of code.

2 Likes

Ah, I understand. I was basically had a middleman for no reason. I really appreciate you explaining this to me.

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.