A WCF ára

Nemrég szórakozásból megvizsgáltuk, milyen nagyságrendű beömlő adatot képes lekezelni egy szerver. Ez remek alkalmat teremtett ahhoz, hogy megnézzük, mi a különbség egy WCF-re illetve mezítlábas TCP socketre épülő szerver között teljesítmény tekintetében.

A feladat

A tesztfeladatban a kliensek adatokkal bombázzák a szervert. Egy adat egy értékhármasból áll, ami egy nevet, egy árat, és egy timestampet tartalmaz. Fel lehet fogni az adatot úgy, mintha termékek árát tartalmazná adott időben. Egy példa az adatra:

{ Name = "Fapapucs", Price = "45.60", Timestamp = "2011/11/07 16:56:35.56" }

Egy kliens folyamatosan ömleszti az adatot (ez a teszt szerver megírásánál fontos lehet), és több kliens csatlakozhat egyszerre. A szerver feladata, hogy statisztikát készítsen a bejövő adatokból, például számlálja, egy termékhez mennyi adat érkezett, illetve számoljon adott termékhez átlag árat. A timestamp-et most nem használjuk. Ezt a statisztikát időként jelenítse meg, az egyszerűség kedvéért a konzolon.

A WCF szerver

Kontrakt

A WCF egyszerűen használható eszközöket ad egy szerver megvalósításához, és egyébként pont az egyszerűség felárára vagyunk kíváncsiak.

Ahhoz, hogy egy WCF szervert beüzemeljünk, elsőként szükségünk van egy Contract-ra, amihez mind a kliensek, mind a szerver tartja magát. Ezt egy interfésszel fejezzük ki WCF alatt. Amit tenni szeretnénk, az egy adathármas átküldése a szervernek, így egy erre szolgáló interfészt kell felírnunk:

[ServiceContract]
interface IBurst
{
    [OperationContract(IsOneWay = true)]
    void Shot(string name, double price, DateTime timestamp);
} // interface IBurst

A Shot() üzenet teszi lehetővé az adathármas utaztatását. Ami magyarázatra szorul, az IsOneWay paraméter használata az attributumban. Hagyományos esetben, amikor a kliens egy üzenetet küld a szervernek, akkor annak megvárja a feldolgozását, így kaphat visszafele menő adatot, ami az interfész leírásban visszatérési értékként, FaultContract-ként, esetleg kimenő paraméterként jelenik meg. Ami a szerver szempontjából fontos, hogy a visszafele menő üzenetet elő is kell állítania, ami plusz erőforrásokat igényel (akkor is kell ilyen, ha az interfészen void a visszatérési érték). Az IsOneWay=true segítségével ezt a visszamenő ágat kikapcsolhatjuk, emiatt várhatólag a szerver egy kicsit kevésbé lesz terhelt.

A szerviz osztály

Az előbb felírt kontrakthoz a szerver oldalon léteznie kell egy azt megvalósító osztálynak, ahova a kliens kérések befutnak.

[ServiceBehavior(
    InstanceContextMode=InstanceContextMode.Single, 
    ConcurrencyMode=ConcurrencyMode.Multiple)]
class Burst : IBurst
{
    private int totalCounter;
    private ConcurrentQueue<Tuple<string, double, DateTime>> dataToProcess =
              new ConcurrentQueue<Tuple<string, double, DateTime>>();

    public void Shot(string name, double price, DateTime timestamp)
    {
        Interlocked.Increment(ref totalCounter);
        dataToProcess.Enqueue(
            new Tuple<string, double, DateTime>(name, price, timestamp));
    } // Shot()
} // class Burst

A Burst osztály megvalósítja az IBurst interfészt az egyetlen metódusával együtt. Ez a metódus nagyon keveset csinál. Egyrészt növel egy totalCounter nevű számlálót. Ez a számláló a statisztikához lesz később használva, ahol kiíratjuk, hogy másodpercenként hány Shot() hívást sikerült lekezelni. Mivel több szálon folyhat a kliensek kiszolgálása, az Interlocked.Increment() metódust kell használni a számláló növelésére. Ellenkező esetben a párhuzamosan végzett növelések értékek elvesztését okozhatja.

A másik művelet, amit a szerviz a beérkező adattal végez, az az, hogy eltárolja későbbi feldolgozás céljából. Miért nem dolgozza fel azonnal?

