Pisanie dużych i sprawnie działających aplikacji platformy .NET Framework

Ten artykuł zawiera porady dotyczące poprawy wydajności dużych aplikacji .NET Framework lub aplikacji, które przetwarzają dużą ilość danych, takich jak pliki lub bazy danych. Te porady pochodzą z ponownego zapisywania kompilatorów języka C# i Visual Basic w kodzie zarządzanym, a ten artykuł zawiera kilka rzeczywistych przykładów kompilatora języka C#.

Program .NET Framework jest wysoce wydajny do tworzenia aplikacji. Zaawansowane i bezpieczne języki oraz bogata kolekcja bibliotek sprawiają, że tworzenie aplikacji jest bardzo owocne. Jednak z dużą produktywnością wiąże się odpowiedzialność. Należy użyć wszystkich możliwości programu .NET Framework, ale przygotować się do dostosowania wydajności kodu w razie potrzeby.

Dlaczego nowa wydajność kompilatora ma zastosowanie do aplikacji

Zespół platformy kompilatora .NET ("Roslyn") przepisał kompilatory języka C# i Visual Basic w kodzie zarządzanym, aby zapewnić nowe interfejsy API do modelowania i analizowania kodu, narzędzi do kompilowania i włączania znacznie bogatszych środowisk obsługujących kod w programie Visual Studio. Ponowne zapisywanie kompilatorów i kompilowanie środowisk programu Visual Studio w nowych kompilatorach ujawniło przydatne szczegółowe informacje o wydajności, które mają zastosowanie do dowolnej dużej aplikacji .NET Framework lub dowolnej aplikacji, która przetwarza dużo danych. Nie musisz wiedzieć o kompilatorach, aby korzystać ze szczegółowych informacji i przykładów z kompilatora języka C#.

Program Visual Studio używa interfejsów API kompilatora do kompilowania wszystkich funkcji IntelliSense, które użytkownicy kochają, takich jak kolorowanie identyfikatorów i słów kluczowych, list uzupełniania składni, zygzaki błędów, porady dotyczące parametrów, problemy z kodem i akcje kodu. Program Visual Studio zapewnia tę pomoc, gdy deweloperzy piszą i zmieniają swój kod, a program Visual Studio musi pozostać dynamiczny, podczas gdy kompilator stale modeluje edycję kodu.

Gdy użytkownicy końcowi wchodzą w interakcję z twoją aplikacją, oczekują, że będzie ona reagować. Wpisywanie lub obsługa poleceń nigdy nie powinno być blokowane. Pomoc powinna pojawić się szybko lub zrezygnować, jeśli użytkownik kontynuuje wpisywanie. Aplikacja powinna unikać blokowania wątku interfejsu użytkownika z długimi obliczeniami, które sprawiają, że aplikacja jest powolna.

Aby uzyskać więcej informacji na temat kompilatorów Roslyn, zobacz Zestaw SDK platformy kompilatora .NET.

Tylko fakty

Podczas dostrajania wydajności i tworzenia dynamicznych aplikacji .NET Framework należy wziąć pod uwagę te fakty.

Fakt 1: Przedwczesne optymalizacje nie zawsze są warte kłopotów

Pisanie kodu bardziej złożonego niż wymaga ponoszenia kosztów konserwacji, debugowania i polerowania. Doświadczeni programiści mają intuicyjne zrozumienie sposobu rozwiązywania problemów z kodowaniem i pisania bardziej wydajnego kodu. Jednak czasami przedwcześnie optymalizują swój kod. Na przykład używają tabeli skrótów, gdy wystarczy prosta tablica lub użyje skomplikowanego buforowania, które może wyciekać pamięci zamiast po prostu ponownie skompilować wartości. Nawet jeśli jesteś programistą środowiska, należy przetestować wydajność i przeanalizować kod, gdy znajdziesz problemy.

Fakt 2: Jeśli nie mierzysz, zgadujesz

Profile i pomiary nie leżą. Profile pokazują, czy procesor CPU jest w pełni załadowany, czy blokowany na dysku we/wy. Profile informują o tym, jakiego rodzaju i ile pamięci przydzielasz oraz czy procesor cpu poświęca dużo czasu na odzyskiwanie pamięci (GC).

