Notatnik techniczny

Async command #1

Async command z limitem wykonań

Przeprowadziłem procedurę przeglądania swojego archiwum kodu w poszukiwaniu fragmentów, które mogę opisać na blogu. I tak oto prezentuję pierwszy fragment – klasa LimitedRepetCommand odpowiedzialna za ograniczenie częstotliwości wykonywania jakiejś akcji.

Co nam daje ten kod?

  • pierwsze żądanie jest wykonywane natychmiast w kontekście wątku właściciela klasy,
  • jeśli w czasie Δ od pierwszego wywołania zostanie zgłoszone kolejne wywołanie, czeka ono do końca okna czasowego Δ. Gdy przyjdzie jego czas, będzie wywołane w kontekście właściciela obiektów, co implikuje, że będzie w stanie manipulować GUI na takich samych prawach, jak wątek tworzący obiekt,
  • gdy podczas trwania czasu oczekiwania Δ pojawi się więcej niż jedno żądanie, wówczas po upływie czasu Δ tylko raz wykonamy kontrolną funkcję,
  • jeśli po upływie czasu Δ od pierwszego wywołania pojawi się nowe żądanie, to jest wykonywane natychmiast
  • jeśli metoda wywoływana wyrzuci wyjątek, zostanie on przechwycony i przekazany do metody obsługi

Przykład zastosowania

  • chcemy ograniczyć częstość odświeżeń widoku aplikacji do 10 sekund,
  • w grze video gracz nie możne strzelać częściej niż co 5 sekund, jeśli jednak po oddanym strzale wyda kilka rozkazów strzału, to wykonany będzie tylko jeden po 5 sekundach.

Zwracam uwagę, że naiwna implementacja (np. taka ->) jest błędna.

public void Execute()
{
    var elapsedTime = (DateTime.Now - _lastExecution).TotalMilliseconds;
    if (elapsedTime < _maxPeriod)
        return;
 
    _lastExecution = DateTime.Now;
 
    try
    {
        _task.Invoke();
    }
    catch (Exception e)
    {
        _errorTask.Invoke(e);
    }
}

Implementacja

Wywołanie zgłoszenia w czasie oczekiwania będzie zignorowane, a my chcemy, aby wykonało się na końcu czasu oczekiwania Δ. Kod spełniający warunek znajduje się poniżej. Wykorzystałem bardzo wygodną klasę SynchronizationContext pozwalającą wykonać daną funkcję w kontekście innego wątku. Zazwyczaj wykorzystuję tę metodę do manipulacji GUI z innego wątku.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
 
namespace Demo.Commands
{
    class LimitedRepetCommand
    {
        public LimitedRepetCommand(Action task, Action<Exception> errorTask, int period)
        {
            _contex = SynchronizationContext.Current;
            _task = task;
            _errorTask = errorTask;
            _maxPeriod = period;
            if (_task == null) throw new ArgumentNullException();
            _timer = new Timer(TimerCb, null, Timeout.Infinite, Timeout.Infinite);
        }
 
 
        public void Execute()
        {
            var elapsedTime = (DateTime.Now - _lastExecution).TotalMilliseconds;
            if (elapsedTime < _maxPeriod)
            {
                var delay = (_maxPeriod - elapsedTime);
                delay = Math.Max(delay, 0);
                _timer.Change((int)delay, Timeout.Infinite);
                return;
            }
            TimerCb(null);
        }
 
        public void Cancel()
        {
            _timer.Change(Timeout.Infinite, Timeout.Infinite);
        }
 
        //=======================================
 
        private void InvokeTask(Action task)
        {
            if (_contex == SynchronizationContext.Current)
            {
                task.Invoke();
                return;
            }
            _contex.Send(b => task.Invoke(), null);
        }
 
        private void TimerCb(object state)
        {
            _lastExecution = DateTime.Now;
            try
            {
                InvokeTask(_task);
            }
            catch (Exception e)
            {
                if (_errorTask != null)
                    InvokeTask(() => _errorTask.Invoke(e));
            }
        }
 
        private readonly SynchronizationContext _contex;
        private readonly Action _task;
        private readonly Action<Exception> _errorTask;
        private readonly int _maxPeriod;
        private readonly Timer _timer;
        private DateTime _lastExecution = DateTime.MinValue;
    }
}

Testy jednostkowe

using System;
using System.Threading;
using System.Windows.Forms;
using Demo.Commands;
using NUnit.Framework;
 
namespace Demo.Tests
{
    [TestFixture]
    public class LimitedRepeatCommandTests
    {
        [Test]
        public void Test1()
        {
            var state = 0;
            var obj = new LimitedRepetCommand(() => ++state, null, 50);
            obj.Execute();
            Assert.AreEqual(1, state);
            obj.Execute();
            Assert.AreEqual(1, state);
            obj.Execute();
            Assert.AreEqual(1, state);
            Thread.Sleep(100);
            Assert.AreEqual(2, state);
            Thread.Sleep(100);
            Assert.AreEqual(2, state);
        }
 
        [Test]
        public void Test2()
        {
            var state = 0;
            var obj = new LimitedRepetCommand(() => ++state, null, 50);
            obj.Execute();
            Assert.AreEqual(1, state);
            obj.Execute();
            obj.Execute();
            obj.Execute();
            obj.Cancel();
            Assert.AreEqual(1, state);
            Thread.Sleep(100);
            Assert.AreEqual(1, state);
        }
 
 
        [Test]
        public void Test3()
        {
            var state = 0;
            var obj = new LimitedRepetCommand(() => ++state, null, 100);
            obj.Execute();
            Assert.AreEqual(1, state);
            obj.Execute();
            obj.Execute();
            obj.Execute();
            Thread.Sleep(70);
            obj.Execute();
            obj.Execute();
            Assert.AreEqual(1, state);
            Thread.Sleep(40);
            Assert.AreEqual(2, state);
            Thread.Sleep(100);
            Assert.AreEqual(2, state);
        }
 
        [Test]
        public void Test4()
        {
            var state = 0;
            var obj = new LimitedRepetCommand(() => ++state, null, 50);
            obj.Execute();
            Assert.AreEqual(1, state);
            obj.Execute();
            obj.Execute();
            obj.Execute();
            Thread.Sleep(40);
            obj.Execute();
            obj.Cancel();
            Assert.AreEqual(1, state);
            Thread.Sleep(100);
            Assert.AreEqual(1, state);
        }
 
        [Test]
        public void TestError1()
        {
            bool errorProcessed = false;
            var tesk = new Action(() => { throw new ApplicationException(); } );
            var errorTask = new Action<Exception>(e => { errorProcessed = true; ; });
            var obj = new LimitedRepetCommand(tesk, errorTask, 50);
            obj.Execute();
            Thread.Sleep(100);
            Assert.AreEqual(errorProcessed, true);
        }
 
    }
}

Odnośniki