Управление на паметта и ресурсите


Финализацията на обекти в .NET



страница3/7
Дата25.02.2017
Размер0.85 Mb.
#15739
1   2   3   4   5   6   7

Финализацията на обекти в .NET


Както видяхме, garbage collector ни освобождава напълно от грижите по управлението и почистването на паметта. В света на .NET, вече са невъзможни изтичането на памет и обръщението към вече унищожен обект, две особено неприятни грешки, които много трудно се откриват и проследяват и могат да превърнат поддръжката или дебъгването на една система в кошмар за програмистите.

За радост, повечето от обектите, с които нашите приложения ще работят, изискват само памет за да са функционални. Например Int32, Double, String и Hashtable са типове, които съхраняват и манипулират байтове от паметта. За тези и за много други обекти, спокойно можем да оставим да се погрижи garbage collector. Не е необходимо да извършваме каквито и да било действия по почистването на ресурсите, заети от тях. Вместо това, ние просто създаваме обекта, използваме неговата функционалност и когато вече не ни трябва, можем да бъдем сигурни, че в по-късен етап garbage collector ще се погрижи да освободи заетата от него памет.

При други обекти, обаче, нещата са малко по-сложни. Например типът System.IO.FileStream вътрешно съдържа файлов манипулатор, който се използва от методите му Read() и Write(). По подобен начин, System.Data.OleDb.OleDbConnection капсулира връзка към база от данни, а System.Net.Sockets.Socket – мрежов сокет.

За всички подобни обекти, капсулиращи някакъв ценен системен ресурс, трябва да се вземат специални мерки, тъй като сам по себе си, garbage collector не може да освобождава тези ресурси. Това е отговорност на самия обект. Именно в тази ситуация на помощ идва финализацията.


Какво е финализация?


Накратко, финализацията позволява да се почистват ресурси, свързани с даден обект, преди обектът да бъде унищожен от garbage collector. Обяснено най-просто, това е начин да се каже на CLR “преди този обект да бъде унищожен, трябва да се изпълни ето този код”.

За да е възможно това, класът трябва да имплементира специален метод, наречен Finalize(). Когато garbage collector установи, че даден обект вече не се използва от приложението, той проверява дали обектът дефинира Finalize() метод. Ако това е така, Finalize() се изпълнява и на по-късен етап (най-рано при следващото преминаване на garbage collector), обектът се унищожава. Този процес ще бъде разгледан детайлно след малко. Засега просто трябва да запомните две неща:



  • Finalize() не може да се извиква явно. Този метод се извиква само от системата за почистване на паметта, когато тя прецени, че даденият обект е отпадък.

  • Най-малко две преминавания на garbage collector са необходими за да се унищожи обект, дефиниращ Finalize() метод. При първото се установява че обектът подлежи на унищожение и се изпълнява финализаторът, а при второто се освобождава и заетата от обекта памет. Всъщност в реалния живот почти винаги са необходими повече от две събирания на garbage collector поради преминаването на обекта в по-горно поколение.

Деструкторите в C#


В .NET, класът System.Object дефинира Finalize() метод. Ако искаме да осигурим финализатор за нашия клас, бихме използвали следния код:

protected override void Finalize()

{

try



{

// Cleanup code goes here

}

finally


{

base.Finalize();

}

}


Както виждате, това, което правим, е да предефинираме Finalize() метода на класа System.Object (спомнете си, че всички типове в .NET наследяват System.Object). Използваме конструкцията try … finally за да се подсигурим, че независимо какъв е резултатът от изпълнението на почистващия код, ще бъде извикан Finalize() методът на родителския обект.



Забележете, че макар System.Object да дефинира Finalize() метод, за да поддържа финализация, вашият клас, или някой от родителските му типове трябва да припокрива Finalize() метода (чрез използването на деструктор). Т.е. ако Finalize() методът на вашия клас е този, наследен от System.Object, то инстанциите на класа няма да поддържат финализация.

Всъщност, ако се опитате да компилирате показания по-горе код, ще получите следното съобщение за грешка от C# компилатора:

Do not override object.Finalize. Instead, provide a destructor.

Дефиниране на деструктори в C#


Екипът, разработвал C# компилатора, установява, че много програмисти не имплементират Finalize() правилно. По-специално, мнозина забравят да използват try … finally блок и да извикат base.Finalize(). Поради тази причина, в C# не може Finalize() да се имплементира явно. Вместо това се използват деструктори, които имат следния специален синтак­сис:

