Diese Frage ist aufgrund mehrerer Unbekannter etwas kniffliger als erwartet: Das Verhalten der zu bündelnden Ressource, die erwartete / erforderliche Lebensdauer von Objekten, der wahre Grund, warum der Pool erforderlich ist usw. In der Regel handelt es sich bei Pools um Spezialthreads Pools, Verbindungspools usw. - weil es einfacher ist, einen zu optimieren, wenn Sie genau wissen, was die Ressource tut, und vor allem die Kontrolle darüber haben, wie diese Ressource implementiert wird.
Da es nicht so einfach ist, habe ich versucht, einen ziemlich flexiblen Ansatz anzubieten, mit dem Sie experimentieren und sehen können, was am besten funktioniert. Wir entschuldigen uns im Voraus für den langen Beitrag, aber es gibt viel zu tun, wenn es um die Implementierung eines angemessenen Ressourcenpools für allgemeine Zwecke geht. und ich kratzte wirklich nur an der Oberfläche.
Ein Allzweckpool müsste einige wichtige "Einstellungen" haben, darunter:
- Strategie zum Laden von Ressourcen - eifrig oder faul;
- Mechanismus zum Laden von Ressourcen - wie man tatsächlich einen erstellt;
- Zugriffsstrategie - Sie erwähnen "Round Robin", das nicht so einfach ist, wie es sich anhört. Diese Implementierung kann einen zirkulären Puffer verwenden, der ähnlich , aber nicht perfekt ist, da der Pool keine Kontrolle darüber hat, wann Ressourcen tatsächlich zurückgefordert werden. Andere Optionen sind FIFO und LIFO; FIFO wird eher ein Direktzugriffsmuster haben, aber LIFO erleichtert die Implementierung einer am wenigsten verwendeten Freigabestrategie erheblich (die Ihrer Meinung nach außerhalb des Anwendungsbereichs lag, aber dennoch erwähnenswert ist).
Für den Mechanismus zum Laden von Ressourcen bietet uns .NET bereits eine saubere Abstraktion - Delegaten.
private Func<Pool<T>, T> factory;
Führen Sie dies durch den Konstruktor des Pools und wir sind damit fertig. Die Verwendung eines generischen Typs mit einer new()
Einschränkung funktioniert ebenfalls, dies ist jedoch flexibler.
Von den beiden anderen Parametern ist die Zugriffsstrategie das kompliziertere Biest, daher bestand mein Ansatz darin, einen auf Vererbung (Schnittstelle) basierenden Ansatz zu verwenden:
public class Pool<T> : IDisposable
{
// Other code - we'll come back to this
interface IItemStore
{
T Fetch();
void Store(T item);
int Count { get; }
}
}
Das Konzept hier ist einfach: Wir lassen die öffentliche Pool
Klasse die allgemeinen Probleme wie die Thread-Sicherheit behandeln, verwenden jedoch für jedes Zugriffsmuster einen anderen "Item Store". LIFO kann leicht durch einen Stapel dargestellt werden, FIFO ist eine Warteschlange, und ich habe eine nicht sehr optimierte, aber wahrscheinlich adäquate Ringpufferimplementierung verwendet, die einen List<T>
Indexzeiger verwendet, um ein Round-Robin-Zugriffsmuster zu approximieren.
Alle unten aufgeführten Klassen sind innere Klassen der Pool<T>
- dies war eine Stilwahl, aber da diese wirklich nicht außerhalb der Klasse verwendet werden sollen Pool
, ist dies am sinnvollsten.
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);
}
}
Dies sind die offensichtlichen - Stapel und Warteschlange. Ich denke nicht, dass sie wirklich viel Erklärung verdienen. Der Ringpuffer ist etwas komplizierter:
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; }
}
}
Ich hätte verschiedene Ansätze wählen können, aber unter dem Strich sollte auf Ressourcen in derselben Reihenfolge zugegriffen werden, in der sie erstellt wurden. Dies bedeutet, dass wir Verweise auf sie beibehalten, sie jedoch als "in Verwendung" markieren müssen (oder nicht) ). Im schlimmsten Fall ist immer nur ein Steckplatz verfügbar, und für jeden Abruf ist eine vollständige Iteration des Puffers erforderlich. Dies ist schlecht, wenn Sie Hunderte von Ressourcen gepoolt haben und diese mehrmals pro Sekunde erwerben und freigeben. Nicht wirklich ein Problem für einen Pool von 5-10 Elementen, und im typischen Fall, wenn Ressourcen nur wenig genutzt werden, müssen nur ein oder zwei Slots vorgerückt werden.
Denken Sie daran, dass diese Klassen private innere Klassen sind. Deshalb müssen sie nicht viel auf Fehler überprüft werden. Der Pool selbst beschränkt den Zugriff auf sie.
Wenn Sie eine Aufzählung und eine Factory-Methode eingeben, sind wir mit diesem Teil fertig:
// 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);
}
}
Das nächste zu lösende Problem ist die Ladestrategie. Ich habe drei Typen definiert:
public enum LoadingMode { Eager, Lazy, LazyExpanding };
Die ersten beiden sollten selbsterklärend sein; Der dritte ist eine Art Hybrid, der Ressourcen faul lädt, aber erst dann wieder Ressourcen verwendet, wenn der Pool voll ist. Dies wäre ein guter Kompromiss, wenn Sie möchten, dass der Pool voll ist (wie es sich anhört), aber die Kosten für die tatsächliche Erstellung bis zum ersten Zugriff aufschieben möchten (dh um die Startzeiten zu verbessern).
Die Lademethoden sind wirklich nicht zu kompliziert, jetzt, wo wir die Item-Store-Abstraktion haben:
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;
}
Die obigen Felder size
und count
beziehen sich auf die maximale Größe des Pools und die Gesamtzahl der Ressourcen, die dem Pool gehören (aber nicht unbedingt verfügbar sind ). AcquireEager
ist die einfachste, es wird davon ausgegangen, dass sich ein Artikel bereits im Geschäft befindet - diese Artikel würden bei der Erstellung vorgeladen, dh in der PreloadItems
zuletzt gezeigten Methode.
AcquireLazy
Überprüft, ob sich freie Elemente im Pool befinden. Andernfalls wird ein neues erstellt. AcquireLazyExpanding
erstellt eine neue Ressource, solange der Pool seine Zielgröße noch nicht erreicht hat. Ich habe versucht, dies zu optimieren, um das Sperren zu minimieren, und ich hoffe, ich habe keine Fehler gemacht (ich habe dies unter Multithread-Bedingungen getestet, aber offensichtlich nicht vollständig).
Sie fragen sich möglicherweise, warum bei keiner dieser Methoden überprüft wird, ob das Geschäft die maximale Größe erreicht hat oder nicht. Ich werde gleich darauf zurückkommen.
Nun zum Pool selbst. Hier ist der vollständige Satz privater Daten, von denen einige bereits angezeigt wurden:
private bool isDisposed;
private Func<Pool<T>, T> factory;
private LoadingMode loadingMode;
private IItemStore itemStore;
private int size;
private int count;
private Semaphore sync;
Bei der Beantwortung der Frage, die ich im letzten Absatz beschönigt habe - wie man sicherstellt, dass wir die Gesamtzahl der erstellten Ressourcen begrenzen - stellt sich heraus, dass .NET bereits ein perfektes Tool dafür hat, es heißt Semaphore und wurde speziell entwickelt, um eine feste Lösung zu ermöglichen Anzahl der Threads, die auf eine Ressource zugreifen (in diesem Fall ist die "Ressource" der innere Objektspeicher). Da wir keine vollständige Warteschlange für Produzenten / Konsumenten implementieren, ist dies für unsere Anforderungen vollkommen ausreichend.
Der Konstruktor sieht folgendermaßen aus:
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();
}
}
Sollte hier keine Überraschungen geben. Zu beachten ist nur das Spezialgehäuse für eifriges Laden mit der PreloadItems
bereits zuvor gezeigten Methode.
Da inzwischen fast alles sauber abstrahiert wurde, sind die tatsächlichen Acquire
und Release
Methoden wirklich sehr einfach:
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();
}
Wie bereits erläutert, verwenden wir das Semaphore
, um die Parallelität zu steuern, anstatt den Status des Item-Stores religiös zu überprüfen. Solange erworbene Gegenstände korrekt freigegeben werden, besteht kein Grund zur Sorge.
Last but not least gibt es Aufräumarbeiten:
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; }
}
Der Zweck dieser IsDisposed
Eigenschaft wird in einem Moment klar. Die Hauptmethode Dispose
besteht darin, die tatsächlich gepoolten Elemente zu entsorgen, wenn sie implementiert werden IDisposable
.
Jetzt können Sie dies im Grunde genommen so wie es ist mit einem try-finally
Block verwenden, aber ich mag diese Syntax nicht, denn wenn Sie anfangen, gepoolte Ressourcen zwischen Klassen und Methoden weiterzugeben, wird es sehr verwirrend. Es ist möglich , dass die Hauptklasse , die eine Ressource nutzt nicht einmal hat einen Verweis auf den Pool. Es wird wirklich ziemlich chaotisch, daher ist es besser, ein "intelligentes" gepooltes Objekt zu erstellen.
Angenommen, wir beginnen mit der folgenden einfachen Schnittstelle / Klasse:
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);
}
}
Hier ist unsere vorgetäuschte verfügbare Foo
Ressource, die IFoo
einen Boilerplate-Code zum Generieren eindeutiger Identitäten implementiert und enthält. Wir erstellen ein weiteres spezielles, gepooltes Objekt:
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();
}
}
Dies überträgt nur alle "echten" Methoden auf das Innere IFoo
(wir könnten dies mit einer Dynamic Proxy-Bibliothek wie Castle tun, aber darauf werde ich nicht eingehen). Es wird auch ein Verweis auf das Objekt beibehalten, das Pool
es erstellt, sodass es sich bei Dispose
diesem Objekt automatisch wieder in den Pool zurückgibt. Außer wenn der Pool bereits entsorgt wurde - dies bedeutet, dass wir uns im "Bereinigungs" -Modus befinden und in diesem Fall stattdessen die interne Ressource bereinigt wird .
Mit dem obigen Ansatz können wir Code wie folgt schreiben:
// 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();
}
Das ist eine sehr gute Sache. Es bedeutet , dass der Code, der verwendet die IFoo
(im Gegensatz zu dem Code, der es schafft) nicht wirklich bewusst , den Pool sein muß. Sie können Objekte sogar mit Ihrer bevorzugten DI-Bibliothek und als Provider / Factory injizieren .IFoo
Pool<T>
Ich habe den vollständigen Code für das Kopieren und Einfügen in PasteBin eingefügt . Es gibt auch ein kurzes Testprogramm, mit dem Sie mit verschiedenen Lade- / Zugriffsmodi und Multithread-Bedingungen herumspielen können, um sich davon zu überzeugen, dass es threadsicher und nicht fehlerhaft ist.
Lassen Sie mich wissen, wenn Sie Fragen oder Bedenken dazu haben.