Tételezzük fel, hogy van egy jól párhuzamosítható feladatunk. Ebben az esetben a kérdés, amit előbb-utóbb fel kell tennünk, hogy hány darab szálat kellene futtatnunk annak érdekében, hogy a legjobb teljesítményt érjük el. Ha túl sok szálat indítunk, akkor a processzor(ok) le lesz(nek) terhelve és sok idő megy el a feladatok közötti váltogatásra. Ha pedig kevés a szálak száma, akkor üresjáratban tölt majd el sok időt a számítógép.
Laikus módon kiindulhatnánk mondjuk abból, hogy a magok számának megfelelő szálat indítunk. Ezzel az a baj, hogy az Intel® HyperThreading™ vagy az AMD® Simultaneous multithreading™ technológiát támogató processzorok esetén valójában dupla annyi mag áll rendelkezésre, szóval egy 2x szorzót érdemes lenne beépíteni. Tehát laikusan azt gondolhatnánk, hogy egy 6 magos AMD Ryzen 5 esetén 12 szálat indítsunk.
Azonban ez nem véletlen egy „laikus” és nem jó megközelítés, mert a probléma ennél jóval összetettebb, mivel nagymértékben függ a feladattól, az operációs rendszertől és attól is, hogy jelenleg hány feladat között kell váltogatnia a processzornak.
Éppen ezért ilyen determinisztikus algoritmus nem létezik arra, ami tuti biztosan megmondja, hogy mennyi szálat lenne optimális indítani egy adott feladathoz.
És akkor még arról nem beszéltünk, hogy a szálak indításának van egy nem elhanyagolható költsége, ha többet szeretnénk egyszerre indítani. Ezeket az operációs rendszernek be kell ütemeznie és az erőforrásokat újra kell osztania. Ha a kódunkban sokszor történik szálak létrehozása és elengedése, akkor a kódunk nagy valószínűséggel több időt tölt el az ütemezőre várva majd, mint tényleges munkával.
De hogyan hidaljuk át a problémát és minimalizáljuk az ütemezési és létrehozási költségeket? Például úgy, hogy létrehozunk egy tömböt szálakból, amire feladatot tudunk kiszervezni és a feladat végeztével nem bontjuk le a szálat, hanem egy újabb feladat kiszervezésére tesszük alkalmassá.
Ezt nem kell nekünk manuálisan leimplementálnunk, hiszen a .NET-ben a ThreadPool osztály pontosan ezért felel. Ennek a működése szintén platformonként eltérő.
Windows esetén a ThreadPool-t a kernel szolgáltatja és az kezeli, míg Linux és Mac esetén a .NET runtime végzi ezeknek a kezelését. A .NET ugyan elfedi ezt a különbséget, de a végeredmény az, hogy bizonyos esetekben lassabb lehet a kód indítása Linux és Mac rendszereken, hiszen a munkavégző szálakat a futtatókörnyezetnek kell létrehoznia.
Nézzünk egy példát a ThreadPool használatára:
using System;
using System.Threading;
namespace PeldaThread7
{
class Program
{
static void Main(string[] args)
{
ThreadPool.QueueUserWorkItem((x) =>
{
Console.WriteLine("Thread pool művelet");
});
Console.ReadKey();
}
}
}
A program kimenete:
Thread pool művelet
A QueueUserWorkItem metódus az, ami a munka jelentős részét végzi. Megadunk neki egy delegate-et vagy egy névtelen eljárást, ahogy a fenti példában is látható. A megadott metódus innentől kezdve saját szálon fog futni, ha éppen van szabad szál.
Amikor elfogynak a szabad szálak a ThreadPool-ban, akkor a programunk addig vár, amíg fel nem szabadul egy. Kérdés az, hogy mikor fogy el? 64 biten 32767, míg 32 biten 1023 szál áll rendelkezésre a pool-ban. Természetesen ez az elméleti maximum, ami gyakorlatban nagymértékben függ a processzor típusától.
Ami fontos és kiemelendő, az az, hogy itt mindig háttérszálakat hozunk létre. Többek között a fenti példában ezért is kell a Console.ReadKey, mert nem fogja megvárni az alkalmazásunk a szálakat.