Cette question est un peu plus délicate que ce à quoi on pourrait s'attendre en raison de plusieurs inconnues: le comportement de la ressource mise en pool, la durée de vie attendue / requise des objets, la vraie raison pour laquelle le pool est requis, etc. pools, pools de connexions, etc. - car il est plus facile d'en optimiser un lorsque vous savez exactement ce que fait la ressource et, plus important encore, contrôlez la manière dont cette ressource est mise en œuvre.
Comme ce n'est pas si simple, ce que j'ai essayé de faire est de proposer une approche assez flexible que vous pouvez expérimenter et voir ce qui fonctionne le mieux. Toutes mes excuses à l'avance pour le long message, mais il y a beaucoup de chemin à parcourir pour mettre en œuvre un pool de ressources à usage général décent. et je ne fais que gratter la surface.
Une piscine à usage général devrait avoir quelques "paramètres" principaux, notamment:
- Stratégie de chargement des ressources - impatient ou paresseux;
- Mécanisme de chargement des ressources - comment en construire un;
- Stratégie d'accès - vous mentionnez "round robin" qui n'est pas aussi simple qu'il y paraît; cette implémentation peut utiliser un tampon circulaire qui est similaire , mais pas parfait, car le pool n'a aucun contrôle sur le moment où les ressources sont réellement récupérées. Les autres options sont FIFO et LIFO; Le FIFO aura davantage un modèle d'accès aléatoire, mais LIFO facilite considérablement la mise en œuvre d'une stratégie de libération des moins récemment utilisées (qui, selon vous, était hors de portée, mais cela vaut toujours la peine d'être mentionné).
Pour le mécanisme de chargement des ressources, .NET nous donne déjà une abstraction propre - les délégués.
private Func<Pool<T>, T> factory;
Passez ceci à travers le constructeur de la piscine et nous en avons presque terminé. L'utilisation d'un type générique avec une new()
contrainte fonctionne également, mais c'est plus flexible.
Parmi les deux autres paramètres, la stratégie d'accès est la bête la plus compliquée, donc mon approche consistait à utiliser une approche basée sur l'héritage (interface):
public class Pool<T> : IDisposable
{
// Other code - we'll come back to this
interface IItemStore
{
T Fetch();
void Store(T item);
int Count { get; }
}
}
Le concept ici est simple - nous laisserons la Pool
classe publique gérer les problèmes courants tels que la sécurité des threads, mais utiliserons un "magasin d'objets" différent pour chaque modèle d'accès. LIFO est facilement représenté par une pile, FIFO est une file d'attente, et j'ai utilisé une implémentation de tampon circulaire pas très optimisée mais probablement adéquate en utilisant un List<T>
pointeur d'index et pour approximer un modèle d'accès circulaire .
Toutes les classes ci-dessous sont des classes internes de Pool<T>
- c'était un choix de style, mais comme elles ne sont vraiment pas destinées à être utilisées en dehors de Pool
, cela a le plus de sens.
class QueueStore : Queue<T>, IItemStore
{
public QueueStore(int capacity) : base(capacity)
{
}
public T Fetch()
{
return Dequeue();
}
public void Store(T item)
{
Enqueue(item);
}
}
class StackStore : Stack<T>, IItemStore
{
public StackStore(int capacity) : base(capacity)
{
}
public T Fetch()
{
return Pop();
}
public void Store(T item)
{
Push(item);
}
}
Ce sont les plus évidents - pile et file d'attente. Je ne pense pas qu'ils méritent vraiment beaucoup d'explications. Le tampon circulaire est un peu plus compliqué:
class CircularStore : IItemStore
{
private List<Slot> slots;
private int freeSlotCount;
private int position = -1;
public CircularStore(int capacity)
{
slots = new List<Slot>(capacity);
}
public T Fetch()
{
if (Count == 0)
throw new InvalidOperationException("The buffer is empty.");
int startPosition = position;
do
{
Advance();
Slot slot = slots[position];
if (!slot.IsInUse)
{
slot.IsInUse = true;
--freeSlotCount;
return slot.Item;
}
} while (startPosition != position);
throw new InvalidOperationException("No free slots.");
}
public void Store(T item)
{
Slot slot = slots.Find(s => object.Equals(s.Item, item));
if (slot == null)
{
slot = new Slot(item);
slots.Add(slot);
}
slot.IsInUse = false;
++freeSlotCount;
}
public int Count
{
get { return freeSlotCount; }
}
private void Advance()
{
position = (position + 1) % slots.Count;
}
class Slot
{
public Slot(T item)
{
this.Item = item;
}
public T Item { get; private set; }
public bool IsInUse { get; set; }
}
}
J'aurais pu choisir un certain nombre d'approches différentes, mais l'essentiel est que les ressources doivent être accessibles dans le même ordre où elles ont été créées, ce qui signifie que nous devons conserver les références à celles-ci, mais les marquer comme "en cours d'utilisation" (ou non ). Dans le pire des cas, un seul emplacement est toujours disponible, et il faut une itération complète du tampon pour chaque extraction. C'est mauvais si vous avez des centaines de ressources mises en commun et que vous les acquérez et les libérez plusieurs fois par seconde; ce n'est pas vraiment un problème pour un pool de 5 à 10 éléments, et dans le cas typique , où les ressources sont peu utilisées, il suffit d'avancer d'un ou deux emplacements.
N'oubliez pas que ces classes sont des classes internes privées - c'est pourquoi elles n'ont pas besoin de beaucoup de vérification d'erreurs, le pool lui-même en restreint l'accès.
Ajoutez une énumération et une méthode d'usine et nous en avons terminé avec cette partie:
// Outside the pool
public enum AccessMode { FIFO, LIFO, Circular };
private IItemStore itemStore;
// Inside the Pool
private IItemStore CreateItemStore(AccessMode mode, int capacity)
{
switch (mode)
{
case AccessMode.FIFO:
return new QueueStore(capacity);
case AccessMode.LIFO:
return new StackStore(capacity);
default:
Debug.Assert(mode == AccessMode.Circular,
"Invalid AccessMode in CreateItemStore");
return new CircularStore(capacity);
}
}
Le prochain problème à résoudre est la stratégie de chargement. J'ai défini trois types:
public enum LoadingMode { Eager, Lazy, LazyExpanding };
Les deux premiers devraient être explicites; le troisième est une sorte d'hybride, il charge les ressources paresseusement mais ne commence pas à réutiliser les ressources tant que le pool n'est pas plein. Ce serait un bon compromis si vous voulez que la piscine soit pleine (ce qui semble être le cas) mais que vous voulez reporter les frais de création effective jusqu'au premier accès (c'est-à-dire pour améliorer les temps de démarrage).
Les méthodes de chargement ne sont vraiment pas trop compliquées, maintenant que nous avons l'abstraction du magasin d'objets:
private int size;
private int count;
private T AcquireEager()
{
lock (itemStore)
{
return itemStore.Fetch();
}
}
private T AcquireLazy()
{
lock (itemStore)
{
if (itemStore.Count > 0)
{
return itemStore.Fetch();
}
}
Interlocked.Increment(ref count);
return factory(this);
}
private T AcquireLazyExpanding()
{
bool shouldExpand = false;
if (count < size)
{
int newCount = Interlocked.Increment(ref count);
if (newCount <= size)
{
shouldExpand = true;
}
else
{
// Another thread took the last spot - use the store instead
Interlocked.Decrement(ref count);
}
}
if (shouldExpand)
{
return factory(this);
}
else
{
lock (itemStore)
{
return itemStore.Fetch();
}
}
}
private void PreloadItems()
{
for (int i = 0; i < size; i++)
{
T item = factory(this);
itemStore.Store(item);
}
count = size;
}
Les champs size
et count
ci-dessus font référence respectivement à la taille maximale du pool et au nombre total de ressources détenues par le pool (mais pas nécessairement disponibles ). AcquireEager
est le plus simple, il suppose qu'un article est déjà dans le magasin - ces articles seraient préchargés à la construction, c'est-à-dire dans la PreloadItems
méthode indiquée en dernier.
AcquireLazy
vérifie s'il y a des articles gratuits dans le pool, et sinon, il en crée un nouveau. AcquireLazyExpanding
créera une nouvelle ressource tant que le pool n'a pas encore atteint sa taille cible. J'ai essayé d'optimiser ce pour minimiser le verrouillage, et j'espère que je ne l' ai pas fait d'erreur (je l' ai testé dans des conditions multi-thread, mais évidemment pas exhaustive).
Vous vous demandez peut-être pourquoi aucune de ces méthodes ne se soucie de vérifier si le magasin a atteint ou non la taille maximale. J'y reviendrai dans un instant.
Maintenant pour la piscine elle-même. Voici l'ensemble complet des données privées, dont certaines ont déjà été montrées:
private bool isDisposed;
private Func<Pool<T>, T> factory;
private LoadingMode loadingMode;
private IItemStore itemStore;
private int size;
private int count;
private Semaphore sync;
En réponse à la question que j'ai passée sous silence dans le dernier paragraphe - comment nous assurer de limiter le nombre total de ressources créées - il s'avère que le .NET dispose déjà d'un très bon outil pour cela, il s'appelle Semaphore et il est spécifiquement conçu pour permettre un nombre de threads accédant à une ressource (dans ce cas, la "ressource" est le magasin d'objets interne). Puisque nous n'implémentons pas une file d'attente complète de producteurs / consommateurs, cela répond parfaitement à nos besoins.
Le constructeur ressemble à ceci:
public Pool(int size, Func<Pool<T>, T> factory,
LoadingMode loadingMode, AccessMode accessMode)
{
if (size <= 0)
throw new ArgumentOutOfRangeException("size", size,
"Argument 'size' must be greater than zero.");
if (factory == null)
throw new ArgumentNullException("factory");
this.size = size;
this.factory = factory;
sync = new Semaphore(size, size);
this.loadingMode = loadingMode;
this.itemStore = CreateItemStore(accessMode, size);
if (loadingMode == LoadingMode.Eager)
{
PreloadItems();
}
}
Ne devrait pas y avoir de surprises ici. La seule chose à noter est le boîtier spécial pour un chargement hâtif, en utilisant la PreloadItems
méthode déjà montrée précédemment.
Étant donné que presque tout a été proprement abrégé maintenant, le réel Acquire
et les Release
méthodes sont vraiment très simples:
public T Acquire()
{
sync.WaitOne();
switch (loadingMode)
{
case LoadingMode.Eager:
return AcquireEager();
case LoadingMode.Lazy:
return AcquireLazy();
default:
Debug.Assert(loadingMode == LoadingMode.LazyExpanding,
"Unknown LoadingMode encountered in Acquire method.");
return AcquireLazyExpanding();
}
}
public void Release(T item)
{
lock (itemStore)
{
itemStore.Store(item);
}
sync.Release();
}
Comme expliqué précédemment, nous utilisons le Semaphore
pour contrôler la concurrence au lieu de vérifier religieusement l'état du magasin d'objets. Tant que les objets acquis sont correctement libérés, il n'y a rien à craindre.
Dernier point mais non le moindre, il y a le nettoyage:
public void Dispose()
{
if (isDisposed)
{
return;
}
isDisposed = true;
if (typeof(IDisposable).IsAssignableFrom(typeof(T)))
{
lock (itemStore)
{
while (itemStore.Count > 0)
{
IDisposable disposable = (IDisposable)itemStore.Fetch();
disposable.Dispose();
}
}
}
sync.Close();
}
public bool IsDisposed
{
get { return isDisposed; }
}
Le but de cette IsDisposed
propriété deviendra clair dans un instant. Tout ce que la Dispose
méthode principale fait vraiment est de supprimer les éléments mis en commun s'ils sont implémentés IDisposable
.
Maintenant, vous pouvez essentiellement l'utiliser tel try-finally
quel , avec un bloc, mais je n'aime pas cette syntaxe, car si vous commencez à faire passer des ressources mises en commun entre les classes et les méthodes, cela deviendra très déroutant. Il est possible que la classe principale qui utilise une ressource ne même pas avoir une référence à la piscine. Cela devient vraiment assez compliqué, donc une meilleure approche consiste à créer un objet groupé «intelligent».
Disons que nous commençons avec l'interface / classe simple suivante:
public interface IFoo : IDisposable
{
void Test();
}
public class Foo : IFoo
{
private static int count = 0;
private int num;
public Foo()
{
num = Interlocked.Increment(ref count);
}
public void Dispose()
{
Console.WriteLine("Goodbye from Foo #{0}", num);
}
public void Test()
{
Console.WriteLine("Hello from Foo #{0}", num);
}
}
Voici notre prétendue Foo
ressource jetable qui implémente IFoo
et contient un code standard pour générer des identités uniques. Ce que nous faisons est de créer un autre objet spécial et groupé:
public class PooledFoo : IFoo
{
private Foo internalFoo;
private Pool<IFoo> pool;
public PooledFoo(Pool<IFoo> pool)
{
if (pool == null)
throw new ArgumentNullException("pool");
this.pool = pool;
this.internalFoo = new Foo();
}
public void Dispose()
{
if (pool.IsDisposed)
{
internalFoo.Dispose();
}
else
{
pool.Release(this);
}
}
public void Test()
{
internalFoo.Test();
}
}
Cela renvoie simplement toutes les méthodes «réelles» à son intérieur IFoo
(nous pourrions le faire avec une bibliothèque Dynamic Proxy comme Castle, mais je n'entrerai pas dans cela). Il conserve également une référence à celui Pool
qui le crée, de sorte que lorsque nous Dispose
cet objet, il se libère automatiquement dans le pool. Sauf lorsque le pool a déjà été éliminé - cela signifie que nous sommes en mode "nettoyage" et dans ce cas, il nettoie en fait la ressource interne à la place.
En utilisant l'approche ci-dessus, nous arrivons à écrire du code comme celui-ci:
// Create the pool early
Pool<IFoo> pool = new Pool<IFoo>(PoolSize, p => new PooledFoo(p),
LoadingMode.Lazy, AccessMode.Circular);
// Sometime later on...
using (IFoo foo = pool.Acquire())
{
foo.Test();
}
C'est une très bonne chose à pouvoir faire. Cela signifie que le code qui utilise le IFoo
(par opposition au code qui le crée) n'a pas réellement besoin de connaître le pool. Vous pouvez même injecter des IFoo
objets en utilisant votre bibliothèque DI préférée et en Pool<T>
tant que fournisseur / usine.
J'ai mis le code complet sur PasteBin pour votre plaisir de copier-coller. Il existe également un court programme de test que vous pouvez utiliser pour jouer avec différents modes de chargement / accès et des conditions multithreads, pour vous assurer qu'il est thread-safe et non bogué.
Faites-moi savoir si vous avez des questions ou des préoccupations à ce sujet.