File: System\Drawing\ImageAnimator.cs
Web Access
Project: src\src\System.Drawing.Common\src\System.Drawing.Common.csproj (System.Drawing.Common)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Drawing.Imaging;
using System.Threading;
 
namespace System.Drawing;
 
/// <summary>
///     Animates one or more images that have time-based frames.
///     See the ImageInfo.cs file for the helper nested ImageInfo class.
///
///     A common pattern for using this class is as follows (See PictureBox control):
///     1. The WinForms app (user's code) calls ImageAnimator.Animate() from the main thread.
///     2. Animate() spawns the animating (worker) thread in the background, which will update the image
///        frames and raise the OnFrameChanged event, which handler will be executed in the main thread.
///     3. The main thread triggers a paint event (Invalidate()) from the OnFrameChanged handler.
///     4. From the OnPaint event, the main thread calls ImageAnimator.UpdateFrames() and then paints the
///        image (updated frame).
///     5. The main thread calls ImageAnimator.StopAnimate() when needed. This does not kill the worker thread.
///
///     Comment on locking the image ref:
///     We need to synchronize access to sections of code that modify the image(s), but we don't want to block
///     animation of one image when modifying a different one; for this, we use the image ref for locking the
///     critical section (lock(image)).
///
///     This class is safe for multi-threading but Image is not; multithreaded applications must use a critical
///     section lock using the image ref the image access is not from the same thread that executes ImageAnimator
///     code. If the user code locks on the image ref forever a deadlock will happen preventing the animation
///     from occurring.
/// </summary>
public sealed partial class ImageAnimator
{
    // We use a timer to apply an animation tick speeds of something a bit shorter than 50ms
    // such that if the requested frame rate is about 20 frames per second, we will rarely skip
    // a frame entirely. Sometimes we'll show a few more frames if available, but we will never
    // show more than 25 frames a second and that's OK.
    internal const int AnimationDelayMS = 40;
 
    /// <summary>
    ///  A list of images to be animated.
    /// </summary>
    private static List<ImageInfo>? s_imageInfoList;
 
    /// <summary>
    ///  A variable to flag when an image or images need to be updated due to the selection of a new frame
    ///  in an image. We don't need to synchronize access to this variable, in the case it is true we don't
    ///  do anything, otherwise the worse case is where a thread attempts to update the image's frame after
    ///  another one did which is harmless.
    /// </summary>
    private static bool s_anyFrameDirty;
 
    /// <summary>
    ///  The thread used for animating the images.
    /// </summary>
    private static Thread? s_animationThread;
 
    /// <summary>
    ///  Lock that allows either concurrent read-access to the images list for multiple threads, or write-
    ///  access to it for a single thread. Observe that synchronization access to image objects are done
    ///  with critical sections (lock).
    /// </summary>
    private static readonly ReaderWriterLock s_rwImgListLock = new();
 
    /// <summary>
    ///  Flag to avoid a deadlock when waiting on a write-lock and an attempt to acquire a read-lock is
    ///  made in the same thread. If RWLock is currently owned by another thread, the current thread is going to wait on an
    ///  event using CoWaitForMultipleHandles while pumps message.
    ///
    ///  The comment above refers to the COM STA message pump, not to be confused with the UI message pump.
    ///  However, the effect is the same, the COM message pump will pump messages and dispatch them to the
    ///  window while waiting on the writer lock; this has the potential of creating a reentrancy situation
    ///  that if during the message processing a wait on a reader lock is originated the thread will be block
    ///  on itself.
    ///
    ///  While processing STA message, the thread may call back into managed code. We do this because
    ///  we can not block finalizer thread. Finalizer thread may need to release STA objects on this thread. If
    ///  the current thread does not pump message, finalizer thread is blocked, and AD  unload is blocked while
    ///  waiting for finalizer thread. RWLock is a fair lock. If a thread waits for a writer lock, then it needs
    ///  a reader lock while pumping message, the thread is blocked forever.
    ///  This TLS variable is used to flag the above situation and avoid the deadlock, it is ThreadStatic so each
    ///  thread calling into ImageAnimator is guarded against this problem.
    /// </summary>
    [ThreadStatic]
    private static int t_threadWriterLockWaitCount;
 
    /// <summary>
    ///  Prevent instantiation of this class.
    /// </summary>
    private ImageAnimator()
    {
    }
 