Należy ustawić cele wydajności dla kluczowych środowisk lub scenariuszy klientów w aplikacji i napisać testy w celu mierzenia wydajności. Zbadaj testy zakończone niepowodzeniem, stosując metodę naukową: użyj profilów, aby cię pokierować, postawić hipotezę, jaka może być problem, i przetestować hipotezę przy użyciu eksperymentu lub zmiany kodu. Ustanów podstawowe pomiary wydajności w czasie przy użyciu regularnego testowania, dzięki czemu można odizolować zmiany, które powodują regresje wydajności. Zbliżając się do pracy nad wydajnością w rygorystyczny sposób, można uniknąć marnowania czasu dzięki aktualizacjom kodu, których nie potrzebujesz.

Fakt 3: Dobre narzędzia robią różnicę

Dobre narzędzia pozwalają szybko przechodzić do największych problemów z wydajnością (procesor CPU, pamięć lub dysk) i ułatwić znalezienie kodu, który powoduje te wąskie gardła. Firma Microsoft dostarcza różne narzędzia wydajności, takie jak Visual Studio Profiler i PerfView.

Narzędzie PerfView to zaawansowane narzędzie, które ułatwia skoncentrowanie się na głębokich problemach, takich jak we/wy dysku, zdarzenia GC i pamięć. Śledzenie zdarzeń związanych z wydajnością dla zdarzeń systemu Windows (ETW) można łatwo wyświetlać dla aplikacji, na proces, na stos i informacje o wątku. Narzędzie PerfView pokazuje ilość i rodzaj pamięci przydzielanej przez aplikację oraz jakie funkcje lub stosy wywołań współtworzyją ilość alokacji pamięci. Aby uzyskać szczegółowe informacje, zapoznaj się z bogatymi tematami pomocy, pokazami i filmami wideo dołączonymi do narzędzia.

Fakt 4: Chodzi o alokacje

Możesz pomyśleć, że tworzenie dynamicznej aplikacji .NET Framework dotyczy algorytmów, takich jak używanie szybkiego sortowania zamiast sortowania bąbelków, ale tak nie jest. Największym czynnikiem tworzenia dynamicznej aplikacji jest przydzielanie pamięci, zwłaszcza gdy aplikacja jest bardzo duża lub przetwarza duże ilości danych.

Prawie wszystkie prace nad tworzeniem dynamicznych środowisk IDE przy użyciu nowych interfejsów API kompilatora polegają na unikaniu alokacji i zarządzaniu strategiami buforowania. Ślady narzędzia PerfView pokazują, że wydajność nowych kompilatorów języka C# i Visual Basic jest rzadko powiązana z procesorem CPU. Kompilatory mogą być powiązane we/wy podczas odczytywania setek tysięcy lub milionów wierszy kodu, odczytywania metadanych lub emitowania wygenerowanego kodu. Opóźnienia wątków interfejsu użytkownika są prawie wszystkie ze względu na odzyskiwanie pamięci. Rozszerzenie GC programu .NET Framework jest wysoce dostosowane do wydajności i wykonuje większość pracy współbieżnie podczas wykonywania kodu aplikacji. Jednak pojedyncza alokacja może wyzwolić kosztowną kolekcję gen2 , zatrzymując wszystkie wątki.

Typowe alokacje i przykłady

Przykładowe wyrażenia w tej sekcji zawierają ukryte alokacje, które wydają się małe. Jeśli jednak duża aplikacja wykonuje wyrażenia wystarczająco dużo razy, może to spowodować setki megabajtów, nawet gigabajtów, alokacji. Na przykład testy jednominutowe, które symulowały wpisywanie przez dewelopera w edytorze przydzielonych gigabajtów pamięci i doprowadziło zespół ds. wydajności do skupienia się na scenariuszach pisania.

Konwersja boxing

Boxing występuje, gdy typy wartości, które zwykle żyją na stosie lub w strukturach danych, są opakowane w obiekt. Oznacza to, że należy przydzielić obiekt do przechowywania danych, a następnie zwrócić wskaźnik do obiektu. Program .NET Framework czasami pola wartości ze względu na sygnaturę metody lub typ lokalizacji przechowywania. Zawijanie typu wartości w obiekcie powoduje alokację pamięci. Wiele operacji boxingu może współtworzyć megabajty lub gigabajty alokacji do aplikacji, co oznacza, że aplikacja spowoduje więcej GCs. Program .NET Framework i kompilatory języka unikają boksowania, gdy jest to możliwe, ale czasami dzieje się tak, gdy najmniej się tego spodziewasz.

