Antipattern nesprávného vytváření instancí

Někdy se neustále vytvářejí nové instance třídy, kdy se mají vytvořit jednou a pak sdílet. Toto chování může poškodit výkon a označuje se jako nesprávný antipattern vytváření instancí. Antipattern je běžná reakce na opakující se problém, který je obvykle neefektivní a může být dokonce kontraproduktivní.

Popis problému

Mnoho knihoven poskytuje abstrakce externích prostředků. Interně tyto třídy obvykle spravují svá vlastní připojení k prostředku, fungují jako zprostředkovatelé, které mohou uživatelé použít, pokud chtějí k prostředku získat přístup. Tady jsou některé příklady tříd zprostředkovatelů, které se mohou týkat aplikací Azure:

  • System.Net.Http.HttpClient. Komunikuje s webovou službou pomocí protokolu HTTP.
  • Microsoft.ServiceBus.Messaging.QueueClient. Odesílá a přijímá zprávy do fronty služby Service Bus.
  • Microsoft.Azure.Documents.Client.DocumentClient. Připojí se k instanci služby Azure Cosmos DB.
  • StackExchange.Redis.ConnectionMultiplexer. Připojuje se k Redisu, včetně služby Azure Cache for Redis.

Instance těchto tříd by se měly vytvořit jednou a pak by se měly v rámci životního cyklu aplikace opakovaně používat. Častým omylem je ale předpoklad, že by se tyto třídy měly načítat pouze podle potřeby a že by se měly rychle uvolňovat. (Tady uvedené jsou knihovny .NET, ale vzor není jedinečný pro .NET.) Následující ASP.NET příklad vytvoří instanci HttpClient pro komunikaci se vzdálenou službou. Kompletní ukázku najdete tady.

public class NewHttpClientInstancePerRequestController : ApiController
{
    // This method creates a new instance of HttpClient and disposes it for every call to GetProductAsync.
    public async Task<Product> GetProductAsync(string id)
    {
        using (var httpClient = new HttpClient())
        {
            var hostName = HttpContext.Current.Request.Url.Host;
            var result = await httpClient.GetStringAsync(string.Format("http://{0}:8080/api/...", hostName));
            return new Product { Name = result };
        }
    }
}

Ve webové aplikaci se tato technika nedá škálovat. Pro každý požadavek uživatele se vytvoří nový objekt HttpClient. V případě velkého zatížení může webový server vyčerpat počet dostupných soketů a může dojít k chybám SocketException.

Tento problém se neomezuje na třídu HttpClient. Jiné třídy, které balí prostředky nebo je jejich vytvoření náročné, můžou způsobit podobné problémy. Následující příklad vytvoří instanci třídy ExpensiveToCreateService. Problémem tu není nezbytně vyčerpání soketů, ale jednoduše doba potřebná k vytvoření každé instance. Neustálé vytváření a ničení instancí této třídy může negativně ovlivnit škálovatelnost systému.

public class NewServiceInstancePerRequestController : ApiController
{
    public async Task<Product> GetProductAsync(string id)
    {
        var expensiveToCreateService = new ExpensiveToCreateService();
        return await expensiveToCreateService.GetProductByIdAsync(id);
    }
}

public class ExpensiveToCreateService
{
    public ExpensiveToCreateService()
    {
        // Simulate delay due to setup and configuration of ExpensiveToCreateService
        Thread.SpinWait(Int32.MaxValue / 100);
    }
    ...
}

Oprava nesprávného antipatternu vytváření instancí

Pokud se třída, která balí externí prostředek, dá sdílet a je bezpečná pro přístup z více vláken, vytvořte sdílenou instanci typu singleton nebo fond opakovaně použitelných instancí třídy.

Následující příklad používá statickou instanci HttpClient, a proto sdílí připojení napříč všemi požadavky.

public class SingleHttpClientInstanceController : ApiController
{
    private static readonly HttpClient httpClient;

    static SingleHttpClientInstanceController()
    {
        httpClient = new HttpClient();
    }