A szinkronizáció veszélyei

Ezen a ponton két választási lehetőségünk van. Amit a beérkező adattal tenni szeretnénk, az az, hogy megkeressük a csoportját (name paraméter alapján), és átlagot számolunk erre a csoportra. A gond az, hogy az átlagszámítás összetett művelet. Nem úgy értve összetett, hogy bonyolult, hanem hogy nem tudjuk egy elemi gépi utasítással megoldani. Főleg nem úgy, hogy az támogatva van szinkronizációs mechanizmusokkal. Az Interlocked.Increment() hívás például a gépikódu LOCK INC utasítássá fordul, és itt hardveres szinkronizációval megy végbe az érték növelése. A hardveres szinkronizáció miatt a művelet nagyon gyors. A mi esetünkben azonban egy általános lock-olást kellene végezni a lock C# kulcsszóval, vagy a Monitor osztály segítségével (amit egyébként a lock kulcsszó mögötti mechanizmus is használ)

Lock Convoy

Bár a lock/Monitor.Enter viszonylag gyors, illetve az általunk elvégezni kívánt művelet nem tart sokáig, a célunk az, hogy rengeteg kliens kérést (üzenetet) kiszolgáljunk. Ez a rengeteg kérés fog ráömleni a lock/Monitor.Enter-re, ami az kérések sorba állítását eredményezheti. Ezt a jelenséget hívják lock convoy-nak.

Egy szál vs. több szál

Másik megoldás lehet, hogy a feldolgozandó adatokat egy queue-ba helyezzük, majd azt onnan egyetlen szálból feldolgozzuk. Ez nem feltétlenül jó ötlet, ezért alaposan meg kell fontolni. Miért lehet ez rossz ötlet? Egyrészt, ha csak egy szál dolgozza fel az adatokat, akkor esetleg nem használjuk ki egy többmagos processzor erejét. Miért nem félünk most ettől? Azért, mert az adatokat a WCF húzza be, és neki egy adat behúzása várhatólag nagyságrendekkel több műveletbe fog kerülni, mint utána nekünk az adatokat feldolgozni. (A WCF mögött egy többrétegű infrastuktúra van, aminek a kódja lefut, mi pedig pár gépi utasítással feldolgozzuk az adatot). Mivel az adat szolgáltatása (a WCF része) sokkal több időt igényel, mint utána az adat feldolgozása, nem hogy az lesz a gond, hogy a többmagos processzorok erejét nem tudjuk adatfeldolgozásnál kihasználni, hanem az, hogy várhatólag egy magot sem tudunk száz százalékban kihasználni, mivel várakozni kell a feldolgozandó adatra. Ha az adatok feldolgozása több időt igényelne, akkor nem lenne jó ötlet a queue és az egy szál.

Lock-free adatszerkezetek

Egy másik dolog, ami miatt a queue rossz ötlet lehet, az az, hogy akkor nyerünk vele bármit, ha az adat queue-ba tétele nem igényel ugyanolyan szinkronizációt, mint amit mi most el akarunk kerülni, azaz nem kell lock/Monitor.Enter a queue-ba tételhez. Mivel több szálból fogunk a közös queue-ba adatot tenni, valamilyen szinkronizációra nyilván szükség van. A szinkronizációs mechanizmusok azonban nem egyenköltségűek. A lock/Monitor.Enter az operációs rendszer critical section objektumát használja, ami elég gyors ugyan, de még mindig rengeteg időbe telik például egy Interlocked.Increment() és az emögött lévő egyetlen LOCK INC gépikódú utasítás végrehajtásához képest.

Szerencsére a .NET 4-gyel jöttek adatszerkezetek, amellyel a többszálú programok igényeit próbálják kielégíteni, és ennek keretében kaptunk egy ConcurrentQueue nevű osztályt. Ez az osztály egy queue adatszerkezetet valósít meg, és biztosítja, hogy több szálból lehet adatokat be illetve kivenni. Ehhez belső megvalósításában az Interlocked.Incremet()/Decrement műveleteket használja, ezzel nagyon gyors szinkronizációt elérve. Mivel így a queue használata gyorsabb, mint lock-ot (Montitor.Enter-t) tenni a számításaink köré, összességében, a mi speciális esetünkben nem olyan rossz ötlet a queue és az egyetelen feldolgozó szál használata. Az egyszerű tesztünkben emiatt ezt az utat választjuk.