Aby wyświetlić boxing w programie PerfView, otwórz ślad i przyjrzyj się GC Heap Alloc Stacks pod nazwą procesu aplikacji (pamiętaj, raporty narzędzia PerfView dotyczące wszystkich procesów). Jeśli widzisz typy, takie jak System.Int32 i System.Char w ramach alokacji, są typami wartości boxing. Wybranie jednego z tych typów spowoduje wyświetlenie stosów i funkcji, w których są one w pudełku.

Przykład 1: metody ciągów i argumenty typu wartości

Ten przykładowy kod ilustruje potencjalnie niepotrzebne i nadmierne boxing:

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);
    }
}

Ten kod zapewnia funkcje rejestrowania, więc aplikacja może często wywoływać Log funkcję, a może miliony razy. Problem polega na tym, że wywołanie string.Format do rozwiązania Format(String, Object, Object) przeciążenia.

To przeciążenie wymaga, aby program .NET Framework umieścił int wartości w obiektach w celu przekazania ich do tego wywołania metody. Częściowa poprawka polega na wywołaniu id.ToString() wywołania i size.ToString() przekazaniu wszystkich ciągów (które są obiektami).string.Format Wywołanie ToString() powoduje przydzielenie ciągu, ale alokacja zostanie mimo to wykonana wewnątrz string.Formatelementu .

Możesz rozważyć, że to podstawowe wywołanie string.Format metody to tylko łączenie ciągów, więc zamiast tego możesz napisać ten kod:

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

Jednak ten wiersz kodu wprowadza alokację boksu, ponieważ kompiluje się do Concat(Object, Object, Object)elementu . Program .NET Framework musi zaznaczyć literał znaku, aby wywołać Concat

Poprawka dla przykładu 1

Kompletna poprawka jest prosta. Wystarczy zastąpić literał znaku literałem ciągu, który nie powoduje pola, ponieważ ciągi są już obiektami:

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

Przykład 2: pole wyliczenia

Ten przykład był odpowiedzialny za ogromną ilość alokacji w nowych kompilatorach języka C# i Visual Basic z powodu częstego używania typów wyliczenia, zwłaszcza w operacjach wyszukiwania w słowniku.

public enum Color
{
    Red, Green, Blue
}

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

Ten problem jest bardzo subtelny. Narzędzie PerfView zgłosi to jako GetHashCode() pole wyboru, ponieważ metoda zawiera podstawową reprezentację typu wyliczenia ze względów implementacji. Jeśli przyjrzysz się bliżej funkcji PerfView, możesz zobaczyć dwie alokacje boksu dla każdego wywołania metody GetHashCode(). Kompilator wstawia jeden, a program .NET Framework wstawia drugi.

Poprawka dla przykładu 2

Można łatwo uniknąć obu alokacji, rzutując do podstawowej reprezentacji przed wywołaniem metody GetHashCode():

((int)color).GetHashCode()

Innym typowym źródłem boksu w typach wyliczenia jest Enum.HasFlag(Enum) metoda . Argument przekazany do HasFlag(Enum) musi być boxed. W większości przypadków zamiana wywołań na Enum.HasFlag(Enum) test bitowy jest prostsza i wolna od alokacji.

Pamiętaj o pierwszej wydajności (czyli nie przedwcześnie optymalizuj) i nie rozpoczynaj ponownego pisania całego kodu w ten sposób. Należy pamiętać o tych kosztach boxingu, ale zmienić kod dopiero po profilowaniu aplikacji i znalezieniu punktów aktywnych.

Ciągi

Manipulacje ciągami są jednymi z największych sprawców alokacji i często pojawiają się w programie PerfView w pięciu pierwszych alokacjach. Programy używają ciągów do serializacji, JSON i interfejsów API REST. Ciągi można używać jako stałych programowych do współdziałania z systemami, gdy nie można używać typów wyliczenia. Gdy profilowanie pokazuje, że ciągi mają bardzo wpływ na wydajność, poszukaj wywołań metodString, takich jak Format, , ConcatSplit, Join, i Substringtak dalej. Pozwala StringBuilder uniknąć kosztów tworzenia jednego ciągu z wielu elementów, ale nawet przydzielanie StringBuilder obiektu może stać się wąskim gardłem, którym trzeba zarządzać.

