Psaní velkých a pohotově reagujících aplikací .NET Framework

Tento článek obsahuje tipy pro zlepšení výkonu velkých aplikací rozhraní .NET Framework nebo aplikací, které zpracovávají velké množství dat, jako jsou soubory nebo databáze. Tyto tipy pocházejí z přepsání kompilátorů jazyka C# a Visual Basic ve spravovaném kódu a tento článek obsahuje několik skutečných příkladů z kompilátoru jazyka C#.

.NET Framework je vysoce produktivní pro vytváření aplikací. Výkonné a bezpečné jazyky a bohatá kolekce knihoven vytvářejí aplikace vysoce plodné. S velkou produktivitou však přichází zodpovědnost. Měli byste použít všechny možnosti rozhraní .NET Framework, ale být připraveni ladit výkon kódu v případě potřeby.

Proč se nový výkon kompilátoru vztahuje na vaši aplikaci

Tým .NET Compiler Platform (Roslyn) přepsal kompilátory jazyka C# a Visual Basic ve spravovaném kódu, aby poskytl nová rozhraní API pro modelování a analýzu kódu, pro vytváření nástrojů a umožnění mnohem bohatších, na kódu závislých zkušeností v sadě Visual Studio. Přepsání kompilátorů a sestavování prostředí sady Visual Studio na nových kompilátorech odhalilo užitečné přehledy o výkonu, které platí pro libovolnou velkou aplikaci .NET Framework nebo jakoukoli aplikaci, která zpracovává velké množství dat. Abyste mohli využít přehledy a příklady z kompilátoru jazyka C#, nemusíte o kompilátoru jazyka C# vědět.

Visual Studio používá rozhraní API kompilátoru k sestavení všech funkcí IntelliSense, které uživatelé milují, jako je zabarvení identifikátorů a klíčových slov, seznamy dokončování syntaxe, vlnovkování chyb, tipy pro parametry, problémy s kódem a akce kódu. Visual Studio poskytuje tuto nápovědu, když vývojáři zapisují a mění kód, a Visual Studio musí zůstat responzivní, zatímco kompilátor průběžně modeluje úpravy vývojářů kódu.

Když koncoví uživatelé s vaší aplikací pracují, očekávají, že budou reagovat. Zadávání nebo zpracování příkazů by nikdy nemělo být blokováno. Nápověda by se měla rychle zobrazit nebo se vzdát, pokud uživatel pokračuje v psaní. Aplikace by se měla vyhnout blokování vlákna uživatelského rozhraní dlouhými výpočty, které aplikaci znemožní.

Další informace o kompilátorech Roslyn naleznete v tématu Sada .NET Compiler Platform SDK.

Jen fakta

Při ladění výkonu a vytváření responzivních aplikací .NET Framework zvažte tato fakta.

Fakt 1: Předčasné optimalizace nejsou vždy užitečné

Psaní kódu, který je složitější, než je třeba, zvýší náklady na údržbu, ladění a leštění. Zkušení programátoři mají intuitivní přehled o tom, jak řešit problémy s kódováním a psát efektivnější kód. Někdy ale kód předčasně optimalizují. Například používají hašovací tabulku, když by stačilo jednoduché pole, nebo používají složitou metodu ukládání do mezipaměti, která může unikat paměť, místo prostého přepočítávání hodnot. I když jste programátor zkušenosti, měli byste otestovat výkon a analyzovat kód, když zjistíte problémy.

Fakt 2: Pokud neměříte, hádáte

Profily a měření nelžou. Profily ukazují, jestli je procesor plně načtený nebo jestli jste blokovaní na vstupně-výstupních operacích disku. Profily vám říkají, jaký druh a kolik paměti přidělujete a jestli váš procesor tráví hodně času v uvolňování paměti (GC).