Szerviz példány jellemzői

A Burst osztály felett egy ServiceBehavior attributum látható. Ezzel két dolgot állítunk be. Az egyik, hogy a szerviz példányt singleton-ként szeretnénk használni. Ez általában rossz ötlet és a skálázhatóság és a stabilitás rovására megy. A tesztben viszont egy egyszerű feladat van, aminek a szinkronizálását mi magunk végezzük. Azt a kockázatot is nyugodtan vállalhatjuk, hogy a szerviz példány “halála” az összes klienst érinti, mivel nem éles alkalmazásról van szó. Cserébe elkerülhetjük a szerviz példány folyamatos létrehozását/eldobását, bár az ebből adódó teljesítménynövekedés valószínűleg elenyésző. A ConcurrencyMode megadásával jelezzük, hogy a WCF nyugodtan ráengedhet több szálat az objektumunkra, a szinkronizációval mi foglalkozunk. Ellenkező esetben a singleton szerviz példány miatt a WCF sorba állítaná a bejövő kéréseket, jelentősen csökkentve ezzel a szerverünk feldolgozó képességét.

A szerviz indítása

A teszthez az egyszerűség kedvéért kódból konfiguráljuk a szervizt, és ez így néz ki:

static void Main(string[] args)
{
    var host = 
        new ServiceHost(
                new Burst(),                           
                new Uri("net.tcp://xxx.xxx.xxx.xxx")); 


    host.AddServiceEndpoint(
           typeof(IBurst),
           NetTcpBinding(SecurityMode.None),
           "test");

    host.Open();

    Console.WriteLine("Started");
    Console.ReadKey();

    host.Close();
} // Main()

A fenti kód létrehozza az egyetlen szerviz objektum példányt a ServiceHost() konstruktorában. Ezután létrehoz egy TCP-n alapuló binding-ot (AddServiceEndpoint hivás paramétereként). Ez egy gyors adatátvitelt lehetővé tévő binding, mert bár SOAP üzenetekkel kommunikál, az utazó csomagokat nem szövegesen (mint például egy http-n alapuló binding) hanem binárisan kódolja.

Mivel a basicHttpBinding-ot leszámítva minden binding használ valamilyen security mechanizmust az adatok védelme érdekében, ezt a funkciót nekünk ki kell kapcsolni, különben a titkosítással sok idő menne el, mi pedig a sebességre vagyunk kíváncsiak. A kikapcsolásra szolgál a SecurityMode.None paramétere a binding konstruktorának.

A következő sorok létrehoznak egy végpontot a szervizen, majd elindítják a szervizt, ezzel az adatok fogadására készen áll a szerver.

Az adatok feldolgozása

Amit a szerver jelen állapotában csinál, az az, hogy egy queue-ba ömleszti az adatot. Ha ezt a szervert most megtámadnák a kliensek, pár percen belül elfogyna a memória. Emiatt meg kell írni azt a rutint, ami az adatokat feldolgozza. A teszt arról szól, hogy felhasználóként másodpercenként kapjunk egy riportot az adatok alakulásáról. A riport egyszerűen számítható értékeket tartalmaz, és arra számítva, hogy az adatszolgáltatás a számításhoz képest lassú, elegendő, ha egy metódus másodpercenként meghívódik, ami ekkor kiszámolja a riportot a felgyülemlett adatok alapján, majd kiírja az eredményt.

Ez a megoldás megint csak egy tesztprogramnál használható, mert egyébként nem túl robosztus. Ha a kliensek egy másodperc alatt túl nagy mennyiségű adatot pumpálnak a szerverbe, akkor elfogy a memória. Ha a szerver elkezd mással is foglalkozni (például mert más szervizeket is futtat), szintén előfordulhat, hogy túl sok feldolgozatlan adat gyűlik össze. A mi esetünkben kb 40 byte-nyi adatról van szó rekordonként, így ha annyira optimisták vagyunk, hogy egymillió rekordot be tud abálni a WCF másodpercenként, az is csak 40 megabyte, ami kevés a mai gépek kapacitásához képest.

