Bagikan melalui


Menulis Aplikasi .NET Framework yang Besar dan Responsif

Artikel ini memberikan tips untuk meningkatkan kinerja aplikasi .NET Framework besar, atau aplikasi yang memproses data dalam jumlah besar seperti file atau database. Tips ini berasal dari penulisan ulang kompiler C# dan Visual Basic dalam kode terkelola, dan artikel ini mencakup beberapa contoh nyata dari kompiler C#.

.NET Framework sangat produktif untuk membangun aplikasi. Bahasa yang kuat dan aman serta koleksi pustaka yang kaya membuat pembuatan aplikasi sangat bermanfaat. Namun, dengan produktivitas yang besar diperlukan tanggung jawab. Anda harus menggunakan semua kekuatan .NET Framework, tetapi bersiaplah untuk menyesuaikan performa kode Anda jika diperlukan.

Mengapa performa kompiler baru berlaku untuk aplikasi Anda

Tim .NET Compiler Platform ("Roslyn") menulis ulang C# dan kompiler Visual Basic dalam kode terkelola untuk menyediakan API baru untuk pemodelan dan analisis kode, membangun alat, dan memungkinkan pengalaman yang lebih kaya dan sadar kode di Visual Studio. Menulis ulang kompiler dan membangun pengalaman Visual Studio pada kompiler baru mengungkapkan wawasan performa yang berguna yang berlaku untuk aplikasi .NET Framework besar atau aplikasi apa pun yang memproses banyak data. Anda tidak perlu tahu tentang kompiler untuk memanfaatkan wawasan dan contoh dari kompiler C#.

Visual Studio menggunakan API kompiler untuk membangun semua fitur IntelliSense yang disukai pengguna, seperti pewarnaan pengidentifikasi dan kata kunci, daftar penyelesaian sintaks, coretan untuk kesalahan, tips parameter, masalah kode, dan tindakan kode. Visual Studio menyediakan bantuan ini saat pengembang mengetik dan mengubah kode mereka, dan Visual Studio harus tetap responsif sementara kompilator terus memodelkan kode yang diedit oleh pengembang.

Saat pengguna akhir Anda berinteraksi dengan aplikasi Anda, mereka mengharapkannya untuk responsif. Mengetik atau menangani perintah tidak boleh diblokir. Bantuan akan muncul dengan cepat atau berhenti jika pengguna terus mengetik. Aplikasi Anda harus menghindari pemblokiran rangkaian antarmuka pengguna dengan perhitungan panjang yang membuat aplikasi terasa lamban.

Untuk informasi selengkapnya tentang kompiler Roslyn, lihat The .NET Compiler Platform SDK.

Hanya fakta

Pertimbangkan fakta ini saat menyetel performa dan membuat aplikasi .NET Framework yang responsif.

Fakta 1: Pengoptimalan prematur tidak selalu sepadan dengan kerumitannya

Menulis kode yang lebih kompleks dari yang seharusnya membutuhkan biaya pemeliharaan, debugging, dan pemolesan. Programmer berpengalaman memiliki pemahaman intuitif tentang bagaimana memecahkan masalah pengkodean dan menulis kode yang lebih efisien. Namun, mereka terkadang mengoptimalkan kode mereka sebelum waktunya. Misalnya, mereka menggunakan tabel hash ketika larik sederhana sudah cukup, atau menggunakan penembolokan rumit yang dapat membocorkan memori alih-alih hanya menghitung ulang nilai. Bahkan jika Anda seorang programmer berpengalaman, Anda harus menguji performa dan menganalisis kode Anda ketika Anda menemukan masalah.

Fakta 2: Jika Anda tidak mengukur, Anda hanya menebak

Profil dan ukuran tidak berbohong. Profil menunjukkan kepada Anda apakah CPU terisi penuh atau apakah Anda diblokir pada I/O disk. Profil memberi tahu Anda jenis dan berapa banyak memori yang Anda alokasikan dan apakah CPU Anda menghabiskan banyak waktu di garbage collector (GC).

