An object pool reuses a bounded set of resources instead of constructing a fresh resource for every operation. It is useful when acquisition is expensive and reset is safe. It is counterproductive when ordinary allocation is already cheap or reused objects carry stale state between borrowers.
A pool’s important API is the lease: who may use the resource, when it returns, and what happens after return. An array of available objects alone does not establish those rules.
The Swift 5.7 demonstration uses scratch buffers under a serial-access contract. It rejects acquisition when capacity is exhausted and resets buffers before reuse. It is not a concurrent waiting channel or a production memory allocator.
Hide the pooled resource behind a lease
enum PoolFailure: Error { case exhausted, returned }
fileprivate final class ScratchBuffer {
var values: [Int] = []
func reset() { values.removeAll(keepingCapacity: true) }
}
final class BufferLease {
private var buffer: ScratchBuffer?
private var returnAction: ((ScratchBuffer) -> Void)?
fileprivate init(buffer: ScratchBuffer, onReturn: @escaping (ScratchBuffer) -> Void) {
self.buffer = buffer
returnAction = onReturn
}
func append(_ value: Int) throws {
guard let buffer = buffer else { throw PoolFailure.returned }
buffer.values.append(value)
}
func snapshot() throws -> [Int] {
guard let buffer = buffer else { throw PoolFailure.returned }
return buffer.values
}
func close() {
let resource = buffer
let action = returnAction
buffer = nil
returnAction = nil
if let resource = resource { action?(resource) }
}
deinit { close() }
}
final class BufferPool {
private let capacity: Int
private var created = 0
private var available: [ScratchBuffer] = []
var availableCount: Int { available.count }
init(capacity: Int) { precondition(capacity > 0); self.capacity = capacity }
func borrow() -> BufferLease? {
let buffer: ScratchBuffer
if let reused = available.popLast() { buffer = reused }
else if created < capacity { buffer = ScratchBuffer(); created += 1 }
else { return nil }
return BufferLease(buffer: buffer) { [weak self] resource in
resource.reset()
self?.available.append(resource)
}
}
func withLease<T>(_ body: (BufferLease) throws -> T) throws -> T {
guard let lease = borrow() else { throw PoolFailure.exhausted }
defer { lease.close() }
return try body(lease)
}
}
close clears the lease before invoking its return action. Repeated or reentrant close therefore cannot insert the same resource into the available list twice.
The return closure weakly references the pool, so a retained lease does not keep an abandoned pool alive. The lease itself keeps its resource alive until close. If the pool has gone away, closing releases that resource instead of returning it to a dead owner.
Test exhaustion, reset, and use after return
let pool = BufferPool(capacity: 1)
let first = pool.borrow()!
try first.append(7)
assert(pool.borrow() == nil)
first.close()
first.close()
assert(pool.availableCount == 1)
let second = pool.borrow()!
let clean = try second.snapshot()
assert(clean.isEmpty)
do {
try first.append(9)
assertionFailure("A returned lease must not access a reused resource")
} catch PoolFailure.returned {}
second.close()
The stale lease still exists as an object, but its resource reference is gone. It cannot mutate a buffer now borrowed by another caller.
A raw-resource-returning API would require callers never to retain an alias after returning it. A restricted lease interface makes that rule easier to maintain and test, though the exact protection depends on which operations it exposes.
Structured borrowing handles failures
enum WorkFailure: Error { case stopped }
do {
let _: Int = try pool.withLease { lease in
try lease.append(10)
throw WorkFailure.stopped
}
} catch WorkFailure.stopped {}
assert(pool.availableCount == 1)
let nextCount = try pool.withLease { lease in try lease.snapshot().count }
assert(nextCount == 0)
defer returns the resource on success and failure. Deinitialization is a fallback, not the only reliable signal that work has ended.
If a caller returns the lease object from withLease, it receives an already closed lease. The wrapper owns the borrowing interval. A resource API should not imply a returned alias remains valid beyond that interval.
Capacity bounds objects, not necessarily bytes
For capacity K, the serial implementation maintains:
Each scratch array can still grow without a byte limit. Retaining capacity on reset reuses storage but can retain the largest allocation each buffer ever reached. A pool of four buffers is not automatically a small-memory pool.
For image or decoding resources, define maximum payload size, trimming policy, and failure behavior. Reset must clear all borrower-specific state, including delegates, credentials, and cancellation tokens where those exist.
sequenceDiagram
accTitle: A lease returns a reset resource exactly once
participant Caller
participant Pool
participant Lease
Caller->>Pool: borrow
Pool-->>Caller: lease
Caller->>Lease: use resource
Caller->>Lease: close
Lease->>Pool: reset and return
Caller->>Lease: close again
Note over Lease: No second return
Add concurrency only with a complete policy
Every method here requires serialized access. Protecting only the available array would be insufficient if lease close can race with resource use. A concurrent design needs an isolation boundary covering lease state and pool bookkeeping.
An actor can serialize acquisition and return, but awaiting a lease requires a waiting queue, cancellation, and fairness decisions. Performing an entire synchronous operation inside the actor may serialize all useful work and eliminate the expected parallel benefit.
A nonblocking pool can report exhaustion. A suspending pool can wait. An unbounded fallback can construct more resources but abandons the capacity guarantee. Choose the behavior required by the feature rather than hiding it in a default branch.
Pool only measured acquisition costs
Benchmark resource creation, reset, and reuse under representative payloads. Ordinary small Swift objects often do not benefit enough to justify pool lifetime complexity.
A pooled database connection or decoder may carry external state that reset cannot cheaply or safely restore. Test failed work and cancellation before returning such resources; some failures should discard and replace a resource rather than reuse it.
What the capacity bound does and does not bound
A pool with capacity four limits the number of scratch buffers it creates. It does not automatically limit their total allocated bytes. One borrower can append a very large payload, and a reset that retains capacity can leave that allocation resident for later borrowers.
For a memory budget, define both a lease count and a buffer-size policy. Options include rejecting an append that exceeds a maximum, dropping oversized buffers on return, or maintaining separate size classes. The correct choice depends on observed workload sizes and the cost of rebuilding storage.
Measure the entire acquire-use-return cycle. A pool adds bookkeeping, reset work, and potentially waiting. If creating the underlying resource is cheap, these costs can exceed the allocation work saved. Benchmarking only acquisition from an already warm pool omits precisely the costs that often determine whether pooling helps.
Waiting turns the pool into a scheduler
This implementation rejects an acquisition when all resources are leased. An asynchronous variant could suspend borrowers instead, but that requires a queue and a defined fairness policy. A cancellation must remove a waiting borrower without losing the resource that might be returned at the same time.
Decide what happens when the pool closes while borrowers are waiting or working. Do waiting calls fail immediately? Can active borrowers finish? Are returned resources destroyed rather than made available again? These are observable lifecycle rules, so represent them explicitly rather than hiding them inside a semaphore count.
The serialized assumption matters here. Making the pool an actor is a possible starting point, but moving a mutable buffer outside that actor can still create unsynchronized access. An async API should either keep resource use isolated or transfer exclusive ownership through a contract that actually enforces it.
Reset is part of correctness
Resetting a pooled resource is not merely an optimization. A buffer that still contains a previous borrower’s account data can leak information to the next borrower. A connection that still has an open transaction can apply the next operation in the wrong context.
List every piece of state that must be cleared before a resource reenters the available set. If reset can fail, discard that resource and adjust the created-resource accounting; do not publish it as healthy. Repeated close() calls should remain harmless even on that failure path.
The lease makes the return boundary visible, but callers still have to avoid storing sensitive snapshots longer than needed. Returning the buffer does not revoke copies of data that were intentionally handed out.
Exercise: discard a damaged resource
Add a lease operation that marks its resource unusable. On close, discard it and decrement the created count so a later borrow can construct a replacement. Verify a failed borrower cannot return a damaged buffer to another caller and capacity remains consistent.
Return to the Design Patterns roadmap.