    /// <summary>
    ///  Advances the frame in the specified image. The new frame is drawn the next time the image is rendered.
    /// </summary>
    public static void UpdateFrames(Image? image)
    {
        if (image is null || s_imageInfoList is null)
        {
            return;
        }
 
        if (t_threadWriterLockWaitCount > 0)
        {
            // Cannot acquire reader lock - frame update will be missed.
            return;
        }
 
        // If the current thread already has the writer lock, no reader lock is acquired. Instead, the lock count on
        // the writer lock is incremented. It already has a reader lock, the locks ref count will be incremented
        // w/o placing the request at the end of the reader queue.
 
        s_rwImgListLock.AcquireReaderLock(Timeout.Infinite);
 
        try
        {
            bool foundDirty = false;
            bool foundImage = false;
 
            foreach (ImageInfo imageInfo in s_imageInfoList)
            {
                if (imageInfo.Image == image)
                {
                    if (imageInfo.FrameDirty)
                    {
                        // See comment in the class header about locking the image ref.
                        lock (imageInfo.Image)
                        {
                            imageInfo.UpdateFrame();
                        }
                    }
 
                    foundImage = true;
                }
                else if (imageInfo.FrameDirty)
                {
                    foundDirty = true;
                }
 
                if (foundDirty && foundImage)
                {
                    break;
                }
            }
 
            s_anyFrameDirty = foundDirty;
        }
        finally
        {
            s_rwImgListLock.ReleaseReaderLock();
        }
    }
 
    /// <summary>
    ///  Advances the frame in all images currently being animated. The new frame is drawn the next time the image is rendered.
    /// </summary>
    public static void UpdateFrames()
    {
        if (!s_anyFrameDirty || s_imageInfoList is null)
        {
            return;
        }
 
        if (t_threadWriterLockWaitCount > 0)
        {
            // Cannot acquire reader lock at this time, frames update will be missed.
            return;
        }
 
        s_rwImgListLock.AcquireReaderLock(Timeout.Infinite);
 
        try
        {
            foreach (ImageInfo imageInfo in s_imageInfoList)
            {
                // See comment in the class header about locking the image ref.
                lock (imageInfo.Image)
                {
                    imageInfo.UpdateFrame();
                }
            }
 
            s_anyFrameDirty = false;
        }
        finally
        {
            s_rwImgListLock.ReleaseReaderLock();
        }
    }
 
    /// <summary>
    ///  Adds an image to the image manager. If the image does not support animation this method does nothing.
    ///  This method creates the image list and spawns the animation thread the first time it is called.
    /// </summary>
    public static void Animate(Image image, EventHandler onFrameChangedHandler)
    {
        if (image is null)
        {
            return;
        }
 
        ImageInfo? imageInfo = null;
 
        // See comment in the class header about locking the image ref.
        lock (image)
        {
            // could we avoid creating an ImageInfo object if FrameCount == 1 ?
            imageInfo = new ImageInfo(image);
        }
 
        // If the image is already animating, stop animating it
        StopAnimate(image, onFrameChangedHandler);
 
        // Acquire a writer lock to modify the image info list. If the thread has a reader lock we need to upgrade
        // it to a writer lock; acquiring a reader lock in this case would block the thread on itself.
        // If the thread already has a writer lock its ref count will be incremented w/o placing the request in the
        // writer queue. See ReaderWriterLock.AcquireWriterLock method in the MSDN.
 
        bool readerLockHeld = s_rwImgListLock.IsReaderLockHeld;
        LockCookie lockDowngradeCookie = default;
 
        t_threadWriterLockWaitCount++;
 
        try
        {
            if (readerLockHeld)
            {
                lockDowngradeCookie = s_rwImgListLock.UpgradeToWriterLock(Timeout.Infinite);
            }
            else
            {
                s_rwImgListLock.AcquireWriterLock(Timeout.Infinite);
            }
        }
        finally
        {
            t_threadWriterLockWaitCount--;
            Debug.Assert(t_threadWriterLockWaitCount >= 0, "threadWriterLockWaitCount less than zero.");
        }
 
        try
        {
            if (!imageInfo.Animated)
            {
                return;
            }
 
            // Construct the image array
            s_imageInfoList ??= [];
 
            // Add the new image
            imageInfo.FrameChangedHandler = onFrameChangedHandler;
            s_imageInfoList.Add(imageInfo);
 
            // Construct a new timer thread if we haven't already
            if (s_animationThread is null)
            {
                s_animationThread = new Thread(new ThreadStart(AnimateImages))
                {
                    Name = nameof(ImageAnimator),
                    IsBackground = true
                };
 
                s_animationThread.Start();
            }
        }
        finally
        {
            if (readerLockHeld)
            {
                s_rwImgListLock.DowngradeFromWriterLock(ref lockDowngradeCookie);
            }
            else
            {
                s_rwImgListLock.ReleaseWriterLock();
            }
        }
    }
 