Anda harus menetapkan sasaran performa untuk pengalaman atau skenario pelanggan utama di aplikasi Anda dan menulis pengujian untuk mengukur performa. Selidiki tes yang gagal dengan menerapkan metode ilmiah: gunakan profil untuk memandu Anda, berhipotesis apa masalahnya, dan uji hipotesis Anda dengan eksperimen atau perubahan kode. Tetapkan pengukuran performa dasar dari waktu ke waktu dengan pengujian rutin, sehingga Anda dapat mengisolasi perubahan yang menyebabkan kemunduran performa. Dengan mendekati pekerjaan performa dengan cara yang ketat, Anda tidak akan membuang waktu dengan pembaruan kode yang tidak Anda perlukan.

Fakta 3: Alat yang bagus membuat semua perbedaan

Alat yang baik memungkinkan Anda menelusuri dengan cepat masalah performa terbesar (CPU, memori, atau disk) dan membantu Anda menemukan kode yang menyebabkan kemacetan tersebut. Microsoft mengirimkan berbagai alat performa seperti Visual Studio Profiler dan PerfView.

PerfView adalah alat canggih yang membantu Anda fokus pada masalah mendalam seperti I/O disk, peristiwa GC, dan memori. Anda dapat merekam Peristiwa Tracing for Windows (ETW) terkait performa dan melihat dengan mudah per aplikasi, per proses, per tumpukan, dan informasi per rangkaian. PerfView menunjukkan kepada Anda berapa banyak dan jenis memori yang dialokasikan aplikasi Anda, dan fungsi atau tumpukan panggilan mana yang berkontribusi pada alokasi memori. Untuk detailnya, lihat topik bantuan, demo, dan video yang banyak yang disertakan dengan alat ini.

Fakta 4: Tentang alokasi

Anda mungkin berpikir bahwa membangun aplikasi .NET Framework yang responsif adalah tentang algoritma, seperti menggunakan pengurutan cepat alih-alih pengurutan gelembung, tetapi bukan itu masalahnya. Faktor terbesar dalam membangun aplikasi responsif adalah mengalokasikan memori, terutama saat aplikasi Anda sangat besar atau memproses data dalam jumlah besar.

Hampir semua pekerjaan untuk membangun pengalaman IDE responsif dengan API kompiler baru melibatkan menghindari alokasi dan mengelola strategi penembolokan. Jejak PerfView menunjukkan bahwa performa kompiler C# dan Visual Basic baru jarang terikat dengan CPU. Kompiler dapat terikat I/O saat membaca ratusan ribu atau jutaan baris kode, membaca metadata, atau memancarkan kode yang dihasilkan. Penundaan rangkaian antarmuka pengguna hampir semuanya karena pengumpulan sampah. .NET Framework GC sangat disesuaikan dengan performa dan melakukan sebagian besar pekerjaannya secara bersamaan saat kode aplikasi dijalankan. Namun, alokasi tunggal dapat memicu kumpulan gen2 yang mahal, menghentikan semua rangkaian.

Alokasi umum dan contoh

Contoh ekspresi di bagian ini memiliki alokasi tersembunyi yang tampak kecil. Namun, jika aplikasi besar mengeksekusi ekspresi cukup lama, mereka dapat menyebabkan alokasi ratusan megabyte, bahkan gigabyte. Misalnya, pengujian satu menit yang mensimulasikan pengetikan pengembang di editor mengalokasikan gigabyte memori dan mengarahkan tim performa untuk fokus pada skenario pengetikan.

Tinju

Boxing terjadi ketika jenis nilai yang biasanya hidup di tumpukan atau dalam struktur data dibungkus dalam objek. Artinya, Anda mengalokasikan objek untuk menyimpan data, lalu mengembalikan pointer ke objek. .NET Framework terkadang memberikan nilai box karena tanda tangan metode atau jenis lokasi penyimpanan. Membungkus jenis nilai dalam suatu objek menyebabkan alokasi memori. Banyak operasi boxing dapat menyumbangkan megabyte atau gigabyte alokasi ke aplikasi Anda, yang berarti bahwa aplikasi Anda akan menyebabkan lebih banyak GC. .NET Framework dan kompiler bahasa menghindari boxing jika memungkinkan, tetapi terkadang hal itu terjadi saat Anda tidak mengharapkannya.