Przykład 3: operacje na ciągach

Kompilator języka C# miał ten kod, który zapisuje tekst sformatowanego komentarza do 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 { /* ... */ }

Widać, że ten kod wykonuje wiele manipulacji ciągami. Kod używa metod biblioteki do dzielenia wierszy na oddzielne ciągi, do przycinania białych znaków, aby sprawdzić, czy argument text jest komentarzem dokumentacji XML i wyodrębnić podciągów z wierszy.

W pierwszym wierszu wewnątrz WriteFormattedDocCommenttext.Split wywołania przydziela nową tablicę trójelementową jako argument za każdym razem, gdy jest wywoływana. Kompilator musi emitować kod w celu przydzielenia tej tablicy za każdym razem. Dzieje się tak dlatego, że kompilator nie wie, czy Split przechowuje tablicę w miejscu, w którym tablica może zostać zmodyfikowana przez inny kod, co będzie miało wpływ na późniejsze wywołania metody WriteFormattedDocComment. Wywołanie , aby również przydzielić Split ciąg dla każdego wiersza w text i przydziela inną pamięć do wykonania operacji.

WriteFormattedDocComment ma trzy wywołania TrimStart metody . Dwa znajdują się w pętlach wewnętrznych, które duplikują pracę i alokacje. Co gorsza, wywołanie TrimStart metody bez argumentów powoduje przydzielenie pustej tablicy (parametru params ) oprócz wyniku ciągu.

Na koniec istnieje wywołanie Substring metody, która zwykle przydziela nowy ciąg.

Poprawka dla przykładu 3

W przeciwieństwie do wcześniejszych przykładów małe zmiany nie mogą naprawić tych alokacji. Musisz cofnąć się, przyjrzeć się problemowi i podejść do niego inaczej. Na przykład zauważysz, że argumentem WriteFormattedDocComment() jest ciąg zawierający wszystkie informacje potrzebne przez metodę, więc kod może wykonywać więcej indeksowania zamiast przydzielać wiele ciągów częściowych.

Zespół ds. wydajności kompilatora poradził sobie ze wszystkimi tymi alokacjami za pomocą kodu w następujący sposób:

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...

Pierwsza wersja przydzielonej WriteFormattedDocComment() tablicy, kilku podciągów i przyciętego podciągu wraz z pustą params tablicą. Sprawdzono również wartość "///". Poprawiony kod używa tylko indeksowania i nie przydziela niczego. Znajduje pierwszy znak, który nie jest odstępem, a następnie sprawdza znak według znaku, aby sprawdzić, czy ciąg zaczyna się od "///". Nowy kod używa IndexOfFirstNonWhiteSpaceChar zamiast TrimStart zwracać pierwszy indeks (po określonym indeksie początkowym), w którym występuje znak inny niż biały. Poprawka nie została ukończona, ale można zobaczyć, jak zastosować podobne poprawki dla kompletnego rozwiązania. Stosując to podejście w całym kodzie, można usunąć wszystkie alokacje w programie WriteFormattedDocComment().

Przykład 4: StringBuilder

W tym przykładzie użyto StringBuilder obiektu . Następująca funkcja generuje pełną nazwę typu dla typów ogólnych:

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();
    }
}

Fokus dotyczy wiersza, który tworzy nowe StringBuilder wystąpienie. Kod powoduje alokację alokacji dla sb.ToString() alokacji i alokacji wewnętrznych w StringBuilder ramach implementacji, ale nie można kontrolować tych alokacji, jeśli chcesz uzyskać wynik ciągu.

Poprawka dla przykładu 4

Aby naprawić alokację StringBuilder obiektu, buforuj obiekt. Nawet buforowanie pojedynczego wystąpienia, które może zostać odrzucone, może znacznie zwiększyć wydajność. Jest to nowa implementacja funkcji, pomijając cały kod z wyjątkiem nowych i ostatnich wierszy:

// 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);
}

Kluczowe elementy to nowe AcquireBuilder() funkcje i GetStringAndReleaseBuilder() :