A megjelenítendő riport nevek szerint csoportosítja a bejövő adatokat, és minden csoportra megadja a csoport tagjainak számát, illetve a Price értékek átlagát. Ehhez a riporthoz emiatt elég, ha minden csoporthoz eltároljuk a Price mezők összegét, és a bejövő adatok számát, illetve minden Name értékhez karbantartunk egy fent leírt rekordot:

class StatisticsForName
{
    internal int Counter { get; set; }
    internal double PriceSum { get; set; }
} // class StatisticsForName

A fenti osztály példányait pedig egy Dictionary-ben tároljuk:

Dictionary<string, StatisticsForName> statistics = new Dictionary<string, StatisticsForName>();

Amikor bejön egy { Name = "Fapapucs", Price = "45.60", Timestamp = "2011/11/07 16:56:35.56" } rekord, akkor a Dictionary-ból előkeressük a Fapapucshoz tartozó statisztikát, megnöveljük a Counter értékét eggyel, hozzáadjuk a Price mező értékét a PriceSum-hoz.

Mivel egy szálon tervezzük elvégezni a műveletet, ezen a ponton szinkronizációra nincs szükség (pont ezért választottuk az egyszálas feldolgozást, hogy kikerüljük a számítások körüli szinkronizációt). A következő metódus végzi el a fent leírtakat:

private void Consumer(object state)
{
    Tuple<string, double, DateTime> info;
    while (dataToProcess.TryDequeue(out info))
    {
        StatisticsForName statisticsForName;
        if (statistics.TryGetValue(info.Item1, out statisticsForName))
        {
            statisticsForName.Counter++;
            statisticsForName.PriceSum += info.Item2;
        }
        else
        {
            statistics[info.Item1] = new StatisticsForName { Counter = 1, PriceSum = info.Item2 };
        } // else
    } // while

    // riport irása a képernyőre ...
} // Consumer()

A fenti metódus addig próbál kivenni elemeket a queue-ból, amíg az sikerül neki. Minden kivétel egy Interlocked.Decrement() szinkronizált műveletbe kerül, ami lassabb ugyan annál, mint ha nem kellene szálak közötti szinkronizációval bajlódni, de sokkal gyorsabb a lock/Monitor.Enter-nél. A dictionary használatához már nem kell szinkronizáció, mivel csak egy szál kezeli. Emiatt egy adatra mindennel együtt két nagyon olcsó szinkronizációs művelet jut (két Interlocked, egy az enqueue-nál, egy a dequeue-nál).

A Consumer metódus nem fog magától elindulni, emiatt egy Timer segítségével azt minden másodpercben lefuttatjuk. A Timer-t be lehet állítani például a Burst() osztály konstruktorában:

public Burst()
{
   this.timer = new Timer(Consumer, null, 1000, 1000);
} // Burst()

A riportot összeállító kódot nem részletezem külön, StringBuilder-ekkel összeállít egy egyszerű táblázatot a statisztikáról (itt számol átlagot a Counter és PriceSum alapján), és kiírja, hogy az előző riportált sorhoz képes hány új adat érkezett egy másodpercre vetítve. Az egy másodpercre vetítésnél vigyázni kell, mivel hiába adunk meg a timer-nél 1000 milisec-et, ez nem lesz pontos, főleg a mi esetünkben, ahol agyon szeretnénk terhelni a processzorokat. Emiatt egy stopwatch-csal mérni kell a riportok közötti időt, és azt figyelembe venni.

Amikor az egy szál mégsem egy szál

A fenti megoldás egy komoly veszélyt rejt magában, és ez a timer használatával függ össze: a timer-nek egy külön szála van, amely általában alszik, de mielőtt ezt teszi, kiszámolja, mikor kell felébrednie. Amikor felébred, akkor nem csinál mást, mint a megadott delegate-et a thread pool-ba helyezi, kiszámolja mikor kell újra kelnie, majd visszaalszik. A thread pool a timer által elhelyezett feladatot nem feltétlenül fogja azonnal végrehajtani, ez azonban még nem különösebben nagy baj. A nagy baj az, ha valamiért a thread pool másodpercekig nem tudja feldolgozni a timer által kiadott feladatot. Ekkor ugyanis a timer thread még nyugodt szívvel pakolgatja az újabb és újabb munkaelemeket, amikor felébred, és amikor a thread pool hirtelen felszabadul, akkor ezeket eseteg elkezdi párhuzamosan végrehajtani. Ezek miatt nem teljesen igaz az, hogy a feldolgozó szálon nem kell szinkronizálni. A biztonság kedvéért körbe kell lock-olni a metódust (vagy használjuk a MethodImpl(MethodImplOptions.Synchronized) attributumot).