~MyClass ()

{

// Cleanup code goes here



}

Този код се преобразува от компилатора във Finalize() метод, по такъв начин, че става напълно еквивалентен на предишния (т.е. автоматично се добавя try…finally и се извиква base.Finalize() във finally блока).



Забележете, че макар документацията на C# да използва терминът деструктор, а синтаксисът да е еквивалентен на деструкторите в C++, всъщност приликата свършва до тук.

В C# деструкторите се преобразуват във Finalize() методи, които се извикват от системата за почистване на паметта. Унищожаването на обектите не е детерми­нис­тично и програмистът няма възможност да определи кога и в какъв ред се изпълняват финализаторите. При някои специални обстоятелства дори няма гаранция, че те изобщо ще се изпълнят. Запомнете: общото между деструкторите в C# и тези в C++ се изчерпва със синтаксиса.

Финализация – пример


Нека обобщим казаното досега в един по-завършен пример. В кода показан по-долу, дефинираме клас, който капсулира някакъв Windows ресурс (манипулатор към който се съхранява в член-променливата mResourceHandle):

using System;
// Wrapper around Windows resource

class ResourceWrapper

{

private IntPtr mResourceHandle = IntPtr.Zero;


public ResourceWrapper()

{

// Allocate the resource here



}
~ResourceWrapper()

{

if (mResourceHandle != IntPtr.Zero)



{

// Deallocate the resource here

// ...

mResourceHandle = IntPtr.Zero;



}

}

}



Забележете, че кодът, показан тук, е просто пример как трябва да се дефинира деструктор, но не е правилният начин за освобождаване на системни ресурси. По причини, които ще изясним след малко, не е ефективно да се разчита само на финализацията, когато трябва да се освободи системен ресурс. По-нататък, в секцията “Ръчно управление на ресурсите с интерфейса IDisposable” ще дадем пример как точно трябва да се подходи в такъв случай.

Зад кулисите


Нека сега разгледаме малко по-подробно какво всъщност се случва, когато дефинираме деструктор в кода на нашия клас. В тази секция ще изложим кратко описание на процесите, които протичат зад кулисите, когато CLR изпълнява кода. След това ще дадем някои препоръки, свърза­ни с използването на Finalize() методи.

И така, CLR поддържа две структури, които са свързани с финализацията. Това са т.нар. Finalization List и Freachable Queue.

Когато се създава нов обект, CLR проверява дали типът дефинира Finalize() метод и ако това е така, след създаването на обекта в динамичната памет (но преди извикването на неговия конструктор), указател към обекта се добавя към Finalization list. Така Finalization list съдържа указатели към всички обекти в хийпа, които трябва да бъдат финализирани (имат Finalize() методи), но все още се използват от приложението (или вече не се използват, но още не са проверени от garbage collector).



Създаването на обект, поддържащ финализация изисква една допълнителна операция от страна на CLR – поста­вянето на указател във Finalization list и следова­телно отнема и малко повече време.

Взаимодействието на garbage collector с обектите, нуждаещи се от фина­лизация, е твърде интересно. Нека разгледаме следния пример.

Фигурата по-долу показва опростена схема на състоянието на динамич­ната памет точно преди да започне почистване на паметта. Виждаме че хийпът съдържа три обекта – A, B и C. Нека всички те са от Поколение 0. Обект A все още се използва от приложението, така че той ще оцелее при преминаването на garbage collector. Обекти B и C, обаче, са недостъпни от корените и се определят от garbage collector-a като отпадъци.



Когато даден обект се идентифицира като отпадък, garbage collector проверява дали във Finalization list съществува указател към този обект. Когато такъв указател няма (какъвто е случаят с обект C), неговата памет просто може да се освободи по начина, вече описан в секцията “Как работи garbage collector?”.

Когато обаче във Finalization list се намери такъв указател (както в случая с обект B), garbage collector не може просто да унищожи обекта, тъй като преди това трябва да се извика неговия Finalize() метод. Вместо това, указателят към обекта ще бъде изтрит от Finalization list и ще бъде добавен към Freachable queue.

Състоянието на динамичната памет непосредствено след приключването на събирането на отпадъци е следното:



На фигурата впечатление правят две неща:



  • Обект C е унищожен и паметта, заемана от него, може да се използва повторно от приложението.

  • Указателят към обект B е преместен от Finalization list във Freachable queue, а самият обект продължава да “живее” в динамичната памет и тъй като е оцелял при преминаването на garbage collector, вече е част от Поколение 1.

Опашката Freachable


Опашката Freachable съдържа указатели към всички обекти, чиито Finalize() методи вече могат да се извикат. Името на тази опашка всъщност означава следното: F е съкратено от Finalization – всеки елемент от опашката е указател към обект, който трябва да се финализира, а reachable (достъпен) означава, че обектът е достъпен от приложението. Всеки обект, за който има запис във Freachable queue е достъпен от приложението и не е отпадък. Т.е. Freachable queue се счита за част от корените на приложението, както например са глобалните и статични променливи.

Накратко за финализацията


И така, garbage collector първо определя обект B като недостъпен и следователно – подлежащ на почистване. След това указателят към обект B се изтрива от Finalization list и се добавя към опашката Freachable. В този момент обектът се съживява, т.е. той се добавя към графа на достъпните обекти и вече не се счита за отпадък. Garbage collector пренарежда динамичната памет. При това обект B се третира както всеки друг достъпен от приложението обект, в нашия пример – обект A.

След това CLR стартира специална нишка с висок приоритет, която за всеки запис във Freachable queue изпълнява Finalize() метода на съответния обект и след това изтрива записа от опашката.

При следващото почистване на Поколение 1 от garbage collector, обект B ще бъде третиран като недостъпен (защото записът вече е изтрит от Freachable queue и никой от корените на приложението не сочи към обекта) и паметта, заемана от него ще бъде освободена. Забележете, че тъй като обектът вече е в по-високо поколение, преди това да се случи е възможно да минат още няколко преминавания на garbage collector.

Тъмната страна на финализацията





Люк: Не съм уплашен.




Йода: Добре. Ще бъдеш. Ще бъдеш.

Финализацията е неефективна


Обектите, поддържащи финализация, престояват в паметта значително по-дълго от останалите обекти. Освен това, представете си, че обектът B от горния пример държи референции към други обекти. Тези обекти (и обектите, реферирани пряко или непряко от тях) също ще се съживят и ще останат в паметта, докато живее и обект B. Това означава, че garbage collector не може да освободи паметта в момента, когато установи че обектите са отпадъци.

Неправилно е да се разчита (само) на финализацията, за освобождаване на системни ресурси. Помнете, че нямате контрол върху това кога ще се изпълнят финализаторите. Това може да доведе до ненужно дълго задър­жане на ресурсите заети.


Проблеми с нишките


Finalize() методите се изпълняват от отделна нишка на CLR. Следова­телно, във финализаторите не трябва да се пише код, който прави каквито и да било предположения относно нишката в която се изпълнява.

Освен това, трябва да се избягва код, отнемащ много време. В най-лошия случай, ако даден Finalize() метод влезе в безкраен цикъл, нишката, изпълняваща финализаторите ще се блокира и останалите Finalize() методи няма да се изпълнят. Това е много опасна ситуация, защото докато приложението работи, garbage collector няма да може да освободи заеманата от тези обекти памет.

При прекратяване на работата на приложението, когато CLR се изключва, на всеки Finalize() метод се дават приблизително две секунди, за да се изпълни. Ако методът не завърши изпълнението си за това време, CLR просто убива процеса и не изпълнява повече финализатори. Освен това, CLR дава приблизително четиридесет секунди за да се изпълнят всички Finalize() методи. След като това време мине, процесът се убива.

Проблеми с реда на изпълнение на финализаторите


CLR не дава никакви гаранции за реда, в който ще се извикат отделните финализатори. Това означава, че не е безопасно във Finalize() метод да се обръщате към друг обект, поддържащ финализация, защото неговият финализатор може вече да е бил изпълнен и състоянието на обекта в този случай е непредвидимо. Също, не е безопасно да извиквате статични методи. Тези методи вътрешно могат да използват обекти, които вече са били финализирани и резултатите отново са непредсказуеми.

Какво да правим?


След всичко казано дотук, може би вече се чудите, за какво можете да използвате Finalize() методите. И след като има толкова много неща които не трябва или не е ефективно да се правят във финализацията, какво да правим когато се налага да освободим някакъв ресурс?