[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;
}

Ponieważ nowe kompilatory korzystają z wątków, te implementacje używają pola statycznego wątku (ThreadStaticAttribute atrybutu) do buforowania StringBuilderdeklaracji , a prawdopodobnie można zgaślić deklarację ThreadStatic . Pole thread-static zawiera unikatową wartość dla każdego wątku, który wykonuje ten kod.

AcquireBuilder() Zwraca buforowane StringBuilder wystąpienie, jeśli istnieje, po wyczyszczeniu i ustawieniu pola lub pamięci podręcznej na wartość null. AcquireBuilder() W przeciwnym razie tworzy nowe wystąpienie i zwraca je, pozostawiając pole lub pamięć podręczną ustawioną na wartość null.

Gdy skończysz z elementem StringBuilder , wywołaj metodę GetStringAndReleaseBuilder() , aby uzyskać wynik ciągu, zapisz StringBuilder wystąpienie w polu lub pamięci podręcznej, a następnie zwróci wynik. Wykonanie jest możliwe, aby ponownie wprowadzić ten kod i utworzyć wiele StringBuilder obiektów (chociaż rzadko się to zdarza). Kod zapisuje tylko ostatnie wydane StringBuilder wystąpienie do późniejszego użycia. Ta prosta strategia buforowania znacznie zmniejszyła alokacje w nowych kompilatorach. Części programów .NET Framework i MSBuild ("MSBuild") używają podobnej techniki w celu zwiększenia wydajności.

Ta prosta strategia buforowania jest zgodna z dobrym projektem pamięci podręcznej, ponieważ ma ona limit rozmiaru. Jednak istnieje więcej kodu niż w oryginalnym, co oznacza więcej kosztów konserwacji. Należy wdrożyć strategię buforowania tylko wtedy, gdy znaleziono problem z wydajnością, a narzędzie PerfView pokazało, że StringBuilder alokacje są znaczącym współautorem.

LINQ i lambdas

Zapytanie zintegrowane z językiem (LINQ) w połączeniu z wyrażeniami lambda jest przykładem funkcji zwiększającej produktywność. Jednak jego użycie może mieć znaczący wpływ na wydajność w czasie i może okazać się, że trzeba ponownie napisać kod.

Przykład 5: Lambdas, List<T> i IEnumerable<T>

W tym przykładzie użyto kodu LINQ i stylu funkcjonalnego, aby znaleźć symbol w modelu kompilatora, przy użyciu ciągu nazwy:

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);
    }
}

Nowy kompilator i środowiska IDE utworzone na jego podstawie są bardzo często wywoływane FindMatchingSymbol() i istnieje kilka ukrytych alokacji w jednym wierszu kodu tej funkcji. Aby zbadać te alokacje, najpierw podziel pojedynczy wiersz kodu funkcji na dwa wiersze:

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

W pierwszym wierszu wyrażenies => s.Name == namelambda zamyka się nad zmienną namelokalną . Oznacza to, że oprócz przydzielania obiektu dla delegata , predicate który przechowuje, kod przydziela klasę statyczną do przechowywania środowiska, które przechwytuje wartość name. Kompilator generuje kod podobny do następującego:

// 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);

Dwie new alokacje (jedna dla klasy środowiska i jedna dla delegata) są teraz jawne.

Teraz przyjrzyj się wywołaniu funkcji FirstOrDefault. Ta metoda rozszerzenia w typie System.Collections.Generic.IEnumerable<T> powoduje również alokację. Ponieważ FirstOrDefault obiekt przyjmuje IEnumerable<T> jako pierwszy argument, możesz rozwinąć wywołanie do następującego kodu (uproszczone nieco do dyskusji):

// 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);

Zmienna symbols ma typ List<T>. List<T> Typ kolekcji implementuje IEnumerable<T> i sprytnie definiuje moduł wyliczający (IEnumerator<T>interfejs), który List<T> implementuje element za pomocą elementu struct. Użycie struktury zamiast klasy oznacza, że zwykle unikasz alokacji sterty, co z kolei może mieć wpływ na wydajność odzyskiwania pamięci. Moduły wyliczania są zwykle używane z pętlą języka foreach , która używa struktury modułu wyliczającego, ponieważ jest zwracana na stosie wywołań. Zwiększanie wskaźnika stosu wywołań, aby miejsce dla obiektu nie wpływało na GC tak, jak alokacja sterty.