Měli byste nastavit cíle výkonu pro klíčová prostředí nebo scénáře zákazníků ve vaší aplikaci a psát testy pro měření výkonu. Prozkoumejte neúspěšné testy použitím vědecké metody: použijte profily, které vás provedou, hypotézu o tom, co může být problém, a otestujte svou hypotézu pomocí experimentu nebo změny kódu. Pomocí pravidelného testování nastavte standardní měření výkonu v průběhu času, abyste mohli izolovat změny, které způsobují regrese v výkonu. Když se přiblížíte výkonové práci přísným způsobem, vyhnete se plýtvání časem s aktualizacemi kódu, které nepotřebujete.

Fakt 3: Dobré nástroje dělají všechny rozdíly

Dobré nástroje umožňují rychle přejít k podrobnostem o největších problémech s výkonem (procesor, paměť nebo disk) a pomáhají najít kód, který tyto kritické body způsobuje. Microsoft dodává celou řadu výkonnostních nástrojů, jako Visual Studio Profiler a PerfView.

PerfView je výkonný nástroj, který vám pomůže zaměřit se na hluboké problémy, jako jsou vstupně-výstupní operace disku, události GC a paměť. Můžete zachytit události Event Tracing for Windows (ETW) související s výkonem a snadno zobrazit informace podle aplikace, procesu, zásobníku a vlákna. PerfView ukazuje, kolik a jaký druh paměti aplikace přiděluje a které funkce nebo volání zásobníků přispívají k přidělení paměti. Podrobnosti najdete v bohatých tématech nápovědy, ukázkách a videích, které jsou součástí nástroje.

Fakt 4: Všechno o přiděleních

Možná si myslíte, že vytvoření responzivní aplikace .NET Framework se týká algoritmů, jako je použití rychlého řazení místo řazení bublin, ale to není ten případ. Největší faktor při vytváření responzivní aplikace je přidělování paměti, zejména pokud je aplikace velmi velká nebo zpracovává velké objemy dat.

Téměř veškerá práce na vytváření responzivních prostředí IDE s novými rozhraními API kompilátoru zahrnovala vyhýbání se přidělování a správu strategií ukládání do mezipaměti. Trasování perfView ukazují, že výkon nových kompilátorů jazyka C# a Visual Basic je zřídka vázán na procesor. Kompilátory můžou být vázané na vstupně-výstupní operace při čtení stovek tisíc nebo milionů řádků kódu, čtení metadat nebo generování generovaného kódu. Zpoždění vlákna uživatelského rozhraní jsou téměř všechna kvůli garbage collection. GC rozhraní .NET Framework je vysoce vyladěný pro výkon a provádí většinu své práce souběžně při provádění kódu aplikace. Jedno přidělení ale může aktivovat nákladnou kolekci Gen2 a zastavit všechna vlákna.

Běžné přidělení a příklady

Ukázkové výrazy v této části mají skryté alokace, které se zdají být malé. Pokud ale velká aplikace spustí výrazy dostatečně mnohokrát, mohou způsobit stovky megabajtů, dokonce i gigabajty alokací. Například jednominutové testy, které simulovaly psaní vývojáře v editoru, přidělovaly gigabajty paměti a vedly tým výkonu k zaměření na scénáře psaní.

Zabalení

Boxing nastane, když jsou typy hodnot, které se obvykle nacházejí na zásobníku nebo v datových strukturách, zabalené do objektu. To znamená, že objekt přidělíte k uložení dat a pak vrátíte ukazatel na objekt. Rozhraní .NET Framework někdy boxuje hodnoty kvůli podpisu metody nebo typu úložného místa. Zabalení hodnotového typu v objektu způsobí přidělení paměti. Mnoho operací boxingu může přispívat megabajty nebo gigabajty přidělení do vaší aplikace, což znamená, že vaše aplikace způsobí více GCS. Rozhraní .NET Framework a kompilátory jazyků se snaží vyhnout boxování, pokud je to možné, ale někdy k němu dojde, když to nejméně čekáte.

Pokud chcete zobrazit "boxing" v nástroji PerfView, otevřete trasování a podívejte se na zásobníky alokací haldy GC pod názvem procesu vaší aplikace (mějte na paměti, že PerfView generuje sestavy pro všechny procesy). Pokud se zobrazí typy jako System.Int32 a System.Char v rámci alokací, boxujete hodnotové typy. Pokud zvolíte jeden z těchto typů, zobrazí se zásobníky a funkce, ve kterých jsou obsaženy.

