Adds the ability to offload most volume loaders to a separate persistent worker, to avoid blocking updates and rendering on the "main thread."
This PR is a child of #176 and includes all its commits. Review that one first! I'll keep this one as a draft until that one merges.
How it works:
Currently, the easiest and most generic way to create a loader is to call createVolumeLoader, which accepts a path and an options object and returns a loader of the appropriate type. The procedure to create a loader on a separate worker is quite similar, but includes a couple extra steps:
Create a new LoadWorker. This accepts arguments for initializing a cache and queue. Internally, LoadWorker spins up a new webworker and tells it to get a cache and queue going.
Before using the LoadWorker, await LoadWorker.onOpen(), which returns a promise that resolves when the above initialization step completes and the worker reports that it's ready.
Call LoadWorker.createLoader. This method has almost identical arguments as createVolumeLoader (it just doesn't accept a cache and queue, since loaders on the worker will use the worker's shared cache and queue instances) and returns a WorkerLoader which acts as a handle to the loader on the worker and can go wherever a normal IVolumeLoader can.
More loaders can be created via this method, but currently only one loader can run on the worker at a time, so any previous WorkerLoader will be invalidated and begin throwing errors when any of its methods are called. (There's no technical reason we can't run multiple loaders on the same worker, in fact we'd get some benefits by automatically sharing cache and queue, we're just not yet likely enough to use that functionality in practice to justify the effort of implementing it.)
Worker implementation keyfiles
src/workers/LoadWorkerHandle.ts includes most of the important stuff: LoadWorker, WorkerLoader, and all of the associated message tracking and bookkeeping.
src/workers/types.ts rigorously lays out the contract for what messages to/from the loader look like
src/workers/VolumeLoadWorker.ts contains the worker code, which is not all that interesting: just a map to various message handlers, most of which just call loader methods, and a bit of error handling glue.
Changes to existing loaders
I had to make some changes to existing volume loaders to support working across the boundary between the main thread and the worker. Most significantly, most loaders are now implemented around a new abstract class called ThreadableVolumeLoader.
The most important methods of IVolumeLoader (the shared interface which all volume loaders implement) want to work directly with Volumes: createVolume builds and returns a Volume, and loadVolumeData accepts a Volume as an argument and modifies it directly. But transferring full Volumes to/from a worker would be impractical for a bunch of reasons. ThreadableVolumeLoader is an abstract class which introduces equivalent methods - createImageInfo and loadRawChannelData, respectively - that work with the ImageInfo and LoadSpec objects that describe a Volume. LoadWorker calls these lower-level methods and transfers the resulting changes to ImageInfo and/or LoadSpec back to the main thread, where direct changes can be made to the relevant Volume. ThreadableVolumeLoader also provides default implementations of createVolume and loadVolumeData around these new abstract methods, allowing a loader on the main thread to still use the old API without duplicated code. This has the added benefit of reducing a bit of boilerplate between loaders, since it turns out that the manipulations they were doing to Volume were all basically the same.
Additionally, JsonImageInfoLoader loaded images by creating an off-screen tag and waiting for it to load. But since DOM nodes can't be created on a worker, I rewrote this section of the code to use the fetch API.
Overall looks really good, I like how clean the change to the ThreadableLoader was! I'm going to wait until all the comments are resolved before approving. 👌
Adds the ability to offload most volume loaders to a separate persistent worker, to avoid blocking updates and rendering on the "main thread."
This PR is a child of #176 and includes all its commits. Review that one first! I'll keep this one as a draft until that one merges.
How it works:
Currently, the easiest and most generic way to create a loader is to call
createVolumeLoader
, which accepts a path and an options object and returns a loader of the appropriate type. The procedure to create a loader on a separate worker is quite similar, but includes a couple extra steps:LoadWorker
. This accepts arguments for initializing a cache and queue. Internally,LoadWorker
spins up a new webworker and tells it to get a cache and queue going.LoadWorker
, awaitLoadWorker.onOpen()
, which returns a promise that resolves when the above initialization step completes and the worker reports that it's ready.LoadWorker.createLoader
. This method has almost identical arguments ascreateVolumeLoader
(it just doesn't accept a cache and queue, since loaders on the worker will use the worker's shared cache and queue instances) and returns aWorkerLoader
which acts as a handle to the loader on the worker and can go wherever a normalIVolumeLoader
can.WorkerLoader
will be invalidated and begin throwing errors when any of its methods are called. (There's no technical reason we can't run multiple loaders on the same worker, in fact we'd get some benefits by automatically sharing cache and queue, we're just not yet likely enough to use that functionality in practice to justify the effort of implementing it.)Worker implementation keyfiles
LoadWorker
,WorkerLoader
, and all of the associated message tracking and bookkeeping.Changes to existing loaders
I had to make some changes to existing volume loaders to support working across the boundary between the main thread and the worker. Most significantly, most loaders are now implemented around a new abstract class called
ThreadableVolumeLoader
.The most important methods of
IVolumeLoader
(the shared interface which all volume loaders implement) want to work directly withVolume
s:createVolume
builds and returns aVolume
, andloadVolumeData
accepts aVolume
as an argument and modifies it directly. But transferring fullVolume
s to/from a worker would be impractical for a bunch of reasons.ThreadableVolumeLoader
is an abstract class which introduces equivalent methods -createImageInfo
andloadRawChannelData
, respectively - that work with theImageInfo
andLoadSpec
objects that describe aVolume
.LoadWorker
calls these lower-level methods and transfers the resulting changes toImageInfo
and/orLoadSpec
back to the main thread, where direct changes can be made to the relevantVolume
.ThreadableVolumeLoader
also provides default implementations ofcreateVolume
andloadVolumeData
around these new abstract methods, allowing a loader on the main thread to still use the old API without duplicated code. This has the added benefit of reducing a bit of boilerplate between loaders, since it turns out that the manipulations they were doing toVolume
were all basically the same.Additionally,
tag and waiting for it to load. But since DOM nodes can't be created on a worker, I rewrote this section of the code to use the fetch API.
JsonImageInfoLoader
loaded images by creating an off-screen