Nem pont ezt akartuk elkerülni? Nem, mi azt akartuk elkerülni, hogy a számítások végrehajtása közben, mikor módosítunk egy StatisticsForName példányt, akkor ezt ne kelljen lockolni. Ha két szál dolgozza fel az adatokat, és mind a kettő egy “Alma” elemet talált, mind a kettő elkezdeni a counter növelését és a PriceSum növelését. Mivel ezt nem lehet atomi módon megcsinálni, egy lock-kal kellene védeni, emiatt a másik feldolgozó szál beállna. Ha kevés féle string jön be Name-ként, akkor a feldolgozó szálak gyakran ütköznek, de ha nem is ütköznek gyakran, minden elem feldolgozásához kell egy lock, összességében így a feldolgozó szál a lock-okat darálná folyamatosan, és ez vinne el sok időt. Most a metódus belépésénél lesz egy lock, és normál üzemben nem fordul elő, hogy egy másik szál emiatt nem tud a metódusba lépni. Ráadásul másodpercenként egy lock-kal kell számolni, ami így elenyésző overhead-et jelent.

A teszt kliens

A szerver bombázásához egy egyszerű kódot használunk, ami végtelenciklusban véletlenszerű adatokat küld a szervernek:

using System;
using System.ServiceModel;

namespace WcfClient
{
    [ServiceContract]
    interface IBurst
    {
        [OperationContract(IsOneWay = true)]
        void Shot(string name, double price, DateTime timestamp);
    } // IBurst

    class Program
    {
        static readonly string[] names = 
        { 
            "Alma", "Korte", "Barack", 
            "Cseresznye", "Kaktusz", "Banán", "Eper" };

        static Random rnd = new Random();

        static void Main(string[] args)
        {
            var channel = ChannelFactory<IBurst>.CreateChannel(
                            new NetTcpBinding(SecurityMode.None),            
                            new EndpointAddress("net.tcp://xxx.xxx.xxx.xxx/test"));

            while (true)
            {
                channel.Shot(
                    names[rnd.Next(names.Length)], // Name
                    rnd.NextDouble() * 10000,      // Price
                    DateTime.Now);                 // Timestamp
            } // while
        } // Main()
    } // class Program
} // namespace WcfClient

A WCF szerver eredményei

A teszt egy kétmagos, átlagos teljesítményű számítógépen készült. A szervert 4-10 kliens bombázta adatokkal, a hálózati kártya pedig 100Mbit-et tudott fogadni. Érdekességként megemlítem, hogy eredetileg egy 4 magos gépen próbáltuk mérni az adatokat, de ott már nem lehetett elég csomagot átpréselni a 100Mbit-es hálózati kártyán, ezért a processzor nem pörgött ki 100%-ig.

Az alábbi kép a szerver által a konzolra írt adatokat mutatja. Az érdekes szám a “per sec” mögötti érték, ami közel 60ezer feldolgozott adatot jelent másodpercenként.

Hogy árnyaljuk a képet, érdemes megnézni a CPU terheltséget:

Amit érdemes megfigyelni, az a nagy piros terület. Ez a terület mutatja, hogy mennyi időt töltött a vezérlés kernel módban. Ezek az operációs rendszer kódjai, és látható, hogy a végrehajtás alatt a processzor jelentős időt töltött itt, nyilván a hálózati forgalom bonyolításával volt elfoglalva (rengeteg megszakítás, driver kódok, stb. Érdemes megnézni, hogy csak a megszakítások 25%-kal terhelik a processzort a diagram feletti sorban). Ami a lényeg, hogy jó 30%-ban nem a WCF illetve a mi feldolgozó rutinunk futott (és nem is a WCF alatti .NET library kódok). Ez egyben azt is mutatja, ha a WCF kódja, illetve a mi kódunk nem venne el időt, akkor is maximum 180 ezer adat tudna beesni, ennél többet az operációs rendszer nem tudna kezelni, legalábbis olyan módon, ahogyan ezt a WCF a hálózatra teszi, illetve onnan fogadja. A WCF-nek ugyanis nem csak processzor igénye van (SOAP üzenetek dekódolása, ellenőrzések, üzenetek szétosztása), hanem az adatokat is speciális formában/formázottan küldi, ami a hálózaton is overheadként jelentkezik, illetve mindent aszinkron módon kezel, ami adminisztrációs költséget jelent az I/O managernek.

