Collections thread-safe en .NET avec verrouillage lecteur-écrivain

La manipulation concurrrente de collections en .NET pose un défi classique : l'exception levée lorsqu'un thread itère sur une collection qu'un autre thread modifie simultanément. L'approche naïve consistant à encapsuler chaque accès dans un lock standard résout le problème, mais au prix d'une contention excessive qui dégrade les performances.

Une solution élégante repose sur ReaderWriterLockSlim, qui distingue les accès en lecture (concurrents) des accès en écriture (exclusifs). L'idée consiste à encapsuler cette logique de verrouillage dans des implémentations d'IEnumerable<T> et IEnumerator<T>, rendant la synchronisation transparente pour le code consommateur.

Gestionnaire de verrous amélioré

La classe suivante encapsule ReaderWriterLockSlim avec une gestion appropriée du cycle de vie via CountdownEvent pour attendre la libération des verrous en cours :

using System;
using System.Threading;

namespace Synchronisation.Collections
{
    public sealed class GestionnaireVerrouLecteurEcrivain : IDisposable
    {
        private readonly ReaderWriterLockSlim _verrouInterne = new ReaderWriterLockSlim();
        private readonly CountdownEvent _compteurOperations = new CountdownEvent(1);
        private bool _libere = false;

        public void AcquerirVerrouLecture()
        {
            _compteurOperations.AddCount();
            _verrouInterne.EnterReadLock();
        }

        public void RelacherVerrouLecture()
        {
            _verrouInterne.ExitReadLock();
            _compteurOperations.Signal();
        }

        public void AcquerirVerrouEcriture()
        {
            _compteurOperations.AddCount();
            _verrouInterne.EnterWriteLock();
        }

        public void RelacherVerrouEcriture()
        {
            _verrouInterne.ExitWriteLock();
            _compteurOperations.Signal();
        }

        public void Dispose()
        {
            if (_libere) return;
            _libere = true;
            
            _compteurOperations.Signal();
            _compteurOperations.Wait();
            _compteurOperations.Dispose();
            _verrouInterne.Dispose();
        }
    }
}

Énumérateur thread-safe

L'énumérateur personnalisé acquiert un verrou en lecture lors de sa construction et le libère lors de sa disposition :

using System;
using System.Collections;
using System.Collections.Generic;

namespace Synchronisation.Collections
{
    public sealed class EnumerateurSecurise<T> : IEnumerator<T>
    {
        private readonly IEnumerator<T> _source;
        private readonly GestionnaireVerrouLecteurEcrivain _gestionnaireVerrou;
        private readonly object _verrouEtat = new object();
        private bool _disposeEffectue;

        public EnumerateurSecurise(
            Func<IEnumerator<T>> fabriqueEnumerateur,
            GestionnaireVerrouLecteurEcrivain gestionnaireVerrou)
        {
            if (fabriqueEnumerateur == null)
                throw new ArgumentNullException(nameof(fabriqueEnumerateur));
            if (gestionnaireVerrou == null)
                throw new ArgumentNullException(nameof(gestionnaireVerrou));

            _gestionnaireVerrou = gestionnaireVerrou;
            _gestionnaireVerrou.AcquerirVerrouLecture();
            _source = fabriqueEnumerateur();
        }

        public T Current => _source.Current;

        object IEnumerator.Current => this.Current;

        public bool MoveNext() => _source.MoveNext();

        public void Reset() => _source.Reset();

        public void Dispose()
        {
            lock (_verrouEtat)
            {
                if (_disposeEffectue) return;
                _disposeEffectue = true;
            }

            _source.Dispose();
            _gestionnaireVerrou.RelacherVerrouLecture();
        }
    }
}

Base énumérable sécurisée

Cette classe abstraite constitue le socle pour toutes les collections thread-safe, gérant le cycle de vie du verrou :

using System;
using System.Collections;
using System.Collections.Generic;

namespace Synchronisation.Collections
{
    public abstract class EnumerableSecurise<T> : IEnumerable<T>, IDisposable
    {
        private readonly IEnumerable<T> _donneesSousJacentes;
        private readonly GestionnaireVerrouLecteurEcrivain _gestionnaireVerrou;
        private readonly object _verrouEtat = new object();
        private bool _disposeEffectue;

        protected EnumerableSecurise(IEnumerable<T> donneesSousJacentes)
        {
            _donneesSousJacentes = donneesSousJacentes 
                ?? throw new ArgumentNullException(nameof(donneesSousJacentes));
            _gestionnaireVerrou = new GestionnaireVerrouLecteurEcrivain();
        }

        protected void EntrerEnLecture() => _gestionnaireVerrou.AcquerirVerrouLecture();

        protected void SortirDeLecture() => _gestionnaireVerrou.RelacherVerrouLecture();