Příklad 1: Řetězcové metody a argumenty typu hodnoty

Tento ukázkový kód ilustruje potenciálně zbytečné a nadměrné boxování:

public class Logger
{
    public static void WriteLine(string s) { /*...*/ }
}

public class BoxingExample
{
    public void Log(int id, int size)
    {
        var s = string.Format("{0}:{1}", id, size);
        Logger.WriteLine(s);
    }
}

Tento kód poskytuje funkci protokolování, takže aplikace může funkci Log volat často, možná milionykrát. Problém je v tom, že volání na string.Format se přesměrovává na přetížení Format(String, Object, Object).

Toto přetížení vyžaduje, aby rozhraní .NET Framework zabalilo hodnoty int do objektů, aby je předalo volání této metody. Částečnou opravou je zavolat id.ToString() a size.ToString(), a předat všechny řetězce (které jsou objekty) volání string.Format. Volání ToString() přiděluje řetězec, ale toto přidělení se přesto stane uvnitř string.Format.

Zvažte, že toto základní volání na string.Format je jen o zřetězení řetězců, takže byste místo toho mohli napsat tento kód:

var s = id.ToString() + ':' + size.ToString();

Tento řádek kódu však zavádí alokaci kvůli boxingu, protože se zkompiluje do Concat(Object, Object, Object). Rozhraní .NET Framework musí zadat literál znaku, který se má vyvolat. Concat

Oprava příkladu 1

Úplná oprava je jednoduchá. Jednoduše nahraďte literál znaku řetězcovým literálem, u kterého nedochází k boxingu, protože řetězce jsou již objekty.

var s = id.ToString() + ":" + size.ToString();

Příklad 2: vytvoření výčtu

Tento příklad zodpovídá za obrovské množství přidělení v nových kompilátorech C# a Visual Basic kvůli častému použití typů výčtů, zejména v operacích vyhledávání slovníku.

public enum Color
{
    Red, Green, Blue
}

public class BoxingExample
{
    private string name;
    private Color color;
    public override int GetHashCode()
    {
        return name.GetHashCode() ^ color.GetHashCode();
    }
}

Tento problém je velmi jemný. PerfView by to ohlásil jako GetHashCode() boxování, protože metoda boxuje základní reprezentaci typu výčtu z důvodů implementace. Pokud se v nástroji PerfView podíváte pozorně, může se zobrazit dvě přidělení boxů pro každé volání GetHashCode(). Kompilátor vloží jeden a rozhraní .NET Framework vloží druhý.

Oprava příkladu 2

Můžete se snadno vyhnout oběma přidělením tím, že před voláním GetHashCode() přetypujete na podkladovou reprezentaci:

((int)color).GetHashCode()

Dalším běžným zdrojem boxingu u výčtových typů bývá metoda Enum.HasFlag(Enum). Argument předaný HasFlag(Enum) musí být v rámečku. Ve většině případů je nahrazení volání na Enum.HasFlag(Enum) bitovým testem jednodušší a bez alokace.

Mějte na paměti první skutečnost o výkonu (to znamená, neoptimalizujte předčasně) a nezačínejte přepisovat veškerý svůj kód tímto způsobem. Mějte na paměti tyto náklady na boxování, ale změňte kód až po profilaci aplikace a nalezení kritických míst.

Řetězce

Manipulace s řetězci jsou jedním z největších viníků přiřazování a často se zobrazují v PerfView mezi pěti nejvyššími přiděleními. Programy používají řetězce pro serializaci, JSON a rozhraní REST API. Řetězce můžete použít jako programové konstanty pro spolupráci se systémy, pokud nemůžete použít typy výčtu. Když profilace ukazuje, že řetězce mají významný vliv na výkon, vyhledejte volání metod, jako jsou String, Format, Concat, Split, Join, Substring a podobně. Použití StringBuilder pomáhá zabránit nákladům na vytvoření jednoho řetězce z mnoha částí, ale i přidělení objektu StringBuilder se může stát úzkým hrdlem, které je potřeba spravovat.

