HEIC Converter: Actor Model with Akka.net
I’ve been working on an app to bulk convert Apple’s HEIC images into JPG. This is primarily CPU-bound work, so getting the best throughput requires putting the machine’s complete set of cores to work.
The idea is to have a thread scanning a directory for images and then feeding those images to multiple worker threads to
process. A classic “single producer, multiple consumer” algorithm. I could handle this relatively simply with .NET
Channel<T>
, but I wanted some practice with Akka.net and the actor model. Here’s the actor
configuration I came up with:
The Manager
starts a background thread with Task.Run
, which scans the directory to look for HEIC
images. Anytime it finds an image, the scanner thread sends the Manager
an ImageFound
message containing the path. The
Manager
forwards that message to theRouter
, which tracks a number of Converter
actors to actually do the work. When
an image is converted, Converters
send ImageConverted
back up the chain.
The Manager
also holds a reference to a progress Reporter
actor. This actor prints status messages to the UI.
The Manager
Each time the Manager
receives an ImageFound
message, it increments a count of pending images. When an
ImageConverted
message arrives, it decrements that counter. When the scanner thread has exited, and the counter
reaches zero again, it knows the process is complete and begins an orderly system shutdown.
The Reporter
The Reporter
actor tracks how many ImageFound
and ImageConverted
messages it has received. It uses that
information to display a progress bar and report on estimated time to completion.
The Router
The Router
maintains a Queue<ConvertImageCmd>
that holds the list of images pending conversion. It farms those out
to a Converter
whenever one becomes available. It uses the number of CPU cores from Environment.ProcessorCount
to
determine how many Converters
it should create. If there are four cores, it will manage four Converters
and keep
everything busy.
Whenever a Converter
reports ImageConverted
, Router
forwards the message to the Manager
and hands back another
ConvertImageCmd
from its queue. If the queue is empty, it remembers which Converters
are idle and gives them work
the next time a ConvertImageCmd
arrives. In practice, this never happens because scanning is much faster than
converting.
Akka.net has some built-in routers that can round-robin messages between a set of workers. I opted not to use the
built-in router here because I wanted to support a pause feature. When the custom Router
receives a PauseCmd
it
can cease feeding work its routees. This would not be possible with Akka.net’s router.
The Converter
The Converter
actor processes one ConvertImageCmd
at a time and sends back an ImageConverted
message when done. It
converts images using the excellent ImageSharp
and LibHeifSharp projects. I borrowed this part of the code directly
from the samples in LibHeifSharp. I’m not smart enough to solve image conversion!
Actor Model Benefits
I mentioned earlier that I could have used Channel<T>
to solve this problem. But the actor model offers some
compelling features to help keep the code safe and organized. Using channels, I probably would have wound up
implementing a buggy, incomplete version of akka.net anyway!
Error Handling
I have not yet implemented any error handling. One of the strengths of the actor model is supervision. If a
Converter
should encounter an error and crash, its parent, the Router
, will receive a system message and can decide
what to do based on the exception type. If it looks like a transient error, I plan to return the message to its queue
and try it again later. The system will restart the failing Converter
actor and Router
can give it another image to
process. Otherwise, the Router
can report back an ImageFailed
message and move on to the next image. The Reporter
or other user interface layer can decide how to notify the user.
Thread Safety
Akka.net also ensures that actor messages processing is single threaded: an actor only processes one message at a time.
The Router
can use a plain old Queue<T>
instead of ConcurrentQueue<T>
. Whenever an actor increments or decrements
a number, such as the count of pending images, theres no risk of an update getting lost due to a race condition.
Thread Management
I also didn’t have to manage any threads myself. I’m not controlling how many threads are allocated to the system.
The Akka.net scheduler works with the .NET ThreadPool
to process messages and borrows as many
threads as it needs. If there are no messages to process, the scheduler returns the threads to the pool. I carefully
chose the number of live actors to saturate the CPUs, but that’s as far as I had to think about it.