        protected void EntrerEnEcriture() => _gestionnaireVerrou.AcquerirVerrouEcriture();

        protected void SortirDEcriture() => _gestionnaireVerrou.RelacherVerrouEcriture();

        public IEnumerator<T> GetEnumerator()
        {
            return new EnumerateurSecurise<T>(_donneesSousJacentes.GetEnumerator, _gestionnaireVerrou);
        }

        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

        public void Dispose()
        {
            lock (_verrouEtat)
            {
                if (_disposeEffectue) return;
                _disposeEffectue = true;
            }

            _gestionnaireVerrou.Dispose();
            
            if (_donneesSousJacentes is IDisposable jetable)
            {
                jetable.Dispose();
            }
        }
    }
}

Collection générique thread-safe

Implémentation de ICollection<T> avec séparation des verrous lecture/écriture :

using System;
using System.Collections.Generic;

namespace Synchronisation.Collections
{
    public class CollectionSecurisee<T> : EnumerableSecurise<T>, ICollection<T>
    {
        private readonly ICollection<T> _stockageInterne;

        public CollectionSecurisee() : this(new List<T>()) { }

        protected CollectionSecurisee(ICollection<T> stockageInterne) : base(stockageInterne)
        {
            _stockageInterne = stockageInterne 
                ?? throw new ArgumentNullException(nameof(stockageInterne));
        }

        public int Quantite
        {
            get
            {
                try
                {
                    EntrerEnLecture();
                    return _stockageInterne.Count;
                }
                finally
                {
                    SortirDeLecture();
                }
            }
        }

        public bool EstEnLectureSeule
        {
            get
            {
                try
                {
                    EntrerEnLecture();
                    return _stockageInterne.IsReadOnly;
                }
                finally
                {
                    SortirDeLecture();
                }
            }
        }

        public void Ajouter(T element)
        {
            try
            {
                EntrerEnEcriture();
                _stockageInterne.Add(element);
            }
            finally
            {
                SortirDEcriture();
            }
        }

        public bool Retirer(T element)
        {
            try
            {
                EntrerEnEcriture();
                return _stockageInterne.Remove(element);
            }
            finally
            {
                SortirDEcriture();
            }
        }

        public void Vider()
        {
            try
            {
                EntrerEnEcriture();
                _stockageInterne.Clear();
            }
            finally
            {
                SortirDEcriture();
            }
        }

        public bool Contient(T element)
        {
            try
            {
                EntrerEnLecture();
                return _stockageInterne.Contains(element);
            }
            finally
            {
                SortirDeLecture();
            }
        }

        public void CopierVers(T[] tableau, int indexDebut)
        {
            try
            {
                EntrerEnLecture();
                _stockageInterne.CopyTo(tableau, indexDebut);
            }
            finally
            {
                SortirDeLecture();
            }
        }
    }
}

Liste indexée thread-safe

Extension complète implémentant IList<T> :

using System;
using System.Collections.Generic;

namespace Synchronisation.Collections
{
    public class ListeSecurisee<T> : CollectionSecurisee<T>, IList<T>
    {
        private readonly IList<T> _listeSousJacente;

        public ListeSecurisee() : this(new List<T>()) { }

        protected ListeSecurisee(IList<T> listeSousJacente) : base(listeSousJacente)
        {
            _listeSousJacente = listeSousJacente 
                ?? throw new ArgumentNullException(nameof(listeSousJacente));
        }

        public T this[int position]
        {
            get
            {
                try
                {
                    EntrerEnLecture();
                    return _listeSousJacente[position];
                }
                finally
                {
                    SortirDeLecture();
                }
            }
            set
            {
                try
                {
                    EntrerEnEcriture();
                    _listeSousJacente[position] = value;
                }
                finally
                {
                    SortirDEcriture();
                }
            }
        }

        public void Inserer(int position, T element)
        {
            try
            {
                EntrerEnEcriture();
                _listeSousJacente.Insert(position, element);
            }
            finally
            {
                SortirDEcriture();
            }
        }

        public void RetirerA(int position)
        {
            try
            {
                EntrerEnEcriture();
                _listeSousJacente.RemoveAt(position);
            }
            finally
            {
                SortirDEcriture();
            }
        }

        public int PositionDe(T element)
        {
            try
            {
                EntrerEnLecture();
                return _listeSousJacente.IndexOf(element);
            }
            finally
            {
                SortirDeLecture();
            }
        }
    }
}

Cette architecture modulaire permet d'étendre facilement le principe à d'autres interfaces comme IDictionary<K,V> ou ISet<T>, tout en conservant les garanties de thread-safety et la granularité fine des verrous.

Étiquettes: ReaderWriterLockSlim thread-safe collections IEnumerable IEnumerator

Publié le 6 septembre à 18h37