W przypadku rozszerzonego FirstOrDefault wywołania kod musi wywołać GetEnumerator() metodę IEnumerable<T>. Przypisanie symbols do zmiennej enumerable typu IEnumerable<Symbol> traci informacje, że rzeczywisty obiekt jest List<T>. Oznacza to, że gdy kod pobiera moduł wyliczający za pomocą enumerable.GetEnumerator()polecenia , program .NET Framework musi w polu zwróconej struktury przypisać ją do zmiennej enumerator .

Poprawka dla przykładu 5

Poprawka polega na ponownym zapisie FindMatchingSymbol w następujący sposób, zastępując jego pojedynczy wiersz kodu sześcioma wierszami kodu, które są nadal zwięzłe, łatwe do odczytania i zrozumienia oraz łatwe w obsłudze:

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

Ten kod nie korzysta z metod rozszerzeń LINQ, lambdów ani modułów wyliczania i nie generuje żadnych alokacji. Brak alokacji, ponieważ kompilator może zobaczyć, że symbols kolekcja jest elementem List<T> i może powiązać wynikowy moduł wyliczający (strukturę) ze zmienną lokalną o odpowiednim typie, aby uniknąć boksowania. Oryginalna wersja tej funkcji była doskonałym przykładem ekspresyjnej mocy języka C# i wydajności programu .NET Framework. Ta nowa i bardziej wydajna wersja zachowuje te cechy bez dodawania żadnego złożonego kodu do konserwacji.

Buforowanie metody asynchronicznej

W następnym przykładzie pokazano typowy problem podczas próby użycia buforowanych wyników w metodzie asynchronicznej .

Przykład 6: buforowanie w metodach asynchronicznych

Funkcje środowiska IDE programu Visual Studio oparte na nowych kompilatorach języka C# i Visual Basic często pobierają drzewa składni, a kompilatory używają asynchronicznego działania, aby zachować czas reakcji programu Visual Studio. Oto pierwsza wersja kodu, którą można napisać, aby uzyskać drzewo składni:

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;
    }
}

Widać, że wywoływanie GetSyntaxTreeAsync() wystąpień klasy Parser, analizuje kod, a następnie zwraca Task obiekt Task<SyntaxTree>. Kosztowna część polega na Parser przydzielaniu wystąpienia i analizowaniu kodu. Funkcja zwraca Task wartość , aby osoby wywołujące mogły oczekiwać na pracę analizy i zwolnić wątek interfejsu użytkownika, aby reagować na dane wejściowe użytkownika.

Kilka funkcji programu Visual Studio może próbować uzyskać to samo drzewo składni, więc możesz napisać następujący kod w celu buforowania wyniku analizy w celu zaoszczędzenia czasu i alokacji. Jednak ten kod powoduje alokację:

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;
    }
}

Zobaczysz, że nowy kod z buforowaniem ma SyntaxTree pole o nazwie cachedResult. Gdy to pole ma wartość null, GetSyntaxTreeAsync() działa i zapisuje wynik w pamięci podręcznej. GetSyntaxTreeAsync()SyntaxTree zwraca obiekt . Problem polega na tym, że gdy masz async funkcję typu Task<SyntaxTree>i zwracasz wartość typu SyntaxTree, kompilator emituje kod w celu przydzielenia zadania do przechowywania wyniku (przy użyciu polecenia Task<SyntaxTree>.FromResult()). Zadanie jest oznaczone jako ukończone, a wynik jest natychmiast dostępny. W kodzie dla nowych kompilatorów obiekty, które zostały już ukończone, Task wystąpiły tak często, że naprawianie tych alokacji znacznie poprawiło czas odpowiedzi.

Poprawka dla przykładu 6

Aby usunąć ukończoną Task alokację, możesz buforować obiekt Task z ukończonym wynikiem:

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;
    }
}