Untuk melihat boxing di PerfView, buka jejak dan lihat GC Heap Alloc Stacks di bawah nama proses aplikasi Anda (ingat, PerfView melaporkan semua proses). Jika Anda melihat jenis seperti System.Int32 dan System.Char di bawah alokasi, Anda adalah jenis nilai boxing. Memilih salah satu dari jenis ini akan menunjukkan tumpukan dan fungsi di mana mereka dibox.

Contoh 1: metode string dan argumen jenis nilai

Kode contoh ini menggambarkan boxing yang berpotensi tidak perlu dan berlebihan:

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

Kode ini menyediakan fungsi pengelogan, sehingga aplikasi mungkin sering memanggil fungsi Log, mungkin jutaan kali. Masalahnya adalah bahwa panggilan ke string.Format diselesaikan ke Format(String, Object, Object) kelebihan beban.

Kelebihan ini memerlukan .NET Framework untuk memasukkan nilai int ke dalam objek untuk meneruskannya ke pemanggilan metode ini. Perbaikan sebagian adalah memanggil id.ToString() dan size.ToString() dan meneruskan semua string (yang merupakan objek) ke panggilan string.Format. Memanggil ToString() memang mengalokasikan string, tetapi alokasi itu akan tetap terjadi di dalam string.Format.

Anda mungkin menganggap bahwa panggilan dasar ke string.Format ini hanyalah perangkaian string, jadi Anda dapat menulis kode ini sebagai gantinya:

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

Namun, baris kode tersebut memperkenalkan alokasi boxing karena dikompilasi ke Concat(Object, Object, Object). .NET Framework harus memasukkan karakter harfiah untuk memanggil Concat

Perbaikan untuk contoh 1

Perbaikan lengkapnya sederhana. Ganti saja karakter harfiah dengan string harfiah, yang tidak menimbulkan boxing karena string sudah menjadi objek:

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

Contoh 2: enum boxing

Contoh ini bertanggung jawab atas sejumlah besar alokasi dalam kompiler C# dan Visual Basic yang baru karena seringnya penggunaan tipe enumerasi, terutama dalam operasi pencarian kamus.

public enum Color
{
    Red, Green, Blue
}

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

Masalah ini sangat tidak kentara. PerfView akan melaporkan ini sebagai boxing GetHashCode() karena metode box representasi yang mendasari jenis enumerasi, untuk alasan implementasi. Jika Anda melihat lebih dekat di PerfView, Anda mungkin melihat dua alokasi boxing untuk setiap panggilan ke GetHashCode(). Kompilator menyisipkan satu, dan .NET Framework menyisipkan yang lain.

Perbaikan untuk contoh 2

Anda dapat dengan mudah menghindari kedua alokasi dengan mentransmisikan ke representasi yang mendasarinya sebelum memanggil GetHashCode():

((int)color).GetHashCode()

Sumber umum boxing lainnya pada jenis enumerasi adalah metode Enum.HasFlag(Enum). Argumen yang diteruskan ke HasFlag(Enum) harus dibox. Dalam kebanyakan kasus, mengganti panggilan ke Enum.HasFlag(Enum) dengan tes bitwise lebih sederhana dan bebas alokasi.

Ingatlah fakta performa pertama (yaitu, jangan optimalkan sebelum waktunya) dan jangan mulai menulis ulang semua kode Anda dengan cara ini. Waspadai biaya boxing ini, tetapi ubah kode Anda hanya setelah membuat profil aplikasi Anda dan menemukan hot spot.

String

Manipulasi string adalah beberapa penyebab terbesar alokasi, dan sering muncul di PerfView di lima alokasi teratas. Program menggunakan string untuk serialisasi, JSON, dan REST API. Anda dapat menggunakan string sebagai konstanta terprogram untuk beroperasi dengan sistem saat Anda tidak dapat menggunakan tipe enumerasi. Saat pembuatan profil Anda menunjukkan bahwa string sangat memengaruhi performa, cari panggilan ke metode String seperti Format, Concat, Split, Join, Substring, dan seterusnya. Menggunakan StringBuilder untuk menghindari biaya pembuatan satu string dari banyak bagian membantu, tetapi bahkan mengalokasikan objek StringBuilder mungkin menjadi hambatan yang perlu Anda kelola.