Microsoft препоръчва използването на финализацията да става съвместно с имплементирането на интерфейса IDisposable.





Не разчитайте само на финализацията за да освобожда­вате ресурси. Имплементирайте IDisposable и използвайте Finalize() методите съвместно с него.

Съживяване на обекти





Смъртта е естествена част от живота. Радвайте се за онези които се превръщат в част от Силата. Не ги оплаквайте. Нека не ви липсват. Привързването води до ревност. То е сянка на алчността. Йода

Както видяхме, цикълът на живот на обект, нуждаещ се от финализация е интересен. Обектът умира, след това референция към него се добавя към един от корените на приложението (Freachable queue) при което обектът се съживява, неговият Finalize() метод се изпълнява, указателят се изтрива от Freachable queue и обектът умира завинаги. В по-късен етап, garbage collector просто ще освободи заетата от този “мъртъв” обект памет.

Какво ще се случи, обаче, ако по време на финализацията обектът се добави към някой от корените на приложението?

Разгледайте следния код:

public class ClassThatResurrects

{

~ClassThatResurrects()



{

SomeRootClass.mThisIsARoot = this;

}

}
public class SomeRootClass



{

public static object mThisIsARoot;

}


В този пример по време на финализацията, обект от типа ClassThatResurrects ще добави референция към себе си в един от корените на приложението – статичната член-променлива mThisIsARoot. Така обектът става достъпен и garbage collector няма да го унищожи. Приложението може да използва обекта чрез променливата mThisIsARoot докато тя сочи към него. Това е интересен случай на съживяване на обект. Виждаме, че като .NET програмисти имаме почти “свръхестестве­ната” сила да връщаме и използваме обекти от света на мъртвите.

Когато в някакъв момент на mTisIsARoot се присвои указател към друг обект, или просто null, съживеният обект отново умира и ще бъде почистен (някога) от garbage collector. В този случай обектът няма повече да се финализира, тъй като неговият Finalize() метод вече е бил извикан веднъж и указател към обекта вече не съществува във Finalization list.

Ако все пак искаме Finalize() методът да се изпълни отново, garbage collector предлага статичният метод ReRegisterForFinalize(), който приема един единствен параметър – референция към обект. Извикването на този метод добавя указател към обекта във Finalization list. Когато garbage collector прецени, че обектът е отпадък, указателят ще бъде преместен във Freachable queue и Finalize() метода ще бъде извикан отново. Ето и пример:

~ClassThatResurrects()

{

SomeRootClass.thisIsARoot = this;



GC.ReRegisterForFinalize(this);

}


Всъщност при написан по този начин деструктор, обектът ще се съживява всеки път когато се извика неговият Finalize() метод и докато приложе­нието работи, той никога няма да умре. Когато приложението прекратява работата си, CLR ще изчака определено време и след това просто ще убие процеса. В едно реално приложение вероятно бихте правили някаква проверка, преди да пререгистрирате обекта за финализация.

Също, никога не извиквайте ReRegisterForFinalize() повече от веднъж в деструктора, това ще доведе до многократното му изпълнение при следващото почистване.

Например този код ще доведе до това че деструкторът ще се изпълни два пъти при следващото съживяване на обекта (четири пъти при следващото, осем след това и т.н.). Никога не правете това в реална програма:

~ClassThatResurrects()

{

SomeRootClass.thisIsARoot = this;



GC.ReRegisterForFinalize(this);

GC.ReRegisterForFinalize(this);

}


Причината за това поведение на кода е, че ReRegisterForFinalize() просто добавя указател към обекта във Finalization list без да проверява дали такъв вече съществува.

Трябва да се помни, че използването на съживяване не е препоръчи­телна практика и трябва да се избягва винаги, когато има възможност. Всъщност макар да звучи интересно и вълнуващо, случаите, в които прилагането на съживяване е добра идея, са доста малко. Използването на вече финализирани обекти може да има непредсказуеми резултати. Ако например съживеният обект вътрешно се обръща към други обекти, изискващи финализация, Finalize() методите на някои от тях могат вече да са били изпълнени. Освен това, всички обекти, към които даденият обект има референции, също ще се съживят и ще заемат памет от хийпа.





Сподели с приятели:
1   2   3   4   5   6   7




©obuch.info 2024
отнасят до администрацията

    Начална страница