A TCP szerver

Hogy lássuk mik a lehetőségek határai, érdemes a fenti problémát megoldani WCF nélkül, saját magunk kezelve a TCP-t. Ebben az esetben kicsit több dolgunk van. Egyrészt nekünk kell intézni a TCP porton való hallgatózást. Szerencsére a .NET kényelmesen használható osztályokat biztosít ehhez:

var listenerPort = 3333;
var listenerAddress = IPAddress.Parse("10.0.13.18");

var server = new TcpListener(listenerAddress, listenerPort);

server.Start();

Ettől még a kliensek nem tudnak csatlakozni, a csatlakozási igényeik csak egy queue-ba kerülnek, amit fel kell dolgozni. Ehhez a TcpListener példányon egy AcceptTcpClient() metódust kell hívnunk, ami viszont beblokkol egy szálat, ha nincs várakozó kliens. Ez a lépés egy nagy teljesítményigényű szervernél nem optimális. Emiatt az APM-es párját használjuk, ez visszaadja a vezérlést, és ha egy kliens felbukkan, egy callback delegate-ünk fogja az eseményt lekezelni. Az APM-es verzió nem igényel szálat, az operációs rendszer képes arra, hogy a .NET Thread Pool-ja mögötti Completion Port-ot értesíti a kliens érkezéséről, ami pedig egy Thread Pool szálat fog indítani a feladat kezelésére. Ez a folyamat részletesen le van írva az APM-ről szóló cikkben.

Nekünk a bonyolult folyamat beindításához az alábbi egyszerű sort kell leírni:

server.BeginAcceptTcpClient(ClientHandler, server);

A ClientHandler az a metódus, ami meg fog hívódni a kliens kapcsolódásakor. Ennek két feladata lesz. Egyrészt, amikor ez a metódus meghívódik, onnantól megint csak sorakoznának a kliens kapcsolódások, és azokat nem kezelné senki (mivel most már nincs várakozó Accept). Emiatt első lépésként hívunk egy új BeginAcceptTcpClient()-et. Ezután szerzünk egy TcpClient objektumot, ami a kapcsolódott klienst testesíti meg számunkra. Végül elkezdjük feldolgozni az adatokat, amit a kliens küld.

A kommunikációhoz használhatnánk valami kultúrált protokolt (a WCF ugye ezt megtette), mi azonban csak a sebességre törekszünk, emiatt az adatokat mezítlábas módon fogjuk áthajtani a hálózaton. A hibakezeléssel sem foglalkozunk sokat. Az adatkezelés ugyanaz lesz, mint a WCF-es verzió esetén, azaz számoljuk a beérkező adatokat, illetve egy feldolgozási sorba tesszük, Ezek alapján a kód az alábbi módon épül fel:

static void ClientHandler(IAsyncResult asyncResult)
{
    // A szervert átadtuk state-ként a BeginAccept hívásnál
    var server = asyncResult.AsyncState as TcpListener;

    // A következő kapcsolódó klienst is vegye fel valaki
    server.BeginAcceptTcpClient(ClientHandler, server);

    // A jelenleg folyamatban levő Accept lezárása
    var client = server.EndAcceptTcpClient(asyncResult);

    // Kényelmesebb stream-ként kezelni a bejövő adatokat
    var clientStream = client.GetStream();

    // A BinaryReader könnyű lehetőség primitív adatok átvitelére
    var reader = new BinaryReader(clientStream);

    try
    {
        // Amíg tudunk, olvasunk
        while (true)
        {
            // Adat visszaalakítása, lásd később
            var triplet = DeserializeTriplet(reader);

            // Egyelőre ennyit teszünk az adattal:
            dataToProcess.Enqueue(triplet);                    
            Interlocked.Increment(ref dataCounter);
        }
    }
    catch 
    {
        reader.Close(); // zárja a network streamet is, ami zárja a tcpclient-et
        Console.WriteLine("Client disconnected...");
    } // catch
} // ClientHandler()

