Job Groups
A job group counts jobs submitted together. Submitting returns at once, so the caller carries on with its own work. Later it can check whether the jobs are done, wait for them, or never look at them again.
Work you start and do not wait for straight away is common in a game engine: loading assets, rasterising glyphs, compiling shaders, building meshes.
(make-job-group pool &key error-handler) → job-group
(submit-job group function) → group
(submit-parallel-for group start end function &key chunk-size) → group
(jobs-done-p group) → boolean
(wait-for-jobs group)
Submitting jobs
submit-job queues a function of no arguments to run on a worker, counted in the group:
(defvar *textures* '())
(defvar *textures-lock* (th:make-lock "textures"))
(let ((group (th:make-job-group *pool*)))
(dolist (file '("grass.png" "stone.png" "water.png"))
(let ((file file))
(th:submit-job group (lambda ()
(let ((pixels (decode-texture file)))
(th:with-lock (*textures-lock*)
(push (cons file pixels) *textures*)))))))
;; ... other work ...
(th:wait-for-jobs group))
Bind a fresh variable for each job, as (let ((file file)) ...) does, so that each closure captures its own value rather than the loop's variable.
Both submit-job and submit-parallel-for return the group, so a job started on its own is one expression:
(defvar *castle-loading*
(th:submit-job (th:make-job-group *pool*) (lambda () (setf *castle* (read-file "castle.glb")))))
;; later: (th:jobs-done-p *castle-loading*)
submit-parallel-for queues the chunks of a range, as parallel-for splits them, counted in the group, and returns at once:
(let ((group (th:make-job-group *pool*)))
(th:submit-parallel-for group 0 (length vertices) #'transform-vertices)
(prepare-draw-calls) ; runs while the workers transform
(th:wait-for-jobs group)
(submit-draw-calls))
Jobs and chunks of different kinds may share one group.
Checking and waiting
(jobs-done-p group) is true once every job submitted to the group has finished. It never waits and allocates nothing, so a frame loop can ask it every frame:
(let ((group (th:make-job-group *pool*)))
(th:submit-job group (lambda () (setf *level* (load-level "castle"))))
(loop until (th:jobs-done-p group)
do (draw-loading-screen)))
(wait-for-jobs group) returns when every job has finished.
- From a thread that is not a worker of the group's pool, it blocks.
- From one of the pool's workers, the worker runs the group's queued jobs while it waits, including jobs queued after it began waiting, and no others. So a job may wait for its own jobs and for any other group, as long as the waits form no cycle: a job that waits, through others, for itself never finishes, under any scheduler.
- If a job signalled an error and the group has no error handler, it signals a
job-erroronce the jobs are done. See Errors in Jobs. - With no jobs, or all of them done, it returns at once.
Groups take turns
An idle worker runs one job of the first group that has jobs queued, then moves that group to the back. So a group of many jobs, such as a hundred assets to load, does not hold up a frame's submit-parallel-for queued after it: their jobs alternate. Within a group, jobs start in the order they were submitted.
Reusing a group
Once its jobs have finished, a group can be used again. A reused group allocates nothing for submit-parallel-for, so a group made at start-up suits work started every frame:
(defun frame (group chunk count)
"Run CHUNK over COUNT items in GROUP, made once at start-up, while this
thread animates, then draw."
(th:submit-parallel-for group 0 count chunk)
(animate-sprites) ; on this thread, meanwhile
(th:wait-for-jobs group)
(draw))
Keep the group with what it works for, made with it, as the physics in Recipes keeps its own.
submit-job queues the function you give it, so as with parallel-for, a function made once allocates nothing and a fresh closure does.
Jobs nobody waits for
A group whose jobs nobody waits for still needs somewhere for their errors to go. Give it an error handler, or give the pool a default one, as Errors in Jobs shows:
(th:make-job-group *pool* :error-handler (lambda (condition)
(log-error "Background job failed: ~A" condition)))
Thread rules
Jobs may be submitted to a group from any thread, and any thread may ask jobs-done-p. But only one thread at a time may wait for a group with wait-for-jobs. If several threads need to know, give each its own group, or have one thread wait and tell the others, for example with a semaphore.