    /// <summary>
    ///  Whether or not the image has multiple time-based frames.
    /// </summary>
    public static bool CanAnimate([NotNullWhen(true)] Image? image)
    {
        if (image is null)
        {
            return false;
        }
 
        // See comment in the class header about locking the image ref.
        lock (image)
        {
            Guid[] dimensions = image.FrameDimensionsList;
 
            foreach (Guid guid in dimensions)
            {
                FrameDimension dimension = new(guid);
                if (dimension.Equals(FrameDimension.Time))
                {
                    return image.GetFrameCount(FrameDimension.Time) > 1;
                }
            }
        }
 
        return false;
    }
 
    /// <summary>
    ///  Removes an image from the image manager so it is no longer animated.
    /// </summary>
    public static void StopAnimate(Image image, EventHandler onFrameChangedHandler)
    {
        // Make sure we have a list of images
        if (image is null || s_imageInfoList is null)
        {
            return;
        }
 
        // Acquire a writer lock to modify the image info list - See comments on Animate() about this locking.
 
        bool readerLockHeld = s_rwImgListLock.IsReaderLockHeld;
        LockCookie lockDowngradeCookie = default;
 
        t_threadWriterLockWaitCount++;
 
        try
        {
            if (readerLockHeld)
            {
                lockDowngradeCookie = s_rwImgListLock.UpgradeToWriterLock(Timeout.Infinite);
            }
            else
            {
                s_rwImgListLock.AcquireWriterLock(Timeout.Infinite);
            }
        }
        finally
        {
            t_threadWriterLockWaitCount--;
            Debug.Assert(t_threadWriterLockWaitCount >= 0, "threadWriterLockWaitCount less than zero.");
        }
 
        try
        {
            // Find the corresponding reference and remove it
            for (int i = 0; i < s_imageInfoList.Count; i++)
            {
                ImageInfo imageInfo = s_imageInfoList[i];
 
                if (image == imageInfo.Image)
                {
                    if (onFrameChangedHandler == imageInfo.FrameChangedHandler
                        || (onFrameChangedHandler is not null && onFrameChangedHandler.Equals(imageInfo.FrameChangedHandler)))
                    {
                        s_imageInfoList.Remove(imageInfo);
                    }
 
                    break;
                }
            }
        }
        finally
        {
            if (readerLockHeld)
            {
                s_rwImgListLock.DowngradeFromWriterLock(ref lockDowngradeCookie);
            }
            else
            {
                s_rwImgListLock.ReleaseWriterLock();
            }
        }
    }
 
    /// <summary>
    ///  Worker thread procedure which implements the main animation loop.
    ///
    ///  NOTE: This is the ONLY code the worker thread executes, keeping it in one method helps better understand
    ///  any synchronization issues.
    ///
    ///  WARNING: Also, this is the only place where ImageInfo objects (not the contained image object) are modified,
    ///  so no access synchronization is required to modify them.
    /// </summary>
    private static void AnimateImages()
    {
        Debug.Assert(s_imageInfoList is not null, "Null images list");
 
        Stopwatch stopwatch = Stopwatch.StartNew();
 
        while (true)
        {
            Thread.Sleep(AnimationDelayMS);
 
            // Because Thread.Sleep is not accurate, capture how much time has actually elapsed during the animation
            long timeElapsed = stopwatch.ElapsedMilliseconds;
            stopwatch.Restart();
 
            // Acquire reader-lock to access imageInfoList, elements in the list can be modified w/o needing a writer-lock.
            // Observe that we don't need to check if the thread is waiting or a writer lock here since the thread this
            // method runs in never acquires a writer lock.
            s_rwImgListLock.AcquireReaderLock(Timeout.Infinite);
            try
            {
                for (int i = 0; i < s_imageInfoList.Count; i++)
                {
                    ImageInfo imageInfo = s_imageInfoList[i];
 
                    if (imageInfo.Animated)
                    {
                        imageInfo.AdvanceAnimationBy(timeElapsed);
 
                        if (imageInfo.FrameDirty)
                        {
                            s_anyFrameDirty = true;
                        }
                    }
                }
            }
            finally
            {
                s_rwImgListLock.ReleaseReaderLock();
            }
        }
    }
}