A szerializálást a DeserializeTriplet() függvény végzi, ez pár egyszerű hívás:

static Tuple<string, double, DateTime> DeserializeTriplet(BinaryReader reader)
{
    var name = reader.ReadString();
    var price = reader.ReadDouble();
    var timeStamp = new DateTime(reader.ReadInt64());

    return new Tuple<string, double, DateTime>(name, price, timeStamp);
} // DeserializeTriplet()

A kliens az adat küldésekor ugyanezeket az adatokat írja ki ugyanilyen módon. Ez az adatok küldésének nagyon hatékony módja, ugyanakkor nagyon érzékeny is. A BinaryFormatter például olyan sorrendben tárolja egy 8 byte-on ábrázolt long byte-jait, ahogy maga a számítógép. Egy Intel processzoros gép például little endian-os. Ha a másik fél (a kliens) nem ilyen, akkor az adatok rosszul lesznek beolvasva. Mivel most a sebességre hajtunk, számunkra ez a törékenyen mód is megfelelő.

Érdemes elidőzni a hálózat olvasásának a módján is. Itt a végtelencikluson belül szinkron olvasások történnek. Ez legtöbb szerver megvalósításnál nagyon rossz döntés lenne, mivel beakasztja az olvasó szálat, amíg nem jönnek meg az adatok, ez pedig csúnyán leépíti egy szerver teljesítményét. A tesztünknek azonban van egy speciális jellemzője: folyamatosan dőlnek be az adatok. Emiatt a mi esetünkben a szinkron olvasás sokkal hatékonyabb, mivel az olvasó szál nem fog beakadni, ugyanakkor elkerülhetjük az APM-es olvasás overhead-jét (ami nem kicsi, lásd az APM-es cikk végét a file olvasás teszttel).

Az adatok feldolgozása (átlag számítás, riport készítés) ugyanolyan módon történik, mint a WCF esetében, emiatt azt a kódot már nem másolom ide. A kliens felépítése sem különösebben érdekes, lényegében azt csinálja, mint a WCF kliens, csak a generált adatokat a DeserializeTriplet() metódus tükörképével írja ki egy NetworkStream példányra. A TCP-s szerver eredménye:


Látható, hogy ez a szerver 6-7 szer több adatot képes feldolgozni, bőven átlépve a WCF-es szervernél számolt 180ezres elméleti limitet is. Ez a tömörebb adatnak, az egyszerűbb protokolnak és a szinkron olvasásoknak köszönhető. A CPU diagrammon az is látszik, hogy arányaiban most még nagyobb része esik a vezérlésnek kernel módba, ami annak a következménye, hogy most nincs meg a WCF-es kód overhead-je, ami az előző esetben a user módú kód egy részét adta.

Hogyan értelmezzük az adatokat?

Első olvasásra a WCF teljesítménye nagyon rossznak tűnhet, hiszen a mezítlábas szerver 6-7-szeres sebességgel volt képes az adatokat feldolgozni. Érdemes azonban figyelembe venni a következőket:

Adatfeldolgozás

A tesztprogramban az adatok feldolgozásának processzor igénye szinte a nullával volt egyenlő. Összeadás, inkrementálás, egy rövid string hash-ének számítása, ilyenek történtek. Ez néhány (néhányszor tíz) gépi utasítást igényel. A ConcurrentQueue erre még rátesz egy keveset, de akkor is szélsőségesen kevés műveletet végez a szerver, emiatt a WCF által rárakott overhead nagyon kijön a tesztben. Ha bármilyen komolyabb műveletet szeretnénk a kapott adatokkal elvégezni (pl aminek adatbázis vonzata van, vagy a timestamp alapján rendezéseket kell végezni), akkor a hatszoros szorzó nagyon gyorsan el tud tűnni, hiszen az adatok behúzásának az időigénye a feldolgozáshoz képest kezd eltörpülni.

Robosztusság