Ten kod zmienia typ cachedResult elementu na Task<SyntaxTree> i stosuje async funkcję pomocnika, która przechowuje oryginalny kod z klasy GetSyntaxTreeAsync(). GetSyntaxTreeAsync() teraz używa operatora łączenia wartości null do zwrócenia cachedResult , jeśli nie ma wartości null. Jeśli cachedResult ma wartość null, GetSyntaxTreeAsync() wywołuje GetSyntaxTreeUncachedAsync() i buforuje wynik. Zwróć uwagę, że GetSyntaxTreeAsync() nie oczekuje na wywołanie GetSyntaxTreeUncachedAsync() metody , ponieważ kod normalnie. Nieużywaj funkcji await oznacza, że gdy GetSyntaxTreeUncachedAsync() zwraca swój Task obiekt, GetSyntaxTreeAsync() natychmiast zwraca wartość Task. Teraz buforowany wynik jest Taskwynikiem , więc nie ma alokacji, aby zwrócić buforowany wynik.

Uwagi dodatkowe

Poniżej przedstawiono kilka dodatkowych kwestii dotyczących potencjalnych problemów w dużych aplikacjach lub aplikacjach, które przetwarzają dużo danych.

Słowniki

Słowniki są używane wszechobecnie w wielu programach, a słowniki są bardzo wygodne i z natury wydajne. Są one jednak często używane niewłaściwie. W programie Visual Studio i nowych kompilatorach analiza pokazuje, że wiele słowników zawiera jeden element lub było pustych. Dictionary<TKey,TValue> Pusty zawiera dziesięć pól i zajmuje 48 bajtów na stercie na maszynie x86. Słowniki są doskonałe, gdy potrzebujesz mapowania lub struktury danych asocjacyjnych z wyszukiwaniem w czasie stałym. Jeśli jednak masz tylko kilka elementów, tracisz dużo miejsca przy użyciu słownika. Zamiast tego można na przykład iteracyjnie przejrzeć element List<KeyValuePair\<K,V>>, tak samo szybko. Jeśli używasz słownika tylko do ładowania go z danymi, a następnie odczytu z niego (bardzo typowy wzorzec), użycie posortowanej tablicy z wyszukiwaniem N(log(N)) może być niemal tak szybkie, w zależności od liczby używanych elementów.

Klasy a struktury

W ten sposób klasy i struktury zapewniają klasyczny kompromis czasu/przestrzeni na potrzeby dostrajania aplikacji. Klasy generują 12 bajtów narzutu na maszynie x86, nawet jeśli nie mają pól, ale są one niedrogie do obejścia, ponieważ wymaga tylko wskaźnika, aby odwołać się do wystąpienia klasy. Struktury nie generują alokacji sterty, jeśli nie są one w pudełku, ale gdy przekazujesz duże struktury jako argumenty funkcji lub zwracane wartości, potrzeba czasu procesora CPU, aby niepodzieal skopiować wszystkie elementy członkowskie danych struktur. Zwróć uwagę na powtarzające się wywołania właściwości, które zwracają struktury, i buforuj wartość właściwości w zmiennej lokalnej, aby uniknąć nadmiernego kopiowania danych.

Pamięci podręczne

Typową sztuczką wydajności jest buforowanie wyników. Jednak pamięć podręczna bez limitu rozmiaru lub zasad usuwania może być przeciek pamięci. Podczas przetwarzania dużych ilości danych, jeśli przechowujesz dużo pamięci w pamięci podręcznej, możesz spowodować zastąpienie korzyści związanych z buforowanym wyszukiwaniem.

W tym artykule omówiono, jak należy pamiętać o objawach wąskich gardeł wydajności, które mogą mieć wpływ na czas reakcji aplikacji, zwłaszcza w przypadku dużych systemów lub systemów, które przetwarzają dużą ilość danych. Typowymi sprawcami są boks, manipulacje ciągami, LINQ i lambda, buforowanie w metodach asynchronicznych, buforowanie bez zasad ograniczenia rozmiaru lub usuwania, niewłaściwe użycie słowników i przekazywanie struktur. Należy pamiętać o czterech faktach dotyczących dostrajania aplikacji:

  • Nie należy przedwcześnie optymalizować — wydajnie i dostrajaj aplikację, gdy występują problemy.

  • Profile nie leżą — zgadujesz, czy nie mierzysz.

  • Dobre narzędzia robią różnicę — pobierz program PerfView i wypróbuj go.

  • Chodzi o alokacje — w tym miejscu zespół platformy kompilatora spędził większość czasu na poprawie wydajności nowych kompilatorów.

Zobacz też