    // This method uses the shared instance of HttpClient for every call to GetProductAsync.
    public async Task<Product> GetProductAsync(string id)
    {
        var hostName = HttpContext.Current.Request.Url.Host;
        var result = await httpClient.GetStringAsync(string.Format("http://{0}:8080/api/...", hostName));
        return new Product { Name = result };
    }
}

Důležité informace

  • Klíčovým prvkem tohoto antipatternu je opakované vytváření a ničení instancí objektu ke sdílení. Pokud třída není ke sdílení (není bezpečná pro přístup z více vláken), potom tento antipattern neplatí.

  • Typ sdíleného prostředku může diktovat, jestli se má použít typ singleton nebo se má vytvořit fond. Třída HttpClient je určená spíše ke sdílení než k použití ve fondu. Jiné objekty můžou podporovat použití ve fondu a umožňovat tak systému, aby rozložil zatížení na více instancí.

  • Objekty, které sdílíte mezi více požadavků, musí být bezpečné pro přístup z více vláken. Třída HttpClient je navržená pro použití tímto způsobem, jiné třídy ale nemusí souběžné požadavky podporovat. Podívejte se proto do dostupné dokumentace.

  • Při nastavování vlastností u sdílených objektů buďte opatrní, protože může vést ke konfliktům časování. Konflikt časování může způsobit například nastavení DefaultRequestHeaders pro třídu HttpClient před jednotlivými žádostmi. Vlastnosti tohoto typu nastavte jednou (například během spuštění). Pokud budete později potřebovat jiné nastavení, vytvořte samostatné instance.

  • Některé typy prostředků jsou omezené a neměli byste na ně spoléhat. Příkladem jsou připojení k databázi. Pokud budete udržovat otevřené připojení k databázi, které se nevyžaduje, můžete tak bránit jiným souběžným uživatelům v získání přístupu k této databázi.

  • V rozhraní .NET Framework se velké množství objektů vytvářejících připojení k externím prostředkům vytváří pomocí statických metod pro vytváření objektů jiných tříd, které tato připojení spravují. Tyto objekty se mají ukládat a opakovaně používat, neměly by se vyřazovat a vytvářet znovu. Například ve službě Azure Service Bus se objekt QueueClient vytvoří prostřednictvím objektu MessagingFactory. MessagingFactory interně spravuje připojení. Další informace najdete v tématu Osvědčené postupy pro zlepšení výkonu pomocí zasílání zpráv Service Bus.

Zjištění nesprávného antipatternu vytváření instancí

Mezi příznaky tohoto problému patří pokles propustnosti nebo vyšší míra chyb a také některé z těchto situací:

  • Zvýšený počet výjimek naznačující, že došlo k vyčerpání prostředků, jako jsou třeba sokety, připojení k databázi, popisovače souborů a další
  • Vyšší využití a uvolňování paměti
  • Zvýšení síťových aktivit, aktivit disku nebo databáze

Následující postup vám pomůže tento problém identifikovat:

  1. Proveďte monitorování procesů produkčního systému. Můžete tak identifikovat body, kdy se doby odezvy zpomalí nebo dojde k chybě systému kvůli nedostatku prostředků.
  2. Prozkoumejte telemetrická data zachycená v těchto bodech, abyste mohli určit, které operace mohou vytvářet a ničit objekty spotřebovávající prostředky.
  3. Proveďte zátěžový test každé podezřelé operace v řízeném testovacím prostředí (místo v produkčním systému).
  4. Zkontrolujte zdrojový kód a zkontrolujte, jak se spravují zprostředkovací objekty.

V trasování zásobníku vyhledejte operace, které v zatíženém systému běží pomalu nebo generují výjimky. Tyto informace vám pomáhají určit, jakým způsobem tyto operace využívají prostředky. Výjimky vám můžou pomoct zjistit, jestli jsou chyby způsobeny vyčerpanými sdílenými prostředky.

Ukázková diagnostika

V následujících částech se tento postup použije u ukázkové aplikace popsané výše.