Contoh 3: operasi string

Kompiler C# memiliki kode ini yang menulis teks komentar dokumen XML yang diformat:

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 { /* ... */ }

Anda dapat melihat bahwa kode ini melakukan banyak manipulasi string. Kode menggunakan metode pustaka untuk memisahkan baris menjadi string terpisah, memangkas spasi, memeriksa apakah argumen text adalah komentar dokumentasi XML, dan mengekstrak substring dari baris.

Pada baris pertama di dalam WriteFormattedDocComment, panggilan text.Split mengalokasikan larik tiga elemen baru sebagai argumen setiap kali dipanggil. Kompiler harus mengeluarkan kode untuk mengalokasikan larik ini setiap kali. Itu karena kompilator tidak tahu apakah Split menyimpan larik di suatu tempat di mana larik mungkin dimodifikasi oleh kode lain, yang akan memengaruhi panggilan selanjutnya ke WriteFormattedDocComment. Panggilan ke Split juga mengalokasikan string untuk setiap baris di text dan mengalokasikan memori lain untuk melakukan operasi.

WriteFormattedDocComment memiliki tiga panggilan ke metode TrimStart. Dua berada di perulangan dalam yang menduplikasi pekerjaan dan alokasi. Lebih buruk lagi, memanggil metode TrimStart tanpa argumen akan mengalokasikan larik kosong (untuk parameter params) selain hasil string.

Terakhir, ada panggilan ke metode Substring, yang biasanya mengalokasikan string baru.

Perbaikan untuk contoh 3

Tidak seperti contoh sebelumnya, pengeditan kecil tidak dapat memperbaiki alokasi ini. Anda perlu mundur, melihat masalahnya, dan mendekatinya secara berbeda. Misalnya, Anda akan melihat bahwa argumen ke WriteFormattedDocComment() adalah string yang memiliki semua informasi yang dibutuhkan metode, sehingga kode dapat melakukan lebih banyak pengindeksan daripada mengalokasikan banyak string parsial.

Tim performa kompiler menangani semua alokasi ini dengan kode seperti ini:

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

Versi pertama WriteFormattedDocComment() mengalokasikan larik, beberapa substring, dan substring yang dipangkas bersama dengan larik params kosong. Itu juga memeriksa "///". Kode yang direvisi hanya menggunakan pengindeksan dan tidak mengalokasikan apa pun. Ia menemukan karakter pertama yang bukan spasi, dan kemudian memeriksa karakter demi karakter untuk melihat apakah string dimulai dengan "///". Kode baru menggunakan IndexOfFirstNonWhiteSpaceChar alih-alih TrimStart untuk mengembalikan indeks pertama (setelah indeks awal yang ditentukan) di mana karakter non-spasi muncul. Perbaikan belum selesai, tetapi Anda dapat melihat cara menerapkan perbaikan serupa untuk solusi lengkap. Dengan menerapkan pendekatan ini di seluruh kode, Anda dapat menghapus semua alokasi di WriteFormattedDocComment().

Contoh 4: StringBuilder

Contoh ini menggunakan objek StringBuilder. Fungsi berikut menghasilkan nama jenis lengkap untuk jenis generik:

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

Fokusnya adalah pada baris yang membuat instans StringBuilder baru. Kode menyebabkan alokasi untuk sb.ToString() dan alokasi internal dalam implementasi StringBuilder, tetapi Anda tidak dapat mengontrol alokasi tersebut jika Anda menginginkan hasil string.

Perbaikan untuk contoh 4

Untuk memperbaiki alokasi objek StringBuilder, simpan objek dalam cache. Bahkan melakukan penembolokan satu instans yang mungkin dibuang dapat meningkatkan performa secara signifikan. Ini adalah implementasi baru fungsi, menghilangkan semua kode kecuali untuk baris pertama dan terakhir yang baru:

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

Bagian kuncinya adalah fungsi AcquireBuilder() dan GetStringAndReleaseBuilder() baru:

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