Az egyik ok, ami miatt a TCP-s szerver ennyivel rávert a WCF-re, az az, hogy semmit nem foglalkozik a hibakezeléssel. Az átvitt adatnak például nincs semmilyen struktúrája. és a szerver nem is ellenőriz ezen semmit, feltételezi, hogy az bejövő adatok rendben vannak. A másik, hogy most csak egy funkciót láttunk el, emiatt a TCP szervernek nem kellett dispatching-ot csinálnia. Abban a pillanatban, amikor elkezdenénk komolyan venni a TCP-s szerver tervezését, bevezetnénk valamiféle (akár legegyszerűbb) protokolt. Ennek a protokolnak a kezelése időbe kerülne, és szépen elkezdene elveszni a hatszoros előny.

A robosztusság témaköréhez tartozik a szinkron/aszinkron olvasás. A TCP-s szerver esetében tudtuk, hogy folyamatos adatáramlás lesz egy adott klienstől, és emiatt használtunk szinkron olvasást. Az aszinkron mechanizmus kikerülésével sok időt lehetett spórolni. Másik oldalról, ha a kliens akár kicsit is akadozik az adatok küldésével, az olvasó szálak a TCP-s szerveren beakadnak, pazarolva ezzel az erőforrásokat. Másik hátránya a TCP-s szerverünknek, hogy az alkalmazott végtelenciklus és a szinkron olvasás miatt egy kliens így végső soron egy dedikált szálat kapott (ráadásul a thread pool-ból, ami plusszban nagyon csúnya megoldás). Ez nem nagy gond 5-10 kliensnél, de nagyon nagy gond 50-100 kliens esetében.

Na és a hálózat…

Nem szabad elfelejteni, hogy az eredeti teszt egy négy magos gépen készült volna, csak nem bírta a hálózati kártya, így egy ócskább masinán kellett tesztelni. Ha már a WCF-es szerver is képes a modern hardverek határát nyalogatni, nem biztos, hogy a kód további optimalizálásával érdemes plusz teljesítmény kisajtolni.

Konklúzió

A fentiek miatt magam részéről a WCF-et nem tartom lassúnak. Egy komolyan megírt programnál valószínűleg úgy tele kellene rakni a TCP-s szervert különböző mechanizmusokkal, hogy bőven leépítené a tesztben elért előnyét. Emellett valószínűleg rengeteget kellene dolgozni egy robosztus szerveren, és valószínűleg meg sem lehetne közelíteni azt a teszteltséget, amit a WCF a megléte alatt már megszerzett. Hát nem tudom. Lehet, hogy olcsóbb egy picit erősebb gép…

  1. #1 by Anonymous on 2011. November 5. - 16:44

    Szép munka!
    Kiváncsi lennék azért, hogy egy kikapcsolt reliableSession esetleg befolyasolná-e az eredmenyeket a WCF oldalon? Esetleg igazságosabb lenne az összehasonlítás, ha WCF egy Byte[]-t várna és hasonlóan egy BinaryFormatter végezné el a sorosítást a service oldalon. Vagy esetleg a transferMode nem Buffered, hanem Streamed lenne…

  2. #2 by Tóth Viktor on 2011. November 5. - 18:38

    Köszi! Ahogy nézem a doksiban, a netTcpBinding-nál nincs bekapcsolva alapból a reliable session. De a byte tömb és a stream jó ötlet, lehet, hogy majd kipróbálom!

    • #3 by Anonymous on 2011. November 6. - 09:19

      Igazad van, my bad.

  3. #4 by Tóth Viktor on 2011. November 7. - 11:27

    Kipróbáltam, hogy a Shot() három paraméter helyet csak egy byte tömböt vesz át (és abba én teszem a három adatot), de nem lett gyorsabb. Pedig arra számítottam, hogy egy picit majd javít. Úgy látszik, ezen nem megy el olyan sok idő.

  4. #5 by Anonymous on 2011. November 7. - 11:40

    Hmm érdekes, köszi!

  5. #6 by st4rlight on 2011. November 10. - 19:02

    Imádom a cikkeidet olvasni😉

  6. #7 by Tóth Viktor on 2011. November 10. - 20:06

    köszönöm🙂

  7. #8 by eMeL on 2012. October 10. - 11:28

    “Imádom a cikkeidet olvasni” +1

    Tagadhatatlanul nem csak érdekes témákról szól, de figyelmet fenntartó a nyelvezet és a stílus, ráadásul kellően közérthető, mégsem szájbarágós.

    Na de hagyom a nyalást…😉

  1. A szinkronizáció buktatói - pro C# tology - devPortal

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: