Pisanie bezpiecznego i wydajnego kodu w języku C#
Język C# udostępnia funkcje, które umożliwiają pisanie weryfikowalnego bezpiecznego kodu o lepszej wydajności. Jeśli dokładnie zastosujesz te techniki, mniejsza liczba scenariuszy wymaga niebezpiecznego kodu. Te funkcje ułatwiają używanie odwołań do typów wartości jako argumentów metody i zwracanych metod. Po bezpiecznym zakończeniu te techniki minimalizują kopiowanie typów wartości. Korzystając z typów wartości, można zminimalizować liczbę alokacji i przejść odzyskiwania pamięci.
Większość przykładowego kodu w tym artykule używa funkcji dodanych w języku C# 7.2. Aby korzystać z tych funkcji, upewnij się, że projekt nie jest skonfigurowany do używania wcześniejszej wersji. Aby uzyskać więcej informacji, zobacz konfigurowanie wersji językowej.
Jedną z zalet korzystania z typów wartości jest to, że często unikają alokacji sterty. Wadą jest to, że są kopiowane według wartości. Ten kompromis utrudnia optymalizowanie algorytmów działających na dużych ilościach danych. Funkcje językowe wyróżnione w tym artykule zawierają mechanizmy, które umożliwiają bezpieczne wydajne kod przy użyciu odwołań do typów wartości. Użyj tych funkcji, aby zminimalizować zarówno alokacje, jak i operacje kopiowania.
Niektóre wskazówki zawarte w tym artykule odnoszą się do praktyk kodowania, które są zawsze zalecane, nie tylko dla korzyści z wydajności. Użyj słowa kluczowego readonly , gdy dokładnie wyraża intencję projektowania:
- Zadeklaruj niezmienne struktury jako
readonly. - Zadeklaruj
readonlyelementy członkowskie dla modyfikowanych struktur.
W tym artykule wyjaśniono również niektóre optymalizacje niskiego poziomu, które są zalecane podczas uruchamiania profilera i zidentyfikowały wąskie gardła:
- Użyj modyfikatora parametrów
in. - Instrukcje użycia
ref readonly return. - Użyj
ref structtypów. - Użyj
ninttypów inuint.
Te techniki równoważą dwa konkurencyjne cele:
Zminimalizuj alokacje na stercie.
Zmienne, które są typami referencyjnymi , przechowują odwołanie do lokalizacji w pamięci i są przydzielane na zarządzanym stosie. Odwołanie jest kopiowane tylko wtedy, gdy typ odwołania jest przekazywany jako argument do metody lub zwracany z metody. Każdy nowy obiekt wymaga nowej alokacji, a później musi zostać odzyskany. Odzyskiwanie pamięci zajmuje trochę czasu.
Zminimalizuj kopiowanie wartości.
Zmienne, które są typami wartości , zawierają bezpośrednio ich wartość, a wartość jest zwykle kopiowana podczas przekazywania do metody lub zwracanej z metody. To zachowanie obejmuje kopiowanie wartości
thispodczas wywoływania iteratorów i metod wystąpienia asynchronicznego struktur. Operacja kopiowania trwa w zależności od rozmiaru typu.
W tym artykule użyto następującej przykładowej koncepcji struktury punktu 3D, aby wyjaśnić jego zalecenia:
public struct Point3D
{
public double X;
public double Y;
public double Z;
}
Różne przykłady używają różnych implementacji tej koncepcji.
Deklarowanie niezmiennych struktur jako readonly
Zadeklaruj element , readonly struct aby wskazać, że typ jest niezmienny. Modyfikator readonly informuje kompilator, że twoim zamiarem jest utworzenie niezmiennego typu. Kompilator wymusza podjęcie decyzji projektowej przy użyciu następujących reguł:
- Wszystkie elementy członkowskie pól muszą być tylko do odczytu.
- Wszystkie właściwości muszą być tylko do odczytu, w tym właściwości zaimplementowane automatycznie.
Te dwie reguły są wystarczające, aby zapewnić, że żaden element członkowski readonly struct nie modyfikuje stanu tej struktury. Element struct jest niezmienny. Struktura Point3D może być zdefiniowana jako niezmienialna struktura, jak pokazano w poniższym przykładzie:
readonly public struct ReadonlyPoint3D
{
public ReadonlyPoint3D(double x, double y, double z)
{
this.X = x;
this.Y = y;
this.Z = z;
}
public double X { get; }
public double Y { get; }
public double Z { get; }
}
Postępuj zgodnie z tą rekomendacją za każdym razem, gdy intencją projektu jest utworzenie niezmiennego typu wartości. Wszelkie ulepszenia wydajności są dodatkową korzyścią. Słowa readonly struct kluczowe wyraźnie wyrażają twoją intencję projektowania.
Deklarowanie readonly elementów członkowskich dla struktur modyfikowalnych
W języku C# 8.0 lub nowszym, gdy typ struktury jest modyfikowalny, zadeklaruj elementy członkowskie, które nie modyfikują stanu jako readonly elementów członkowskich.
Rozważ inną aplikację, która wymaga struktury punktu 3D, ale musi obsługiwać możliwośćmutowania. Następująca wersja struktury punktu 3D dodaje readonly modyfikator tylko do tych elementów członkowskich, które nie modyfikują struktury. Postępuj zgodnie z tym przykładem, gdy projekt musi obsługiwać modyfikacje struktury przez niektórych członków, ale nadal chcesz korzystać z zalet wymuszania readonly na niektórych elementach członkowskich:
public struct Point3D
{
public Point3D(double x, double y, double z)
{
_x = x;
_y = y;
_z = z;
}
private double _x;
public double X
{
readonly get => _x;
set => _x = value;
}
private double _y;
public double Y
{
readonly get => _y;
set => _y = value;
}
private double _z;
public double Z
{
readonly get => _z;
set => _z = value;
}
public readonly double Distance => Math.Sqrt(X * X + Y * Y + Z * Z);
public readonly override string ToString() => $"{X}, {Y}, {Z}";
}
W poprzednim przykładzie pokazano wiele lokalizacji, w których można zastosować readonly modyfikator: metody, właściwości i metody dostępu do właściwości. Jeśli używasz właściwości zaimplementowanych automatycznie, kompilator dodaje readonly modyfikator do get metody dostępu dla właściwości odczytu i zapisu. Kompilator dodaje readonly modyfikator do automatycznie implementowanych deklaracji właściwości dla właściwości z tylko akcesorem get .
readonly Dodanie modyfikatora do elementów członkowskich, które nie są w staniemutacji, zapewnia dwie powiązane korzyści. Najpierw kompilator wymusza intencję. Ten element członkowski nie może zmutować stanu struktury. Po drugie kompilator nie utworzy kopii defensywnych parametrówin podczas uzyskiwania dostępu do elementu readonly członkowskiego. Kompilator może bezpiecznie przeprowadzić tę optymalizację, ponieważ gwarantuje, że struct element nie jest modyfikowany przez element członkowski readonly .
Instrukcje use ref readonly return
Użyj zwrotu ref readonly , gdy oba następujące warunki są spełnione:
- Zwracana
structwartość jest większa niż IntPtr.Size. - Okres istnienia magazynu jest większy niż metoda zwracająca wartość.
Wartości można zwrócić przy użyciu odwołania, gdy zwracana wartość nie jest lokalna do metody zwracanej. Zwracanie przez odwołanie oznacza, że tylko odwołanie jest kopiowane, a nie struktura. W poniższym przykładzie właściwość nie może użyć zwracanej ref wartości, Origin ponieważ zwracana wartość jest zmienną lokalną:
public Point3D Origin => new Point3D(0,0,0);
Jednak następująca definicja właściwości może zostać zwrócona przez odwołanie, ponieważ zwracana wartość jest statycznym elementem członkowskim:
public struct Point3D
{
private static Point3D origin = new Point3D(0,0,0);
// Dangerous! returning a mutable reference to internal storage
public ref Point3D Origin => ref origin;
// other members removed for space
}
Nie chcesz, aby wywołujący modyfikowali źródło, dlatego należy zwrócić wartość przez ref readonly:
public struct Point3D
{
private static Point3D origin = new Point3D(0,0,0);
public static ref readonly Point3D Origin => ref origin;
// other members removed for space
}
ref readonly Powrót umożliwia zapisanie kopii większych struktur i zachowanie niezmienności wewnętrznych elementów członkowskich danych.
W lokacji wywołań wywołujący dokonają wyboru, aby użyć Origin właściwości jako ref readonly wartości lub :
var originValue = Point3D.Origin;
ref readonly var originReference = ref Point3D.Origin;
Pierwsze przypisanie w poprzednim kodzie tworzy kopię stałej Origin i przypisuje ją. Drugi przypisuje odwołanie. Zwróć uwagę, że readonly modyfikator musi być częścią deklaracji zmiennej. Nie można zmodyfikować odwołania do odwołania. Próby wykonania tej czynności powodują błąd w czasie kompilacji.
Modyfikator readonly jest wymagany w deklaracji originReference.
Kompilator wymusza, że obiekt wywołujący nie może zmodyfikować odwołania. Próbuje przypisać wartość bezpośrednio wygeneruj błąd czasu kompilacji. W innych przypadkach kompilator przydziela kopię defensywną , chyba że może bezpiecznie korzystać z odwołania tylko do odczytu. Reguły analizy statycznej określają, czy można zmodyfikować strukturę. Kompilator nie tworzy kopii defensywnej, gdy struktura jest elementem readonly struct członkowskim lub jest readonly członkiem struktury. Kopie defensywne nie są potrzebne do przekazania struktury jako argumentu in .
Używanie modyfikatora parametrów in
W poniższych sekcjach wyjaśniono, co in robi modyfikator, jak go używać i kiedy należy go używać do optymalizacji wydajności:
- Słowa
outkluczowe ,refiin - Używanie
inparametrów dla dużych struktur - Opcjonalne użycie elementu w lokacji
inpołączenia - Unikaj kopii obronnych
Słowa outkluczowe , refi in
Słowo in kluczowe uzupełnia ref słowa kluczowe i out w celu przekazania argumentów według odwołania. Słowo in kluczowe określa, że argument jest przekazywany przez odwołanie, ale wywołana metoda nie modyfikuje wartości. in Modyfikator można zastosować do dowolnego elementu członkowskiego, który przyjmuje parametry, takie jak metody, delegaty, lambda, funkcje lokalne, indeksatory i operatory.
Dzięki dodaniu słowa kluczowego in język C# zapewnia pełne słownictwo do wyrażania intencji projektowania. Typy wartości są kopiowane po przekazaniu do wywoływanej metody, gdy nie określisz żadnego z następujących modyfikatorów w podpisie metody. Każdy z tych modyfikatorów określa, że zmienna jest przekazywana przez odwołanie, unikając kopiowania. Każdy modyfikator wyraża inną intencję:
out: Ta metoda ustawia wartość argumentu używanego jako ten parametr.ref: Ta metoda może zmodyfikować wartość argumentu użytego jako ten parametr.in: Ta metoda nie modyfikuje wartości argumentu użytego jako ten parametr.
Dodaj modyfikator w in celu przekazania argumentu przy użyciu odwołania i zadeklaruj intencję projektowania, aby przekazać argumenty przez odwołanie, aby uniknąć niepotrzebnego kopiowania. Nie zamierzasz modyfikować obiektu użytego jako tego argumentu.
in Modyfikator uzupełnia out i ref na inne sposoby. Nie można tworzyć przeciążeń metody, które różnią się tylko obecnością inmetody , outlub ref. Te nowe reguły rozszerzają to samo zachowanie, które zawsze było definiowane dla out parametrów i .ref Podobnie jak modyfikatory out i ref , typy wartości nie są pola, ponieważ in modyfikator jest stosowany. Inną funkcją parametrów in jest możliwość użycia wartości literału lub stałych dla argumentu do parametru in .
Modyfikator in może być również używany z typami referencyjnymi lub wartościami liczbowymi. Jednak korzyści w tych przypadkach są minimalne, jeśli istnieją.
Istnieje kilka sposobów, na które kompilator wymusza charakter argumentu in tylko do odczytu. Przede wszystkim wywołana metoda nie może bezpośrednio przypisać do parametru in . Nie można bezpośrednio przypisać go do żadnego pola parametru in , gdy ta wartość jest typem struct . Ponadto nie można przekazać parametru in do żadnej metody przy użyciu ref modyfikatora lub out . Te reguły dotyczą dowolnego pola parametru in , pod warunkiem, że pole jest typem struct , a parametr jest również typem struct . W rzeczywistości te reguły dotyczą wielu warstw dostępu do składowych, pod warunkiem że typy na wszystkich poziomach dostępu do składowych to structs. Kompilator wymusza, że struct typy przekazywane jako in argumenty, a ich struct składowe są zmiennymi tylko do odczytu, gdy są używane jako argumenty do innych metod.
Używanie in parametrów dla dużych struktur
Modyfikator można zastosować in do dowolnego readonly struct parametru, ale ta praktyka może poprawić wydajność tylko dla typów wartości, które są znacznie większe niż IntPtr.Size. W przypadku typów prostych (takich jak sbyte, , intshortbyteuintlongushortdoublefloatulongchari decimal , i boolenum typy) wszelkie potencjalne zyski wydajności są minimalne. Niektóre proste typy, takie jak decimal w rozmiarze 16 bajtów, są większe niż odwołania 4 bajtów lub 8 bajtów, ale nie wystarczy, aby wymiernie zmienić wydajność w większości scenariuszy. Wydajność może obniżyć wydajność przy użyciu odwołania przekazywanego dla typów mniejszych niż IntPtr.Size.
Poniższy kod przedstawia przykład metody, która oblicza odległość między dwoma punktami w przestrzeni 3D.
private static double CalculateDistance(in Point3D point1, in Point3D point2)
{
double xDifference = point1.X - point2.X;
double yDifference = point1.Y - point2.Y;
double zDifference = point1.Z - point2.Z;
return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}
Argumenty są dwiema strukturami, które zawierają trzy podwójne. Podwójna wartość to 8 bajtów, więc każdy argument to 24 bajty. in Określając modyfikator, przekazujesz odwołanie 4-bajtowe lub 8-bajtowe do tych argumentów, w zależności od architektury maszyny. Różnica w rozmiarze jest mała, ale może się sumować, gdy aplikacja wywołuje tę metodę w ścisłej pętli przy użyciu wielu różnych wartości.
Jednak wpływ optymalizacji niskiego poziomu, takich jak użycie in modyfikatora, powinien być mierzony w celu zweryfikowania korzyści z wydajności. Można na przykład pomyśleć, że użycie in parametru guid byłoby korzystne. Typ Guid to 16 bajtów rozmiaru, dwa razy większy niż odwołanie 8 bajtów. Jednak taka niewielka różnica nie może spowodować mierzalnych korzyści z wydajności, chyba że jest to metoda, która jest w czasie krytycznej ścieżki gorącej dla aplikacji.
Opcjonalne użycie w lokacji in połączenia
W przeciwieństwie do parametru refin lub out nie trzeba stosować modyfikatora w lokacji wywołania. Poniższy kod przedstawia dwa przykłady wywoływania CalculateDistance metody. Pierwsza używa dwóch zmiennych lokalnych przekazanych przez odwołanie. Druga zawiera zmienną tymczasową utworzoną w ramach wywołania metody.
var distance = CalculateDistance(pt1, pt2);
var fromOrigin = CalculateDistance(pt1, new Point3D());
Pominięcie in modyfikatora w lokacji wywołania informuje kompilator, że może utworzyć kopię argumentu z dowolnego z następujących powodów:
- Istnieje niejawna konwersja, ale nie konwersja tożsamości z typu argumentu na typ parametru.
- Argument jest wyrażeniem, ale nie ma znanej zmiennej magazynu.
- Istnieje przeciążenie, które różni się obecnością lub brakiem .
inW takim przypadku przeciążenie wartości według jest lepszym dopasowaniem.
Te reguły są przydatne podczas aktualizowania istniejącego kodu w celu używania argumentów referencyjnych tylko do odczytu. Wewnątrz wywoływanej metody można wywołać dowolną metodę wystąpienia, która używa parametrów według wartości. W tych przypadkach tworzona jest kopia parametru in .
Ponieważ kompilator może utworzyć zmienną tymczasową dla dowolnego parametru, można również określić wartości domyślne dla dowolnego inin parametru. Poniższy kod określa źródło (punkt 0,0,0) jako wartość domyślną drugiego punktu:
private static double CalculateDistance2(in Point3D point1, in Point3D point2 = default)
{
double xDifference = point1.X - point2.X;
double yDifference = point1.Y - point2.Y;
double zDifference = point1.Z - point2.Z;
return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}
Aby wymusić przekazanie argumentów tylko do odczytu przez odwołanie, określ in modyfikator argumentów w lokacji wywołania, jak pokazano w poniższym kodzie:
distance = CalculateDistance(in pt1, in pt2);
distance = CalculateDistance(in pt1, new Point3D());
distance = CalculateDistance(pt1, in Point3D.Origin);
To zachowanie ułatwia wdrażanie in parametrów w dużych bazach kodu, w których możliwe są wzrosty wydajności. Najpierw dodajesz in modyfikator do podpisów metod. Następnie można dodać in modyfikator w lokacjach wywołań i utworzyć readonly struct typy, aby umożliwić kompilatorowi uniknięcie tworzenia defensywnych kopii parametrów w większej in liczbie lokalizacji.
Unikaj kopii defensywnych
struct Przekaż jako argument parametru in tylko wtedy, gdy jest zadeklarowany za pomocą readonly modyfikatora lub metoda uzyskuje dostęp tylko readonly do elementów członkowskich struktury. W przeciwnym razie kompilator musi tworzyć kopie defensywne w wielu sytuacjach, aby upewnić się, że argumenty nie sąmutowane. Rozważmy następujący przykład, który oblicza odległość punktu 3D od źródła:
private static double CalculateDistance(in Point3D point1, in Point3D point2)
{
double xDifference = point1.X - point2.X;
double yDifference = point1.Y - point2.Y;
double zDifference = point1.Z - point2.Z;
return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}
Struktura Point3Dnie jest strukturą tylko do odczytu. W treści tej metody istnieje sześć różnych wywołań dostępu do właściwości. Podczas pierwszego badania można pomyśleć, że te dostępy są bezpieczne. W końcu get akcesorium nie powinno modyfikować stanu obiektu. Ale nie ma reguły językowej, która to wymusza. Jest to tylko wspólna konwencja. Dowolny typ może zaimplementować metodę get dostępu, która zmodyfikowała stan wewnętrzny.
Bez gwarancji języka kompilator musi utworzyć tymczasową kopię argumentu przed wywołaniem żadnego elementu członkowskiego, który nie został oznaczony modyfikatorem readonly . Magazyn tymczasowy jest tworzony na stosie, wartości argumentu są kopiowane do magazynu tymczasowego, a wartość jest kopiowana do stosu dla każdego dostępu do elementu członkowskiego jako argumentu this . W wielu sytuacjach te kopie szkodzą wydajności wystarczającej, że wartość-pass-by-value jest szybsza niż odwołanie typu pass-by-read-only-reference, gdy typ argumentu nie jest a readonly struct metoda wywołuje elementy członkowskie, które nie są oznaczone readonly. Jeśli oznaczysz wszystkie metody, które nie modyfikują stanu struktury jako readonly, kompilator może bezpiecznie określić, że stan struktury nie jest modyfikowany, a kopia defensywna nie jest potrzebna.
Jeśli obliczenie odległości używa niezmiennej struktury, ReadonlyPoint3Dobiekty tymczasowe nie są potrzebne:
private static double CalculateDistance3(in ReadonlyPoint3D point1, in ReadonlyPoint3D point2 = default)
{
double xDifference = point1.X - point2.X;
double yDifference = point1.Y - point2.Y;
double zDifference = point1.Z - point2.Z;
return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}
Kompilator generuje bardziej wydajny kod podczas wywoływania elementów członkowskich elementu readonly struct. Odwołanie this , zamiast kopii odbiornika, jest zawsze parametrem przekazywanym in przez odwołanie do metody składowej. Ta optymalizacja zapisuje kopiowanie podczas używania argumentu readonly struct jako argumentu in .
Nie przekazuj typu wartości dopuszczalnej do wartości null jako argumentu in . Typ Nullable<T> nie jest zadeklarowany jako struktura tylko do odczytu. Oznacza to, że kompilator musi wygenerować kopie defensywne dla dowolnego argumentu typu wartości null przekazanego do metody przy użyciu in modyfikatora w deklaracji parametru.
Możesz zobaczyć przykładowy program, który demonstruje różnice wydajności przy użyciu narzędzia BenchmarkDotNet w naszym repozytorium przykładów w witrynie GitHub. Porównuje on przekazywanie modyfikowalnej struktury według wartości i przez odwołanie do przekazywania niezmiennej struktury według wartości i przez odwołanie. Użycie niezmiennej struktury i przekazywanie przez odwołanie jest najszybsze.
Używanie ref struct typów
Użyj elementu ref struct lub readonly ref struct, takiego jak Span<T> lub ReadOnlySpan<T>, aby pracować z blokami pamięci jako sekwencją bajtów. Pamięć używana przez zakres jest ograniczona do pojedynczej ramki stosu. To ograniczenie umożliwia kompilatorowi wykonanie kilku optymalizacji. Podstawową motywacją do tej funkcji było Span<T> i powiązane struktury. Uzyskasz ulepszenia wydajności z tych ulepszeń przy użyciu nowych i zaktualizowanych interfejsów API platformy .NET, które korzystają z Span<T> typu.
Deklarowanie struktury jako readonly ref łączy korzyści i ograniczenia deklaracji ref struct i readonly struct . Pamięć używana przez zakres odczytu jest ograniczona do pojedynczej ramki stosu, a pamięć używana przez zakres odczytu nie może zostać zmodyfikowana.
Mogą istnieć podobne wymagania dotyczące pracy z pamięcią utworzoną przy użyciu stackalloc interfejsów API międzyoperacyjności lub w przypadku korzystania z pamięci. Możesz zdefiniować własne ref struct typy dla tych potrzeb.
Używanie nint typów i nuint
Typy liczb całkowitych o rozmiarze natywnym to 32-bitowe liczby całkowite w procesie 32-bitowym lub 64-bitowych liczbach całkowitych w procesie 64-bitowym. Używaj ich do scenariuszy międzyoperacyjnych, bibliotek niskiego poziomu i optymalizacji wydajności w scenariuszach, w których matematyka całkowita jest szeroko używana.
Wnioski
Użycie typów wartości minimalizuje liczbę operacji alokacji:
- Storage dla typów wartości jest przydzielany stos dla zmiennych lokalnych i argumentów metody.
- Storage dla typów wartości, które są elementami członkowskimi innych obiektów, jest przydzielane jako część tego obiektu, a nie jako oddzielna alokacja.
- Storage dla wartości zwracanych wartości jest przydzielany stos.
Kontrastuj z typami referencyjnymi w tych samych sytuacjach:
- Storage dla typów referencyjnych jest przydzielany stertę dla zmiennych lokalnych i argumentów metody. Odwołanie jest przechowywane na stosie.
- Storage dla typów referencyjnych, które są elementami członkowskimi innych obiektów, są przydzielane oddzielnie na stercie. Obiekt zawierający przechowuje odwołanie.
- Storage dla wartości zwracanych typu odwołania jest przydzielana sterta. Odwołanie do tego magazynu jest przechowywane na stosie.
Minimalizacja alokacji wiąże się z kompromisami. Kopiujesz więcej pamięci, gdy rozmiar struct odwołania jest większy niż rozmiar odwołania. Odwołanie to zazwyczaj 64 bity lub 32 bity i zależy od procesora cpu maszyny docelowej.
Kompromisy te zwykle mają minimalny wpływ na wydajność. Jednak w przypadku dużych struktur lub większych kolekcji wpływ na wydajność zwiększa się. Wpływ może być duży w ciasnych pętlach i gorących ścieżkach dla programów.
Te ulepszenia języka C# zostały zaprojektowane pod kątem algorytmów krytycznych dla wydajności, w których minimalizacja alokacji pamięci jest głównym czynnikiem umożliwiającym osiągnięcie niezbędnej wydajności. W kodzie, który piszesz, często nie używasz tych funkcji. Te ulepszenia zostały jednak przyjęte na całym platformie .NET. W miarę jak więcej interfejsów API korzysta z tych funkcji, zobaczysz, jak poprawisz wydajność aplikacji.