Karena kompiler baru menggunakan rangkaian, implementasi ini menggunakan bidang thread-statis (atribut ThreadStaticAttribute) untuk menyimpan cache StringBuilder, dan Anda mungkin dapat melupakan deklarasi ThreadStatic. Bidang thread-statis menyimpan nilai unik untuk setiap thread yang mengeksekusi kode ini.

AcquireBuilder() mengembalikan instans yang dilakukan penembolkan StringBuilder jika ada, setelah menghapusnya dan mengatur bidang atau penembolakn ke null. Jika tidak, AcquireBuilder() membuat instans baru dan mengembalikannya, membiarkan bidang atau cache disetel ke null.

Setelah selesai dengan StringBuilder, Anda memanggil GetStringAndReleaseBuilder() untuk mendapatkan hasil string, menyimpan instans StringBuilder di bidang atau cache, lalu mengembalikan hasilnya. Eksekusi dimungkinkan untuk memasukkan kembali kode ini dan membuat beberapa objek StringBuilder (walaupun jarang terjadi). Kode hanya menyimpan instans StringBuilder yang terakhir dirilis untuk digunakan nanti. Strategi penembolokan sederhana ini secara signifikan mengurangi alokasi di kompiler baru. Bagian dari .NET Framework dan MSBuild ("MSBuild") menggunakan teknik serupa untuk meningkatkan performa.

Strategi penembolokan sederhana ini menganut desain penembolokan yang baik karena memiliki batasan ukuran. Namun, ada lebih banyak kode sekarang daripada yang asli, yang berarti lebih banyak biaya pemeliharaan. Anda harus mengadopsi strategi penyimpanan penembolokan hanya jika Anda menemukan masalah performa, dan PerfView telah menunjukkan bahwa alokasi StringBuilder merupakan kontributor yang signifikan.

LINQ dan lambda

Language-Integrated Query (LINQ), bersama dengan ekspresi lambda, adalah contoh fitur produktivitas. Namun, penggunaannya mungkin berdampak signifikan pada performa dari waktu ke waktu, dan Anda mungkin perlu menulis ulang kode Anda.

Contoh 5: Lambdas, List<T>, dan IEnumerable<T>

Contoh ini menggunakan LINQ and functional style code untuk menemukan simbol dalam model kompiler, diberi string nama:

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

Kompiler baru dan pengalaman IDE yang dibangun di atasnya sering memanggil FindMatchingSymbol(), dan ada beberapa alokasi tersembunyi dalam satu baris kode fungsi ini. Untuk memeriksa alokasi tersebut, pertama bagilah satu baris kode fungsi menjadi dua baris:

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

Di baris pertama, ekspresis => s.Name == name lambda menutup variabel namelokal . Ini berarti bahwa selain mengalokasikan objek untuk delegate yang dipegang predicate, kode tersebut juga mengalokasikan kelas statis untuk menampung lingkungan yang menangkap nilai name. Kompiler menghasilkan kode seperti berikut:

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

Dua alokasi new (satu untuk kelas lingkungan dan satu untuk delegate) sekarang eksplisit.

Sekarang lihat panggilan ke FirstOrDefault. Metode ekstensi pada jenis System.Collections.Generic.IEnumerable<T> ini juga menimbulkan alokasi. Karena FirstOrDefault mengambil objek IEnumerable<T> sebagai argumen pertamanya, Anda dapat memperluas panggilan ke kode berikut (sedikit disederhanakan untuk diskusi):

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

Variabel symbols memiliki jenis List<T>. Jenis koleksi List<T> mengimplementasikan IEnumerable<T> dan dengan cerdik mendefinisikan enumerator (IEnumerator<T> antarmuka) yang mengimplementasikan List<T> dengan struct. Menggunakan struktur alih-alih kelas berarti Anda biasanya menghindari alokasi tumpukan, yang, pada gilirannya, dapat memengaruhi performa pengumpulan sampah. Enumerator biasanya digunakan dengan perulangan foreach bahasa, yang menggunakan struktur enumerator seperti yang dikembalikan pada tumpukan panggilan. Menaikkan penunjuk tumpukan panggilan untuk memberi ruang bagi objek tidak memengaruhi GC seperti halnya alokasi tumpukan.