Identifikace bodů zpomalení nebo chyb

Následující obrázek ukazuje výsledky vygenerované pomocí New Relic APM a zobrazuje operace s dlouhou dobou odezvy. V tomto případě stojí za to prozkoumat podrobněji metodu GetProductAsync v kontroleru NewHttpClientInstancePerRequest. Všimněte si, že při běhu těchto operací se také zvýší chybovost.

Řídicí panel monitorování New Relic zobrazující ukázkovou aplikaci, která pro každý požadavek vytváří novou instanci objektu HttpClient

Prozkoumání telemetrických dat a hledání korelací

Následující obrázek zobrazuje data zachycená pomocí profilace vláken během období odpovídajícího předchozímu obrázku. Systém stráví značnou dobu otvíráním připojení soketů a ještě více času jejich zavíráním a zpracováváním výjimek soketů.

Profiler vláken New Relic zobrazující ukázkovou aplikaci, která pro každý požadavek vytváří novou instanci objektu HttpClient

Provedení zátěžového testování

Pomocí zátěžového testování simulujte obvyklé operace, které můžou uživatelé provádět. Můžete tak identifikovat části systému, které trpí při různém zatížení vyčerpáním prostředků. Tyto testy provádějte v řízeném prostředí (ne v produkčním systému). Následující graf ukazuje propustnost požadavků zpracovávaných kontrolerem NewHttpClientInstancePerRequest, když se zatížení uživatelů zvýší na 100 souběžných uživatelů.

Propustnost ukázkové aplikace, která pro každý požadavek vytváří novou instanci objektu HttpClient

Objem požadavků zpracovaných za sekundu zpočátku při rostoucím zatížení stoupá. Při zhruba 30 uživatelích ale objem úspěšných požadavků dosáhne limitu a systém začne generovat výjimky. Od té chvíle se objem výjimek společně s uživatelským zatížením postupně zvyšuje.

Zátěžový test tyto chyby ohlásil jako chyby HTTP 500 (Interní server). Kontrola telemetrických dat ukázala, že tyto chyby byly způsobeny tím, že systému došly při vytváření dalších a dalších objektů HttpClient prostředky soketů.

Následující graf zobrazuje podobný test pro kontroler, který vytváří vlastní objekt ExpensiveToCreateService.

Propustnost ukázkové aplikace, která pro každý požadavek vytváří novou instanci ExpensiveToCreateService

Tentokrát kontroler negeneruje žádné výjimky, propustnost ale stejně dosáhne limitu, zatímco se průměrná doba odezvy zvýší 20 x. (Graf používá logaritmické měřítko pro dobu odezvy a propustnost.) Telemetrie ukázala, že hlavní příčinou problému bylo vytvoření nových instancí ExpensiveToCreateService .

Implementace řešení a ověření výsledku

Po přepnutí metody GetProductAsync na sdílení jedné instance HttpClient ukázal druhý zátěžový test zlepšení výkonu. Nebyly hlášeny žádné chyby a systém byl schopen zpracovat zvyšující se zatížení až do 500 požadavků za sekundu. Průměrná doba odezvy se v porovnání s předchozím testem zkrátila na polovinu.

Propustnost ukázkové aplikace, která pro každý požadavek opakovaně používá stejnou instanci objektu HttpClient

Následující obrázek zobrazuje pro porovnání telemetrická data trasování zásobníku. Tentokrát systém tráví většinu času prováděním skutečné práce (místo otvírání a zavírání soketů).

Profiler vláken New Relic zobrazující ukázkovou aplikaci, která pro všechny požadavky vytvoří jednu instanci objektu HttpClient

Následující graf zobrazuje podobný zátěžový test s použitím sdílené instance objektu ExpensiveToCreateService. Objem zpracovaných požadavků se opět společně s uživatelským zatížením zvyšuje, ale doba odezvy zůstává nízká.

Graf zobrazující podobný zátěžový test s použitím sdílené instance objektu ExpensiveToCreateService