
Modèle jetable (principe de conception jetable)
Je suppose que presque tous les programmeurs qui utilisent .NET diront maintenant que ce modèle est un morceau de gâteau. Que c'est le modèle le plus connu utilisé sur la plateforme. Cependant, même le domaine problématique le plus simple et le plus connu aura des zones secrètes que vous n'avez jamais examinées. Décrivons donc le tout depuis le début pour les débutants et tout le reste (afin que chacun de vous se souvienne des bases). Ne sautez pas ces paragraphes - je vous regarde!
Si je demande ce qui est IDisposable, vous direz sûrement que c'est
public interface IDisposable { void Dispose(); }
Quel est le but de l'interface? Je veux dire, pourquoi devons-nous effacer la mémoire du tout si nous avons un garbage collector intelligent qui efface la mémoire à notre place, donc nous n'avons même pas à y penser. Cependant, il y a quelques petits détails.
Ce chapitre a été traduit du russe conjointement par l'auteur et par des traducteurs professionnels . Vous pouvez nous aider avec la traduction du russe ou de l'anglais dans n'importe quelle autre langue, principalement en chinois ou en allemand.
Aussi, si vous voulez nous remercier, la meilleure façon de le faire est de nous donner une étoile sur github ou sur fork repository
github / sidristij / dotnetbook .
Il existe une idée fausse selon laquelle IDisposable
sert à libérer des ressources non gérées. Ceci n'est que partiellement vrai et pour le comprendre, il vous suffit de vous souvenir des exemples de ressources non gérées. La classe File
est-elle une ressource non gérée? Non. Peut-être que DbContext
est une ressource non gérée? Non, encore une fois. Une ressource non gérée est quelque chose qui n'appartient pas au système de type .NET. Quelque chose que la plateforme n'a pas créé, quelque chose qui existe hors de sa portée. Un exemple simple est un descripteur de fichier ouvert dans un système d'exploitation. Un handle est un nombre qui identifie de manière unique un fichier ouvert - non, pas par vous - par un système d'exploitation. Autrement dit, toutes les structures de contrôle (par exemple, la position d'un fichier dans un système de fichiers, les fragments de fichiers en cas de fragmentation et d'autres informations de service, les numéros d'un cylindre, d'une tête ou d'un secteur d'un disque dur) sont à l'intérieur d'un système d'exploitation mais pas Plateforme .NET. La seule ressource non gérée transmise à la plateforme .NET est le numéro IntPtr. Ce nombre est encapsulé par FileSafeHandle, qui est à son tour encapsulé par la classe File. Cela signifie que la classe File n'est pas une ressource non gérée à elle seule, mais utilise une couche supplémentaire sous la forme d'IntPtr pour inclure une ressource non gérée - le handle d'un fichier ouvert. Comment lisez-vous ce fichier? Utilisation d'un ensemble de méthodes sous WinAPI ou Linux OS.
Les primitives de synchronisation dans les programmes multithread ou multiprocesseurs sont le deuxième exemple de ressources non gérées. Ici appartiennent des tableaux de données qui sont passés par P / Invoke et aussi des mutex ou des sémaphores.
Notez que le système d'exploitation ne transmet pas simplement le handle d'une ressource non gérée à une application. Il enregistre également cette poignée dans le tableau des poignées ouvertes par le processus. Ainsi, le système d'exploitation peut fermer correctement les ressources après la fin de l'application. Cela garantit que les ressources seront fermées de toute façon après avoir quitté l'application. Cependant, la durée d'exécution d'une application peut être différente, ce qui peut entraîner un verrouillage des ressources long.
Ok Maintenant, nous avons couvert les ressources non gérées. Pourquoi devons-nous utiliser IDisposable dans ces cas? Parce que .NET Framework n'a aucune idée de ce qui se passe en dehors de son territoire. Si vous ouvrez un fichier à l'aide de l'API OS, .NET n'en saura rien. Si vous allouez une plage de mémoire à vos propres besoins (par exemple en utilisant VirtualAlloc), .NET ne saura rien non plus. S'il ne le sait pas, il ne libérera pas la mémoire occupée par un appel VirtualAlloc. Ou, il ne fermera pas un fichier ouvert directement via un appel API OS. Ceux-ci peuvent entraîner des conséquences différentes et inattendues. Vous pouvez obtenir OutOfMemory si vous allouez trop de mémoire sans la libérer (par exemple, simplement en définissant un pointeur sur null). Ou, si vous ouvrez un fichier sur un partage de fichiers via le système d'exploitation sans le fermer, vous verrouillerez le fichier sur ce partage de fichiers pendant une longue période. L'exemple de partage de fichiers est particulièrement bon car le verrou restera du côté IIS même après la fermeture d'une connexion avec un serveur. Vous n'avez pas le droit de libérer le verrou et vous devrez demander aux administrateurs d'effectuer iisreset
ou de fermer la ressource manuellement à l'aide d'un logiciel spécial.
Ce problème sur un serveur distant peut devenir une tâche complexe à résoudre.
Tous ces cas nécessitent un protocole universel et familier pour l'interaction entre un système de type et un programmeur. Il doit clairement identifier les types qui nécessitent une fermeture forcée. L'interface IDisposable sert exactement ce but. Il fonctionne de la manière suivante: si un type contient l'implémentation de l'interface IDisposable, vous devez appeler Dispose () après avoir fini de travailler avec une instance de ce type.
Il existe donc deux façons standard de l'appeler. Habituellement, vous créez une instance d'entité pour l'utiliser rapidement dans une méthode ou pendant la durée de vie de l'instance d'entité.
La première façon consiste à encapsuler une instance en using(...){ ... }
. Cela signifie que vous demandez de détruire un objet une fois le bloc lié à l'utilisation terminé, c'est-à-dire d'appeler Dispose (). La deuxième façon consiste à détruire l'objet, lorsque sa durée de vie est terminée, avec une référence à l'objet que nous voulons libérer. Mais .NET n'a rien d'autre qu'une méthode de finalisation qui implique la destruction automatique d'un objet, non? Cependant, la finalisation ne convient pas du tout car nous ne savons pas quand elle sera appelée. Pendant ce temps, nous devons libérer un objet à un certain moment, par exemple juste après avoir fini de travailler avec un fichier ouvert. C'est pourquoi nous devons également implémenter IDisposable et appeler Dispose pour libérer toutes les ressources que nous possédions. Ainsi, nous suivons le protocole , et c'est très important. Parce que si quelqu'un le suit, tous les participants doivent faire de même pour éviter les problèmes.
Différentes façons de mettre en œuvre IDisposable
Examinons les implémentations d'IDisposable du simple au compliqué. La première et la plus simple consiste à utiliser IDisposable tel quel:
public class ResourceHolder : IDisposable { DisposableResource _anotherResource = new DisposableResource(); public void Dispose() { _anotherResource.Dispose(); } }
Ici, nous créons une instance d'une ressource qui est ensuite publiée par Dispose (). La seule chose qui rend cette implémentation incohérente est que vous pouvez toujours travailler avec l'instance après sa destruction par Dispose()
:
public class ResourceHolder : IDisposable { private DisposableResource _anotherResource = new DisposableResource(); private bool _disposed; public void Dispose() { if(_disposed) return; _anotherResource.Dispose(); _disposed = true; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CheckDisposed() { if(_disposed) { throw new ObjectDisposedException(); } } }
CheckDisposed () doit être appelé comme première expression dans toutes les méthodes publiques d'une classe. La structure de classe ResourceHolder
obtenue semble bonne pour détruire une ressource non managée, qui est DisposableResource
. Toutefois, cette structure n'est pas adaptée à une ressource non gérée encapsulée. Regardons l'exemple avec une ressource non gérée.
public class FileWrapper : IDisposable { IntPtr _handle; public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Dispose() { CloseHandle(_handle); } [DllImport("kernel32.dll", EntryPoint = "CreateFile", SetLastError = true)] private static extern IntPtr CreateFile(String lpFileName, UInt32 dwDesiredAccess, UInt32 dwShareMode, IntPtr lpSecurityAttributes, UInt32 dwCreationDisposition, UInt32 dwFlagsAndAttributes, IntPtr hTemplateFile); [DllImport("kernel32.dll", SetLastError=true)] private static extern bool CloseHandle(IntPtr hObject); }
Quelle est la différence de comportement des deux derniers exemples? Le premier décrit l'interaction de deux ressources gérées. Cela signifie que si un programme fonctionne correctement, la ressource sera quand même libérée. Étant donné que DisposableResource
est géré, .NET CLR le sait et en libérera la mémoire si son comportement est incorrect. Notez que je ne suppose pas consciemment ce que le type DisposableResource
encapsule. Il peut y avoir n'importe quel type de logique et de structure. Il peut contenir à la fois des ressources gérées et non gérées. Cela ne devrait pas nous concerner du tout . Personne ne nous demande de décompiler les bibliothèques de tiers à chaque fois et de voir s'ils utilisent des ressources gérées ou non gérées. Et si notre type utilise une ressource non gérée, nous ne pouvons pas l'ignorer. Nous le faisons dans la classe FileWrapper
. Alors, que se passe-t-il dans ce cas? Si nous utilisons des ressources non gérées, nous avons deux scénarios. Le premier est lorsque tout est OK et que Dispose est appelé. Le deuxième est lorsque quelque chose se passe mal et que Dispose échoue.
Disons tout de suite pourquoi cela peut mal tourner:
- Si nous utilisons
using(obj) { ... }
, une exception peut apparaître dans un bloc de code interne. Cette exception est interceptée par finally
bloc, que nous ne pouvons pas voir (il s'agit du sucre syntaxique de C #). Ce bloc appelle implicitement Dispose. Cependant, il y a des cas où cela ne se produit pas. Par exemple, ni catch
ni intercepter finally
StackOverflowException
. Vous devez toujours vous en souvenir. Parce que si un thread devient récursif et que StackOverflowException
se produit à un moment donné, .NET oubliera les ressources qu'il a utilisées mais non publiées. Il ne sait pas comment libérer des ressources non gérées. Ils resteront en mémoire jusqu'à ce que le système d'exploitation les libère, c'est-à-dire lorsque vous quittez un programme, ou même quelque temps après la fin d'une application. - Si nous appelons Dispose () à partir d'un autre Dispose (). Encore une fois, il se peut que nous n'arrivions pas à y parvenir. Ce n'est pas le cas d'un développeur d'applications distrait, qui a oublié d'appeler Dispose (). C'est la question des exceptions. Cependant, ce ne sont pas seulement les exceptions qui bloquent un thread d'une application. Ici, nous parlons de toutes les exceptions qui empêcheront un algorithme d'appeler un Dispose () externe qui appellera notre Dispose ().
Tous ces cas créeront des ressources non gérées suspendues. C'est parce que Garbage Collector ne sait pas qu'il doit les collecter. Tout ce qu'il peut faire lors de la prochaine vérification est de découvrir que la dernière référence à un graphique d'objet avec notre type FileWrapper
est perdue. Dans ce cas, la mémoire sera réallouée pour les objets avec références. Comment l'empêcher?
Nous devons implémenter le finaliseur d'un objet. Le «finaliseur» est ainsi nommé exprès. Ce n'est pas un destructeur comme cela peut sembler à cause de façons similaires d'appeler les finaliseurs en C # et les destructeurs en C ++. La différence est qu'un finaliseur sera appelé de toute façon , contrairement à un destructeur (ainsi que Dispose()
). Un finaliseur est appelé lorsque le Garbage Collection est lancé (maintenant il suffit de le savoir, mais les choses sont un peu plus compliquées). Il est utilisé pour une libération garantie des ressources en cas de problème . Nous devons implémenter un finaliseur pour libérer les ressources non gérées. Encore une fois, comme un finaliseur est appelé lorsque GC est lancé, nous ne savons pas quand cela se produit en général.
Développons notre code:
public class FileWrapper : IDisposable { IntPtr _handle; public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Dispose() { InternalDispose(); GC.SuppressFinalize(this); } private void InternalDispose() { CloseHandle(_handle); } ~FileWrapper() { InternalDispose(); } /// other methods }
Nous avons amélioré l'exemple avec les connaissances sur le processus de finalisation et sécurisé l'application contre la perte d'informations sur les ressources si Dispose () n'est pas appelé. Nous avons également appelé GC. SuppressFinalize pour désactiver la finalisation de l'instance du type si Dispose () est appelé avec succès. Il n'est pas nécessaire de libérer deux fois la même ressource, non? Ainsi, nous réduisons également la file d'attente de finalisation en lâchant une région aléatoire de code susceptible de s'exécuter avec la finalisation en parallèle, quelque temps plus tard. Maintenant, améliorons encore plus l'exemple.
public class FileWrapper : IDisposable { IntPtr _handle; bool _disposed; public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Dispose() { if(_disposed) return; _disposed = true; InternalDispose(); GC.SuppressFinalize(this); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CheckDisposed() { if(_disposed) { throw new ObjectDisposedException(); } } private void InternalDispose() { CloseHandle(_handle); } ~FileWrapper() { InternalDispose(); } /// other methods }
Maintenant, notre exemple d'un type qui encapsule une ressource non gérée semble complet. Malheureusement, le second Dispose()
est en fait un standard de la plateforme et nous permettons de l'appeler. Notez que les gens autorisent souvent le deuxième appel de Dispose()
pour éviter les problèmes avec un code d'appel et c'est faux. Cependant, un utilisateur de votre bibliothèque qui consulte la documentation MS peut ne pas le penser et autorisera plusieurs appels de Dispose (). L'appel à d'autres méthodes publiques détruira de toute façon l'intégrité d'un objet. Si nous détruisons l'objet, nous ne pouvons plus travailler avec lui. Cela signifie que nous devons appeler CheckDisposed
au début de chaque méthode publique.
Cependant, ce code contient un problème grave qui l'empêche de fonctionner comme prévu. Si nous nous souvenons du fonctionnement du ramasse-miettes, nous remarquerons une fonctionnalité. Lors de la collecte des ordures, GC finalise principalement tout ce qui est hérité directement d' Object . Ensuite, il traite des objets qui implémentent CriticalFinalizerObject . Cela devient un problème car les deux classes que nous avons conçues héritent de Object. Nous ne savons pas dans quel ordre ils arriveront au «dernier kilomètre». Cependant, un objet de niveau supérieur peut utiliser son finaliseur pour finaliser un objet avec une ressource non gérée. Bien que cela ne semble pas être une excellente idée. L'ordre de finalisation serait très utile ici. Pour le définir, le type de niveau inférieur avec une ressource non gérée encapsulée doit être hérité de CriticalFinalizerObject
.
La deuxième raison est plus profonde. Imaginez que vous osiez écrire une application qui ne prend pas grand soin de la mémoire. Il alloue de la mémoire en grande quantité, sans encaissement ni autres subtilités. Un jour, cette application se bloquera avec OutOfMemoryException. Lorsqu'il se produit, le code s'exécute spécifiquement. Il ne peut rien allouer, car cela entraînera une exception répétée, même si la première est interceptée. Cela ne signifie pas que nous ne devrions pas créer de nouvelles instances d'objets. Même un simple appel de méthode peut lever cette exception, par exemple celle de la finalisation. Je vous rappelle que les méthodes sont compilées lorsque vous les appelez pour la première fois. Il s'agit d'un comportement habituel. Comment éviter ce problème? Assez facilement. Si votre objet est hérité de CriticalFinalizerObject , toutes les méthodes de ce type seront compilées immédiatement lors du chargement en mémoire. De plus, si vous marquez des méthodes avec l'attribut [PrePrepareMethod] , elles seront également précompilées et seront sécurisées pour appeler dans une situation de faibles ressources.
Pourquoi est-ce important? Pourquoi consacrer trop d'efforts à ceux qui décèdent? Parce que les ressources non gérées peuvent être suspendues dans un système pendant longtemps. Même après avoir redémarré un ordinateur. Si un utilisateur ouvre un fichier à partir d'un partage de fichiers dans votre application, le premier sera verrouillé par un hôte distant et libéré à l'expiration du délai ou lorsque vous libérez une ressource en fermant le fichier. Si votre application se bloque à l'ouverture du fichier, elle ne sera pas publiée même après le redémarrage. Vous devrez attendre longtemps jusqu'à ce que l'hôte distant le libère. De plus, vous ne devez pas autoriser d'exceptions dans les finaliseurs. Cela conduit à un crash accéléré du CLR et d'une application car vous ne pouvez pas envelopper l'appel d'un finaliseur dans try ... catch . Je veux dire, lorsque vous essayez de publier une ressource, vous devez être sûr qu'elle peut être publiée. Dernier fait non moins important: si le CLR décharge anormalement un domaine, les finaliseurs de types, dérivés de CriticalFinalizerObject, seront également appelés, contrairement à ceux hérités directement d' Object .
Ce charper traduit du russe comme de la langue de l'auteur par des traducteurs professionnels . Vous pouvez nous aider à créer une version traduite de ce texte dans n'importe quelle autre langue, y compris le chinois ou l'allemand, en utilisant les versions russe et anglaise du texte comme source.
De plus, si vous voulez dire "merci", la meilleure façon que vous pouvez choisir est de nous donner une étoile sur github ou un référentiel de forking
https://github.com/sidristij/dotnetbook