Dalam kasus panggilan FirstOrDefault yang diperluas, kode harus memanggil GetEnumerator() pada IEnumerable<T>. Menetapkan symbols ke variabel enumerable berjenis IEnumerable<Symbol> akan menghilangkan informasi bahwa objek sebenarnya adalah List<T>. Ini berarti bahwa ketika kode mengambil enumerator dengan enumerable.GetEnumerator(), .NET Framework harus mengemas struktur yang dikembalikan untuk menetapkannya ke variabel enumerator.

Perbaikan untuk contoh 5

Cara mengatasinya adalah dengan menulis ulang FindMatchingSymbol sebagai berikut, mengganti satu baris kodenya dengan enam baris kode yang masih ringkas, mudah dibaca dan dipahami, serta mudah dipelihara:

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

Kode ini tidak menggunakan metode ekstensi LINQ, lambda, atau enumerator, dan tidak menimbulkan alokasi. Tidak ada alokasi karena kompilator dapat melihat bahwa koleksi symbols adalah List<T> dan dapat mengikat enumerator yang dihasilkan (struktur) ke variabel lokal dengan tipe yang tepat untuk menghindari boxing. Versi asli dari fungsi ini adalah contoh yang bagus dari kekuatan ekspresif C# dan produktivitas .NET Framework. Versi baru dan lebih efisien ini mempertahankan kualitas tersebut tanpa menambahkan kode rumit apa pun untuk dipelihara.

Penembolokan metode asinkron

Contoh berikutnya menunjukkan masalah umum saat Anda mencoba menggunakan hasil cache dalam metode async.

Contoh 6: penembolokan dalam metode asinkron

Fitur Visual Studio IDE yang dibangun di atas kompiler C# dan Visual Basic yang baru sering kali mengambil pohon sintaks, dan kompiler menggunakan asinkron saat melakukannya untuk menjaga Visual Studio tetap responsif. Inilah versi pertama dari kode yang mungkin Anda tulis untuk mendapatkan pohon sintaks:

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

Anda dapat melihat bahwa pemanggilan GetSyntaxTreeAsync() membuat instans Parser, mem-parsing kode, dan kemudian mengembalikan objek Task, Task<SyntaxTree>. Bagian yang mahal adalah mengalokasikan instans Parser dan mem-parsing kode. Fungsi mengembalikan Task sehingga pemanggil dapat menunggu pekerjaan penguraian dan membebaskan rangkaian antarmuka pengguna agar responsif terhadap masukan pengguna.

Beberapa fitur Visual Studio mungkin mencoba untuk mendapatkan pohon sintaks yang sama, jadi Anda dapat menulis kode berikut ke cache hasil parsing untuk menghemat waktu dan alokasi. Namun, kode ini menimbulkan alokasi:

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

Anda melihat bahwa kode baru dengan penembolokan memiliki bidang SyntaxTree bernama cachedResult. Jika bidang ini null, GetSyntaxTreeAsync() berfungsi dan menyimpan hasilnya dalam cache. GetSyntaxTreeAsync() mengembalikan objek SyntaxTree. Masalahnya adalah ketika Anda memiliki fungsi async berjenis Task<SyntaxTree>, dan Anda mengembalikan nilai bertipe SyntaxTree, kompiler mengeluarkan kode untuk mengalokasikan Tugas untuk menampung hasilnya (dengan menggunakan Task<SyntaxTree>.FromResult()). Tugas ditandai sebagai selesai, dan hasilnya segera tersedia. Dalam kode untuk kompiler baru, objek Task yang sudah selesai sering terjadi sehingga memperbaiki alokasi ini meningkatkan responsivitas secara nyata.

Perbaikan untuk contoh 6

Untuk menghapus alokasi Task yang telah selesai, Anda dapat menyimpan objek Tugas dalam cache dengan hasil yang telah selesai:

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