Příklad 3: Operace s řetězci

Kompilátor jazyka C# měl tento kód, který zapisuje text formátovaného komentáře dokumentu XML:

public void WriteFormattedDocComment(string text)
{
    string[] lines = text.Split(new[] { "\r\n", "\r", "\n" },
                                StringSplitOptions.None);
    int numLines = lines.Length;
    bool skipSpace = true;
    if (lines[0].TrimStart().StartsWith("///"))
    {
        for (int i = 0; i < numLines; i++)
        {
            string trimmed = lines[i].TrimStart();
            if (trimmed.Length < 4 || !char.IsWhiteSpace(trimmed[3]))
            {
                skipSpace = false;
                break;
            }
        }
        int substringStart = skipSpace ? 4 : 3;
        for (int i = 0; i < numLines; i++)
            WriteLine(lines[i].TrimStart().Substring(substringStart));
    }
    else { /* ... */ }

Vidíte, že tento kód dělá spoustu manipulace s řetězci. Kód používá metody knihovny k rozdělení řádků do samostatných řetězců, k oříznutí prázdných znaků, ke kontrole, zda text argument je komentář dokumentace XML a extrahuje podřetězce z řádků.

Na prvním řádku uvnitř WriteFormattedDocComment, volání text.Split přidělí nové pole se třemi prvky jako argument pokaždé, když je voláno. Kompilátor musí pokaždé vygenerovat kód pro přidělení tohoto pole. Je to proto, že kompilátor neví, jestli Split pole ukládá někam, kde pole může být změněno jiným kódem, což by mělo vliv na WriteFormattedDocCommentpozdější volání . Volání Split také přidělí řetězec pro každý řádek v text a přidělí další paměť pro provedení operace.

WriteFormattedDocComment má tři volání na metodu TrimStart. Dva jsou ve vnitřních smycích, které duplikují práci a přidělení. Aby to bylo ještě složitější, volání metody TrimStart bez argumentů přiděluje prázdné pole (pro parametr params), a kromě toho generuje řetězcový výsledek.

Nakonec existuje volání Substring metody, která obvykle přiděluje nový řetězec.

Oprava příkladu 3

Na rozdíl od předchozích příkladů nemohou malé úpravy tyto přidělení opravit. Musíte se vrátit zpět, podívat se na problém a přistupovat k němu jinak. Všimněte si například, že argumentem WriteFormattedDocComment() je řetězec, který obsahuje všechny informace, které metoda potřebuje, takže kód by mohl místo přidělování mnoha částečných řetězců provádět větší indexování.

Tým výkonu kompilátoru vyřešil všechny tyto přidělení pomocí kódu takto:

private int IndexOfFirstNonWhiteSpaceChar(string text, int start) {
    while (start < text.Length && char.IsWhiteSpace(text[start])) start++;
    return start;
}

private bool TrimmedStringStartsWith(string text, int start, string prefix) {
    start = IndexOfFirstNonWhiteSpaceChar(text, start);
    int len = text.Length - start;
    if (len < prefix.Length) return false;
    for (int i = 0; i < len; i++)
    {
        if (prefix[i] != text[start + i]) return false;
    }
    return true;
}

// etc...

První verze přidělila pole WriteFormattedDocComment(), několik podřetězců a oříznutý podřetězec spolu s prázdným polem params. Také se kontrolovala možnost ///. Upravený kód používá pouze indexování a nepřiděluje nic. Najde první znak, který není prázdný, a potom zkontroluje, jestli řetězec začíná znakem ///. Nový kód používá IndexOfFirstNonWhiteSpaceChar místo TrimStart, aby vrátil první index (po zadaném počátečním indexu), kde se vyskytuje neprázdný znak. Oprava není dokončená, ale můžete se podívat, jak použít podobné opravy pro kompletní řešení. Použitím tohoto přístupu v celém kódu můžete odebrat všechny alokace v WriteFormattedDocComment().

Příklad 4: StringBuilder

Tento příklad používá StringBuilder objekt. Následující funkce vygeneruje úplný název typu pro obecné typy:

public class Example
{
    // Constructs a name like "SomeType<T1, T2, T3>"
    public string GenerateFullTypeName(string name, int arity)
    {
        StringBuilder sb = new StringBuilder();

        sb.Append(name);
        if (arity != 0)
        {
            sb.Append("<");
            for (int i = 1; i < arity; i++)
            {
                sb.Append("T"); sb.Append(i.ToString()); sb.Append(", ");
            }
            sb.Append("T"); sb.Append(i.ToString()); sb.Append(">");
        }

        return sb.ToString();
    }
}

Zaměření je na řádku, který vytvoří novou StringBuilder instanci. Kód způsobí přidělení pro sb.ToString() a interní přidělení v rámci implementace StringBuilder, ale pokud chcete mít výsledek jako řetězec, nemůžete tato přidělení ovlivnit.

Oprava příkladu 4

Pokud chcete opravit přidělení objektu StringBuilder , ukažte objekt do mezipaměti. Dokonce i ukládání do mezipaměti jedné instance, která by mohla být zahozena, může výrazně zvýšit výkon. Toto je nová implementace funkce, která vynechá veškerý kód s výjimkou nových prvních a posledních řádků:

// Constructs a name like "MyType<T1, T2, T3>"
public string GenerateFullTypeName(string name, int arity)
{
    StringBuilder sb = AcquireBuilder();
    /* Use sb as before */
    return GetStringAndReleaseBuilder(sb);
}

Klíčové části jsou nové AcquireBuilder() a GetStringAndReleaseBuilder() funkce:

[ThreadStatic]
private static StringBuilder cachedStringBuilder;

private static StringBuilder AcquireBuilder()
{
    StringBuilder result = cachedStringBuilder;
    if (result == null)
    {
        return new StringBuilder();
    }
    result.Clear();
    cachedStringBuilder = null;
    return result;
}

private static string GetStringAndReleaseBuilder(StringBuilder sb)
{
    string result = sb.ToString();
    cachedStringBuilder = sb;
    return result;
}

Vzhledem k tomu, že nové kompilátory používají vlákna, tyto implementace využívají statické pole vlákna (ThreadStaticAttribute atribut) k ukládání do mezipaměti StringBuilder, a pravděpodobně můžete vynechat použití deklarace ThreadStatic. Statické pole vlákna obsahuje jedinečnou hodnotu pro každé vlákno, které tento kód spouští.

AcquireBuilder() vrátí instanci uloženou StringBuilder v mezipaměti, pokud existuje, po vymazání a nastavení pole nebo mezipaměti na hodnotu null. AcquireBuilder() V opačném případě vytvoří novou instanci a vrátí ji a ponechá pole nebo mezipaměť nastavenou na hodnotu null.

Až dokončíte StringBuilder, zavoláte GetStringAndReleaseBuilder() k získání výsledku jako řetězce, uložíte StringBuilder instanci do pole nebo mezipaměti a poté vrátíte výsledek. Je možné, že spuštění znovu vstoupí do tohoto kódu a vytvoří více objektů StringBuilder (i když k tomu dochází jen zřídka). Kód uloží pouze poslední vydanou StringBuilder instanci pro pozdější použití. Tato jednoduchá strategie ukládání do mezipaměti výrazně snížila přidělení v nových kompilátorech. Části rozhraní .NET Framework a MSBuild ("MSBuild") používají podobnou techniku ke zlepšení výkonu.

Tato jednoduchá strategie ukládání do mezipaměti dodržuje dobrý návrh mezipaměti, protože má velikost limitu. Nyní však existuje více kódu než v originálu, což znamená více nákladů na údržbu. Strategii ukládání do mezipaměti byste měli používat jen tehdy, když narazíte na problém s výkonem a nástroj PerfView ukáže, že StringBuilder alokace výrazně přispívají.

LINQ a lambda

Jazykově integrovaný dotaz (LINQ) ve spojení s výrazy lambda je příkladem funkce produktivity. Jeho použití ale může mít významný dopad na výkon v průběhu času a možná zjistíte, že budete muset kód přepsat.

Příklad 5: Lambdas, List<T> a IEnumerable<T>

Tento příklad používá LINQ a funkční styl kódu k vyhledání symbolu v modelu kompilátoru podle řetězce názvu:

class Symbol {
    public string Name { get; private set; }
    /*...*/
}

class Compiler {
    private List<Symbol> symbols;
    public Symbol FindMatchingSymbol(string name)
    {
        return symbols.FirstOrDefault(s => s.Name == name);
    }
}

Nový kompilátor a prostředí IDE, která jsou na něm postavena, se velmi často volají FindMatchingSymbol() a v jednom řádku kódu této funkce je několik skrytých přidělení. Pokud chcete tyto přidělení prozkoumat, nejprve rozdělte jeden řádek kódu funkce na dva řádky:

Func<Symbol, bool> predicate = s => s.Name == name;
     return symbols.FirstOrDefault(predicate);

Na prvním řádku se výrazs => s.Name == name lambda zavře přes místní proměnnou .name To znamená, že kromě přidělování objektu pro delegáta který predicate udržuje, kód přidělí statickou třídu pro uložení prostředí, které zachycuje hodnotu name. Kompilátor vygeneruje kód podobný následujícímu:

// Compiler-generated class to hold environment state for lambda
private class Lambda1Environment
{
    public string capturedName;
    public bool Evaluate(Symbol s)
    {
        return s.Name == this.capturedName;
    }
}

// Expanded Func<Symbol, bool> predicate = s => s.Name == name;
Lambda1Environment l = new Lambda1Environment() { capturedName = name };
var predicate = new Func<Symbol, bool>(l.Evaluate);

new Dvě přidělení (jedno pro třídu prostředí a druhé pro delegáta) jsou teď zřetelná.

Teď se podívejte na volání FirstOrDefault. Tato metoda rozšíření pro typ System.Collections.Generic.IEnumerable<T> také způsobuje přidělení. Vzhledem k tomu, že FirstOrDefault vezme objekt jako svůj první argument, můžete přizpůsobit volání následujícímu kódu (zjednodušená diskuze):

// Expanded return symbols.FirstOrDefault(predicate) ...
     IEnumerable<Symbol> enumerable = symbols;
     IEnumerator<Symbol> enumerator = enumerable.GetEnumerator();
     while(enumerator.MoveNext())
     {
         if (predicate(enumerator.Current))
             return enumerator.Current;
     }
     return default(Symbol);

Proměnná symbols má typ List<T>. Typ List<T> kolekce implementuje IEnumerable<T> a chytře definuje enumerátor (IEnumerator<T> rozhraní), který List<T> implementuje pomocí struct. Použití struktury místo třídy znamená, že obvykle vyhněte se přidělení haldy, což může zase ovlivnit výkon uvolňování paměti. Vyčíslovače se obvykle používají se smyčkou jazyka foreach, která využívá strukturu vyčíslovače tak, jak je vrácena ve volacím zásobníku. Zvýšení ukazatele zásobníku volání k vytvoření prostoru pro objekt neovlivňuje činnost GC stejným způsobem jako přidělování prostředků v haldě.

V případě rozšířeného FirstOrDefault volání musí kód zavolat GetEnumerator() na IEnumerable<T>. Přiřazení symbols proměnné enumerable typu IEnumerable<Symbol> ztratí informace, že skutečný objekt je List<T>. To znamená, že když kód načte enumerátor s enumerable.GetEnumerator(), rozhraní .NET Framework musí zaboxovat vrácenou strukturu, aby ji mohlo přiřadit proměnné enumerator.

Oprava příkladu 5

Oprava je přepsat FindMatchingSymbol následujícím způsobem a nahradit jeden řádek kódu šesti řádky kódu, které jsou stále stručné, snadno čitelné a srozumitelné a snadno udržovatelné:

public Symbol FindMatchingSymbol(string name)
    {
        foreach (Symbol s in symbols)
        {
            if (s.Name == name)
                return s;
        }
        return null;
    }

Tento kód nepoužívá metody rozšíření LINQ, lambda ani enumerátory a nezpůsobuje žádné alokace. Nejsou žádná přidělení, protože kompilátor může vidět, že symbols kolekce je List<T> a může výsledný enumerátor (který je strukturou) svázat k místní proměnné se správným typem, aby se zabránilo boxování. Původní verze této funkce byla skvělým příkladem expresního výkonu jazyka C# a produktivity rozhraní .NET Framework. Tato nová a efektivnější verze zachovává tyto vlastnosti bez přidání složitého kódu, který se má zachovat.

Ukládání asynchronních metod do mezipaměti

Následující příklad ukazuje běžný problém při pokusu o použití výsledků v mezipaměti v asynchronní metodě.

Příklad 6: Ukládání do mezipaměti v asynchronních metodách

Funkce IDE Visual Studio, které jsou postaveny na nových kompilátorech jazyka C# a Visual Basic, často načítají stromy syntaxe a kompilátory při tom používají asynchronní operace, aby udržely Visual Studio v rychlé odezvě. Tady je první verze kódu, kterou můžete napsat, abyste získali strom syntaxe:

class SyntaxTree { /*...*/ }

class Parser { /*...*/
    public SyntaxTree Syntax { get; }
    public Task ParseSourceCode() { /*...*/ }
}

class Compilation { /*...*/
    public async Task<SyntaxTree> GetSyntaxTreeAsync()
    {
        var parser = new Parser(); // allocation
        await parser.ParseSourceCode(); // expensive
        return parser.Syntax;
    }
}

Můžete vidět, že volání GetSyntaxTreeAsync() vytvoří instanci Parser, parsuje kód a pak vrátí Task objekt, Task<SyntaxTree>. Nákladná je část, která představuje přidělení Parser instance a parsování kódu. Funkce vrátí funkci Task tak, aby volající mohli čekat na analýzu a uvolnit vlákno uživatelského rozhraní, aby reagovalo na uživatelský vstup.

Několik funkcí sady Visual Studio se může pokusit získat stejný strom syntaxe, takže můžete napsat následující kód, který uloží výsledek analýzy do mezipaměti, aby se ušetřil čas a přidělení. Tento kód ale způsobuje přidělení:

class Compilation { /*...*/

    private SyntaxTree cachedResult;

    public async Task<SyntaxTree> GetSyntaxTreeAsync()
    {
        if (this.cachedResult == null)
        {
            var parser = new Parser(); // allocation
            await parser.ParseSourceCode(); // expensive
            this.cachedResult = parser.Syntax;
        }
        return this.cachedResult;
    }
}

Uvidíte, že nový kód s ukládáním do mezipaměti má SyntaxTree pole s názvem cachedResult. Pokud je toto pole null, GetSyntaxTreeAsync() provede práci a uloží výsledek do mezipaměti. GetSyntaxTreeAsync() SyntaxTree vrátí objekt. Problém je, že pokud máte async funkci typu Task<SyntaxTree>a vrátíte hodnotu typu SyntaxTree, kompilátor generuje kód pro přidělení úkolu pro uložení výsledku (pomocí Task<SyntaxTree>.FromResult()). Úkol je označený jako dokončený a výsledek je okamžitě k dispozici. V kódu pro nové kompilátory vznikaly objekty, které byly již dokončeny, Task tak často, že oprava těchto přidělení výrazně zlepšila reaktivitu.

Oprava příkladu 6

Pokud chcete odebrat dokončené Task přidělení, můžete uložit do mezipaměti objekt Task s dokončeným výsledkem:

class Compilation { /*...*/

    private Task<SyntaxTree> cachedResult;

    public Task<SyntaxTree> GetSyntaxTreeAsync()
    {
        return this.cachedResult ??
               (this.cachedResult = GetSyntaxTreeUncachedAsync());
    }

    private async Task<SyntaxTree> GetSyntaxTreeUncachedAsync()
    {
        var parser = new Parser(); // allocation
        await parser.ParseSourceCode(); // expensive
        return parser.Syntax;
    }
}

Tento kód změní typ cachedResult na Task<SyntaxTree> a použije pomocnou funkci async, která obsahuje původní kód z GetSyntaxTreeAsync(). GetSyntaxTreeAsync() nyní používá operátor slučování null k vrácení cachedResult, pokud není null. Pokud je cachedResult null, potom GetSyntaxTreeAsync() vyvolá GetSyntaxTreeUncachedAsync() a uloží výsledek do mezipaměti. Všimněte si, že GetSyntaxTreeAsync() nečeká na volání GetSyntaxTreeUncachedAsync() tak, jak by kód obvykle čekal. Nepoužívání operátoru await znamená, že když GetSyntaxTreeUncachedAsync() vrátí jeho Task objekt, GetSyntaxTreeAsync() okamžitě vrátí Task. Nyní je výsledek uložený v mezipaměti typu Task, takže nejsou potřeba žádné alokace pro vrácení výsledku z mezipaměti.

Další důležité informace

Tady je několik dalších bodů týkajících se potenciálních problémů ve velkých aplikacích nebo aplikacích, které zpracovávají velké množství dat.

Slovníky

Slovníky se používají všudypřítomně v mnoha programech, i když slovníky jsou velmi pohodlné a ze své podstaty efektivní. Často se ale používají nevhodně. V sadě Visual Studio a nových kompilátorech analýza ukazuje, že mnoho slovníků obsahovalo jeden prvek nebo bylo prázdné. Prázdné Dictionary<TKey,TValue> obsahuje deset polí a na x86 počítači zabírá na haldě 48 bajtů. Slovníky jsou skvělé, když potřebujete mapování nebo asociativní datovou strukturu s vyhledáváním v konstantním čase. Pokud ale máte jen několik prvků, můžete pomocí slovníku ztrácet spoustu místa. Místo toho můžete například List<KeyValuePair\<K,V>> procházet iterativně, stejně rychle. Pokud používáte slovník jenom k načtení dat a následnému čtení z něj (velmi běžný vzor), použití seřazeného pole s vyhledáváním N(log(N)) může být téměř stejně rychlé v závislosti na počtu prvků, které používáte.

Třídy vs. struktury

Třídy a struktury poskytují klasický kompromis mezi prostorem a časem pro ladění aplikací. Třídy mají na počítači x86 režijní náklady 12 bajtů, i když nemají žádná pole, ale jejich předání je levné, protože odkazuje pouze na instanci třídy. Struktury nezahrnují přidělení paměti na haldě, pokud nejsou boxovány, ale když předáte velké struktury jako argumenty funkcí nebo návratové hodnoty, vyžaduje to čas CPU atomicky zkopírovat všechny datové členy struktur. Sledujte opakovaná volání vlastností, které vracejí struktury, a ukládejte hodnoty těchto vlastností do místní proměnné, abyste se vyhnuli nadměrnému kopírování dat.

mezipaměti

Běžným trikem s výkonem je ukládání výsledků do mezipaměti. Mezipaměť bez omezení velikosti nebo strategie uvolnění však může způsobit únik paměti. Při zpracování velkých objemů dat, pokud držíte velké množství paměti v mezipaměti, může proces uvolňování paměti přebít výhody těchto vyhledávání.

V tomto článku jsme probrali, jak byste měli vědět o příznaky kritických bodů výkonu, které můžou ovlivnit rychlost odezvy vaší aplikace, zejména u velkých systémů nebo systémů, které zpracovávají velké množství dat. Mezi běžné příčiny patří boxování, manipulace s řetězci, LINQ a lambda, ukládání do mezipaměti v asynchronních metodách, ukládání do mezipaměti bez omezení velikosti nebo zásady odstranění, nevhodné použití slovníků a předávání struktur. Mějte na paměti čtyři fakta pro ladění aplikací:

  • Neoptimalizujte předčasně – buďte produktivní a dolaďte svou aplikaci při zjištění problémů.

  • Profily nelžou – pokud neměříte, hádáte.

  • Dobré nástroje dělají všechny rozdíly – stáhněte si PerfView a vyzkoušejte to.

  • Je to vše o přiděleních – to je místo, kde tým platformy kompilátoru strávil většinu času zlepšením výkonu nových kompilátorů.

Viz také