2 Lifetimes and Threads
dxtr edited this page 2026-10-17 04:42:38 +02:00

Lifetimes and Threads

What outlives what

Vulkan objects are made from other objects, and must be destroyed before them:

Object Destroy before Destroyed by
instance destroy-instance, or leaving with-instance
physical-device (never destroyed; valid while its instance is)
surface its instance destroy-surface
device its instance destroy-device, or leaving with-device
queue (goes with its device)
swapchain its device and its surface destroy-swapchain
fence, binary-semaphore their device destroy-fence, destroy-binary-semaphore

cl-vk enforces this order. Each object remembers the live objects made from it, and destroying it while any is alive signals live-children-error, which names them, and destroys nothing. Once an object is destroyed, using it, or destroying it again, signals destroyed-object-error; its readers still work, so it can be printed and inspected.

with-instance and with-device destroy their object when their body is left, normally or by a non-local exit, unless it was destroyed already. with-device waits for the device's work to finish first. Swapchains, surfaces, fences and semaphores have no with- macros: they usually live across frames, so their lifetimes are rarely a dynamic extent.

A typical order of tear-down:

(vk:device-wait-idle device)          ; no work in flight
;; ... destroy what you made through the raw layer: views, pools, pipelines ...
(mapc #'vk:destroy-binary-semaphore semaphores)
(vk:destroy-fence fence)
(vk:destroy-swapchain swapchain)
(vk:destroy-device device)
(vk:destroy-surface surface)
(vk:destroy-instance instance)

Objects made through the raw layer

Objects you create with raw commands, such as command pools, image views and pipelines, are not tracked. Destroy them yourself, before the device or instance they were made from; Vulkan does not check, and destroying a device with live children is undefined behaviour. The validation layer reports it.

The GPU still using an object

cl-vk tracks what objects exist, not what the GPU is doing. Destroying a fence, semaphore, swapchain or device while submitted work still uses it is an error Vulkan does not catch. Wait first: wait-for-fence for the work that uses it, or device-wait-idle.

Surfaces from other libraries

adopt-surface takes ownership of a VkSurfaceKHR created elsewhere: cl-vk destroys it in destroy-surface. Destroy the surface before the window it was made for.

Memory of the idiomatic layer

The objects are Lisp structs. Their Vulkan resources are released by the destroy- functions only, never by the garbage collector: dropping the last reference to an undestroyed device leaks it.

with-structs memory lasts for the extent of its body. allocate-struct memory lasts until free-struct.

Threads

Vulkan's own rules decide what may be shared: some objects are externally synchronized, which means the program must not use them from two threads at once. cl-vk follows those rules and states them in each object's documentation:

Object May be shared between threads Must be one thread at a time
instance using it, from several threads at once creating and destroying the objects made from it; destroying it while another thread uses it is an error
physical-device everything (immutable)
device using it, from several threads at once creating and destroying the objects made from it; destroying it while another thread uses it is an error
queue different queues on different threads submitting to and presenting on one queue
surface querying it creating a swapchain for it, destroying it
swapchain acquiring, presenting, destroying: in practice the render thread owns it
fence waiting for it, from several threads at once resetting and destroying it
binary-semaphore destroying it
debug-message, queue-family, surface-capabilities everything (immutable)
command tables everything (immutable once loaded)
with-structs structs local to the thread running the body
allocate-struct memory passing it between threads writing it

take-debug-messages may be called from any thread: the debug messenger queues messages under a lock, from whatever thread the driver calls it on.

The debug messenger is a foreign callback. It only queues the message and returns; it never signals or unwinds through the driver's frames.

Allocation

Code that runs every frame should not allocate, so that the garbage collector does not pause the render thread. These allocate nothing:

  • acquire-next-image, present
  • wait-for-fence, reset-fence, fence-signaled-p
  • check
  • set-struct, struct-ref of numbers, keywords and pointers, struct-aref
  • every raw command of cl-vk/ffi, called with stack or preallocated memory

with-structs allocates foreign memory for strings and lists given as values, and struct-ref of a string or of flags makes a Lisp string or list. For structs filled every frame, use allocate-struct once and set-struct each frame; see Recipes.

Creating and destroying objects, physical-devices, the surface- queries and take-debug-messages allocate. Call them at setup, on a resize, or at most once a frame.