Kode ini mengubah jenis cachedResult menjadi Task<SyntaxTree> dan menggunakan fungsi pembantu async yang menyimpan kode asli dari GetSyntaxTreeAsync(). GetSyntaxTreeAsync() sekarang menggunakan null coalescing operator untuk mengembalikan cachedResult jika tidak null. Jika cachedResult adalah null, maka GetSyntaxTreeAsync() memanggil GetSyntaxTreeUncachedAsync() dan menyimpan hasilnya dalam tembolokan. Perhatikan bahwa GetSyntaxTreeAsync() tidak menunggu panggilan ke GetSyntaxTreeUncachedAsync() seperti kode biasanya. Tidak menggunakan menunggu berarti ketika GetSyntaxTreeUncachedAsync() mengembalikan objek Task-nya, GetSyntaxTreeAsync() segera mengembalikan Task. Sekarang, hasil yang ditembolok adalah Task, jadi tidak ada alokasi untuk mengembalikan hasil yang ditembolok.

Pertimbangan tambahan

Berikut adalah beberapa poin lagi tentang potensi masalah di aplikasi besar atau aplikasi yang memproses banyak data.

Kamus

Kamus digunakan di mana-mana di banyak program, dan meskipun kamus sangat nyaman dan efisien secara inheren. Namun, kamus sering digunakan secara tidak tepat. Di Visual Studio dan kompiler baru, analisis menunjukkan bahwa banyak kamus berisi satu elemen atau kosong. Dictionary<TKey,TValue> kosong memiliki sepuluh bidang dan menempati 48 byte di heap pada mesin x86. Kamus sangat bagus saat Anda membutuhkan pemetaan atau struktur data asosiatif dengan pencarian waktu konstan. Namun, bila Anda hanya memiliki beberapa elemen, Anda membuang banyak ruang dengan menggunakan kamus. Sebagai gantinya, misalnya, Anda dapat melihat melalui List<KeyValuePair\<K,V>> secara berulang, sama cepatnya. Jika Anda menggunakan kamus hanya untuk memuatnya dengan data dan kemudian membacanya (pola yang sangat umum), menggunakan larik yang diurutkan dengan pencarian N(log(N)) mungkin hampir sama cepatnya, tergantung pada jumlah elemen yang sedang Anda gunakan.

Kelas vs. struktur

Di satu sisi, kelas dan struktur memberikan tradeoff ruang/waktu klasik untuk mengatur aplikasi Anda. Kelas dikenakan overhead 12 byte pada mesin x86 bahkan jika mereka tidak memiliki bidang, tetapi mereka tidak mahal untuk diedarkan karena hanya membutuhkan pointer untuk merujuk ke instans kelas. Struktur tidak menimbulkan alokasi tumpukan jika tidak dibox, tetapi ketika Anda meneruskan struktur besar sebagai argumen fungsi atau mengembalikan nilai, CPU membutuhkan waktu untuk menyalin semua anggota data struktur secara atomik. Hati-hati terhadap panggilan berulang ke properti yang mengembalikan struktur, dan simpan nilai properti dalam variabel lokal untuk menghindari penyalinan data yang berlebihan.

Penembolokan

Trik performa yang umum adalah menembolok hasil. Namun, tembolokan tanpa batas ukuran atau kebijakan pembuangan dapat menjadi kebocoran memori. Saat memproses data dalam jumlah besar, jika Anda menyimpan banyak memori dalam tembolokan, Anda dapat menyebabkan pengumpulan sampah mengesampingkan manfaat pencarian yang di-cache.

Dalam artikel ini, kami membahas bagaimana Anda harus menyadari gejala kemacetan kinerja yang dapat memengaruhi respons aplikasi Anda, terutama untuk sistem besar atau sistem yang memproses data dalam jumlah besar. Penyebab umum termasuk boxing, manipulasi string, LINQ dan lambda, penembolokan dalam metode asinkron, penembolokan tanpa batas ukuran atau kebijakan pembuangan, penggunaan kamus yang tidak tepat, dan melewati struktur. Ingatlah empat fakta untuk mengatur aplikasi Anda:

  • Jangan optimalkan sebelum waktunya – jadilah produktif dan sesuaikan aplikasi Anda saat Anda menemukan masalah.

  • Profil tidak berbohong – Anda menebak jika Anda tidak mengukur.

  • Alat yang bagus membuat semua perbedaan – unduh PerfView dan cobalah.

  • Ini semua tentang alokasi – di situlah tim platform kompiler menghabiskan sebagian besar waktu mereka untuk meningkatkan performa kompiler baru.

Lihat juga