Dans le cadre du développement d’un petit serveur de Streaming Audio (type Radio), j’ai eu besoin d’envoyer périodiquement des paquets de données audio sur une très longue période de temps.

Le besoin initial

Pour cela, initialement, j’ai mis en place un System.Timers.Timer.

Je me suis alors demandé si ce Timer était suffisamment précis pour ne pas diverger dans le temps.

J’ai donc testé en affichant le temps d’exécution du Timer depuis son démarrage.

Test

System.Timers.Timer timer = new System.Timers.Timer();
timer.Interval = 1000;
timer.Elapsed += Timer_Elapsed;
timer.Start();
watch = System.Diagnostics.Stopwatch.StartNew();
watch.Start();
private static void Timer_Elapsed(object sender, ElapsedEventArgs e)
{
    Console.WriteLine(watch.ElapsedMilliseconds + " ms");
}

1007 ms
2003 ms
3004 ms
4011 ms
5012 ms

57052 ms
58052 ms
59054 ms
60054 ms
61055 ms

Au bout d’une minute, on peut voir que l’on a 54ms de retard. En soi, ce n’est pas très grave. Ce qui l’est beaucoup plus, c’est que cet écart va se creuser au fil du temps. On prend pratiquement 1ms de retard à chaque itération du Timer.

Si on envoie des paquets audio de 500ms, en moins de 10 minutes, on aura plus d’un paquet audio de retard, ce qui va entrainer des coupures audio.

On peut donc voir qu’avec un Timer ‘classique’, le temps diverge rapidement. On a le même problème avec un System.Threading.Timer.

J’ai donc cherché sur le Web des solutions à ce problème. Il existe diverses solutions comme par exemple, faire un Thread.Sleep toute les 1ms et regarder dès que l’on s’approche au plus près du temps qui nous intéresse. Le problème avec cette solution est qu’elle consomme beaucoup de CPU.

Le besoin réel

Je me suis alors posé la question de ce dont j’avais réellement besoin. Ai-je vraiment besoin d’un Timer avec une précision de l’ordre de la µS de façon à être sûr qu’il ne diverge pas dans le temps ?

Ce dont j’ai besoin c’est d’un Timer qui soit précis dans le temps, mais pas forcément entre 2 itérations.

En effet, si le Timer est en retard sur une itération et qu’il le rattrape ensuite, il n’y aura pas de problème.

Du coup, cela change complètement l’approche du problème.

J’ai besoin d’un système qui corresponde au schéma suivant :

Paquet en lecture (coté client) :

Côté Client

Paquet en envoi (coté serveur) :

Côté Serveur

En considérant qu’il n’y a pas de buffer coté client, on veut envoyer le paquet B au temps t0 pendant que le paquet A sera lu , le paquet C au temps t1 pendant que le paquet B sera lu …

Il faut donc que le Timer se déclenche dans l’intervalle de temps tn + 20ms (partie hachuré), peu importe qu’il se déclenche à 501 ms ou 515 ms.

Pour qu’il tombe dans l’intervalle de temps souhaité à chaque itération, il faut recalculer l’intervalle de temps entre chaque itération.

J’ai donc besoin d’un Timer asservi dans le temps.

Asservissement

Schéma d’un asservissement :

Schémas avertissement

La consigne dans mon cas est de 500ms.

On mesure l’écart entre la sortie et la consigne, puis on réinjecte cette différence dans l’intervalle de notre Timer.

Pour l’asservissement j’ai opté pour un Stopwatch qui permet de mesurer précisément  le temps passé.

Code

J’ai développé l’asservissement de manière implicite, je n’ai pas créé un objet spécifique pour le gérer.

Pour le calcul de l’intervalle, je l’ai fait de la manière suivante :

double val = 2 * Interval - (_watch.ElapsedMilliseconds - _lastValue);

– On commence par calculer l’écart entre le temps total écoulé et le temps théorique de la dernière exécution : _watch.ElapsedMilliseconds – _lastValue

– On calcule l’écart entre la consigne et la mesure : Intervalle – (_watch.ElapsedMilliseconds – _lastValue)

– On rajoute la consigne de façon à voir la prochaine valeur d’intervalle pour le Timer

Pour que le calcul de l’intervalle ne soit jamais négatif et ne bloque le Timer il faut rajouter le test suivant, en gardant à l’esprit que cela ne doit jamais arriver et que si cela devait arriver cela occasionnerait des coupures audio :

if (val <= 0)
{
    val = Interval;
}

Pour la propriété lastValue j’ai utilisé un double.

La valeur max d’un double est : 1.7976931348623157E+308 ce qui correspondrait à environ 5.7004475357125694E+297 année d’exécution, on n’a donc pas besoin de gérer la problématique liée au dépassement de capacité de cette propriété.

Résultat

Avec le Timer asservi on ne dépasse pas les 20ms de retard quelle que soit la durée d’exécution du Timer.

Temps mesuré

1020 ms
2003 ms
3002 ms
4001 ms
5009 ms

57001 ms
58002 ms
59001 ms
60001 ms
61001 ms

Code complet

namespace SoftFluent
{
    public class EnslaveTimer
    {
        protected System.Diagnostics.Stopwatch _watch;
        protected bool _running;
        protected double _lastValue;
        protected double _nbExecution;
        protected Timer _timer;
        public event ElapsedEventHandler Elapsed;
        private double _interval;
        public double Interval
        {
            get { return _interval; }
            set
            {
                if (_interval != value && !_running)
                {
                    _interval = value;
                }
            }
        }
        
        public EnslaveTimer(double interval)
        {
            _lastValue = 0;
            _nbExecution = 0;
            Interval = interval;
            _timer = new Timer();
            _timer.Elapsed += Timer_Elapsed;
            _timer.Interval = interval;
            _watch = new Stopwatch();
        }
        private void Timer_Elapsed(object sender, ElapsedEventArgs e)
        {
            double val = 2 * Interval - (_watch.ElapsedMilliseconds - _lastValue);
            if (val <= 0)
            {
                val = Interval;
            }
            _timer.Interval = val;
            _lastValue = ++_nbExecution * Interval;
            if (Elapsed != null)
            {
                Elapsed(this, e);
            }
        }
        public void Start()
        {
            _running = true;
            _watch.Start();
            _timer.Start();
        }
        public void Stop()
        {
            _timer.Stop();
            _watch.Stop();
            _running = false;
            _lastValue = 0;
            _nbExecution = 0;
            _watch.Reset();
        }
    }
}

Pour instancier notre Timer, il suffit simplement de passer en paramètre du constructeur l’intervalle en millisecondes :

EnslaveTimer timer = new EnslaveTimer(500);
timer.Elapsed += Timer_Elapsed;
timer.Start();

Vous avez maintenant à disposition un Timer précis dans la durée.

Ne ratez plus aucune actualité avec la newsletter mensuelle de SoftFluent

Newsletter SoftFluent