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.

On left, converter progress bar. On right, HTOP showing full CPU utilization

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.