Idées fausses pour les développeurs C # novices. Essayer de répondre à des questions standard

J'ai récemment eu l'occasion de discuter avec un assez grand nombre de développeurs C # novices. Beaucoup d'entre eux s'intéressent à la langue et à la plate-forme, et c'est très cool. Chez les juniors verts, l'obscurantisme est répandu à propos des choses évidentes (il suffit de lire un livre sur la mémoire). Et cela m'a également incité à créer cet article. L'article est principalement destiné aux développeurs débutants, mais je pense que de nombreux faits seront utiles aux ingénieurs praticiens. Eh bien, les erreurs les plus évidentes et les moins intéressantes, bien sûr, sont omises. Voici les plus intéressants et les plus significatifs, notamment du point de vue du passage de l'entretien.



# 1 Mantra environ 3 générations dans toutes les situations


Il s'agit plus d'une inexactitude que d'une erreur. La question du "garbage collector en C #" pour le développeur est devenue un classique et peu de gens vont commencer à répondre intelligemment au concept des générations. Cependant, pour une raison quelconque, peu de gens prêtent attention au fait que le grand et terrible ramasse-miettes fait partie de l'exécution. Par conséquent, j'aurais clairement indiqué qu'il ne s'agissait pas d'un doigt et j'aurais demandé quel type d'environnement d'exécution était impliqué. Pour la requête «garbage collector in c #», plus d'un grand nombre d'informations similaires peuvent être trouvées sur Internet. Cependant, peu de personnes mentionnent que ces informations se réfèrent au CLR / CoreCLR (en règle générale). Mais n'oubliez pas Mono, un runtime léger, flexible et embarqué qui a occupé sa niche dans le développement mobile (Unity, Xamarin) et qui est utilisé dans Blazor. Et pour les développeurs respectifs, je vous conseillerais de vous renseigner sur les détails du dispositif d'assemblage en Mono. Par exemple, à la requête «générations de garbage collector mono», vous pouvez voir qu'il n'y a que deux générations - pépinière et ancienne génération (dans le nouveau et à la mode garbage collector - SGen ).

# 2 Mantra sur 2 étapes de la collecte des ordures dans toutes les situations


Il n'y a pas si longtemps, les sources du ramasse-miettes étaient cachées à tout le monde. Cependant, l'intérêt pour la structure interne de la plateforme a toujours été. Par conséquent, les informations ont été extraites de différentes manières. Et certaines imprécisions dans la rétro-ingénierie du collecteur ont fait naître le mythe selon lequel le collecteur fonctionne en 2 étapes: le marquage et le nettoyage. Ou pire encore, 3 étapes - marquage, nettoyage, compression.

Cependant, tout a changé lorsque les gens du feu ont déclenché une guerre avec l'avènement de CoreCLR et du code source pour le collectionneur. Le code du compilateur pour CoreCLR provient entièrement de la version CLR. Personne ne l'a écrit à partir de zéro, respectivement, presque tout ce qui peut être appris du code source CoreCLR sera également vrai pour le CLR. Maintenant, pour comprendre comment quelque chose fonctionne, allez simplement dans github et trouvez-le dans le code source ou lisez- moi . Vous pouvez y voir qu'il y a 5 phases: marquage, planification, mise à jour des liens, compactage (suppression avec délocalisation) et suppression sans délocalisations (c'est difficile à traduire). Mais formellement, il peut être divisé en 3 étapes - marquage, planification, nettoyage.

Au stade du marquage, il s'avère quels objets ne doivent pas être récupérés par le collectionneur.
Au stade de la planification, divers indicateurs de l'état actuel de la mémoire sont calculés et les données nécessaires au stade du nettoyage sont collectées. Grâce aux informations reçues à ce stade, une décision est prise sur le besoin de compactage (défragmentation), il calcule également combien vous avez besoin de déplacer des objets, etc.

Et au stade du nettoyage , selon le besoin de compactage, les liens peuvent être mis à jour et compactés ou supprimés sans bouger.

# 3 L'allocation de mémoire sur le tas est aussi rapide que sur la pile


Encore une fois, l'inexactitude plutôt que le mensonge absolu. Dans le cas général, bien sûr, la différence de vitesse d'allocation de mémoire est minime. En effet, dans le meilleur des cas, avec l' allocation de pointeur de relief , l'allocation de mémoire n'est qu'un décalage de pointeur, comme sur la pile. Cependant, des facteurs tels que l'attribution d'un nouvel objet à l'ancien champ (qui affectera la barrière d'écriture , la mise à jour de la table des cartes - un mécanisme qui vous permet de suivre les liens de l'ancienne génération vers la plus jeune), la présence d'un finaliseur (vous devez ajouter le type à la file d'attente appropriée) peut affecter l'allocation de mémoire sur le tas. Il est également possible que l'objet soit enregistré dans l'un des trous libres du tas (après assemblage sans défragmentation). Et trouver un tel trou, bien que rapide, est évidemment plus lent qu'un simple changement de pointeur. Eh bien, bien sûr, chaque objet créé rapproche le prochain garbage collection. Et dans la prochaine procédure d'allocation de mémoire, cela peut arriver. Ce qui, naturellement, prendra du temps.

# 4 Définition de références, de types significatifs et de packaging à travers les concepts de pile et de tas


Bon classique, qui, heureusement, n'est pas si courant.

Le type de référence se trouve sur le tas. Significatif sur la pile. Beaucoup ont sûrement entendu ces définitions très souvent. Mais non seulement cette vérité n'est que partielle, alors définir des concepts par l'abstraction divulguée n'est pas une bonne idée. Pour toutes les définitions, je vous suggère de vous référer à la norme CLI - ECMA 335 . Tout d'abord, il convient de préciser que les types décrivent des valeurs. Ainsi, le type de référence est défini comme suit - la valeur décrite par le type de référence (lien) indique l' emplacement d' une autre valeur. Pour un type significatif, la valeur qu'il décrit est autonome (autonome). À propos de l'emplacement de ces types de mots. Il s'agit d'une abstraction divulguée que vous devez toujours connaître.

Un type significatif peut être localisé:

  1. En mémoire dynamique (tas), s'il fait partie d'un objet situé sur le tas, ou dans le cas d'un packaging;
  2. Sur la pile, s'il s'agit d'une variable locale / argument / valeur de retour de la méthode;
  3. Dans les registres, s'il permet la taille d'un type significatif et d'autres conditions.

Le type de référence, à savoir la valeur vers laquelle le lien pointe, est actuellement situé sur le tas.

Le lien lui-même peut être situé au même endroit que le type significatif.

L'emballage n'est pas non plus déterminé par les emplacements de stockage. Prenons un bref exemple.

Code C #
public struct MyStruct { public int justField; } public class MyClass { public MyStruct justStruct; } public static void Main() { MyClass instance = new MyClass(); object boxed = instance.justStruct; } 


Et le code IL correspondant pour la méthode Main

Code IL
  1: nop 2: newobj instance void C/MyClass::.ctor() 3: stloc.0 4: ldloc.0 5: ldfld valuetype C/MyStruct C/MyClass::justStruct 6: box C/MyStruct 7: stloc.1 8: ret 


Le type significatif faisant partie de la référence, il est évident qu'il sera situé sur le tas. Et la sixième ligne indique clairement que nous avons affaire à l'emballage. Par conséquent, la définition typique de «copier de la pile vers le tas» échoue.

Pour déterminer ce qu'est un package, pour commencer, il convient de dire que pour chaque type significatif, le CTS (système de type commun) définit un type de référence, qui est appelé un type compressé. Ainsi, l' empaquetage est une opération sur un type significatif qui crée la valeur du type compressé correspondant contenant une copie au niveau du bit de la valeur d'origine.

# 4 Événements - un mécanisme distinct


Les événements existent à partir de la première version du langage et les questions les concernant sont beaucoup plus courantes que les événements eux-mêmes. Cependant, cela vaut la peine de comprendre et de savoir de quoi il s'agit, car ce mécanisme vous permet d'écrire du code très peu couplé, ce qui est parfois utile.

Malheureusement, un événement est souvent compris comme un instrument, un type et un mécanisme distincts. Cela est particulièrement facilité par le type de BCL EventHandler , dont le nom suggère qu'il s'agit de quelque chose de séparé.

La définition d'un événement doit commencer par définir les propriétés. J'ai longtemps dessiné une telle analogie pour moi-même et j'ai récemment vu qu'elle était dessinée dans la spécification CLI.

La propriété définit la valeur nommée et les méthodes qui y accèdent. Cela semble assez évident. Nous passons aux événements. CTS prend en charge les événements ainsi que les propriétés, mais les méthodes d'accès sont différentes et incluent des méthodes pour s'abonner et se désinscrire d'un événement. A partir de la spécification du langage C #, la classe définit un événement ... qui rappelle une déclaration de champ avec l'ajout du mot clé event. Le type de cette déclaration doit être le type de délégué. Merci à la norme CLI pour les définitions.

Cela signifie donc que l'événement n'est rien de plus qu'un délégué qui n'expose qu'une partie des fonctionnalités des délégués - en ajoutant un autre délégué à la liste pour exécution, en le supprimant de cette liste. À l'intérieur de la classe, l'événement n'est pas différent d'un simple champ de type délégué.

# 5 Ressources gérées et non gérées. Finaliseurs et IDisposable


Il y a une confusion absolue lorsqu'il s'agit de ces ressources. Cela est largement facilité par Internet avec un millier d'articles sur la mise en œuvre correcte du modèle Dispose. En fait, il n'y a rien de criminel dans ce modèle - une méthode de modèle modifiée pour un cas spécifique. Mais la question est de savoir si cela est nécessaire. Pour une raison quelconque, certaines personnes ont un désir irrésistible de mettre en œuvre un finaliseur pour chaque éternuement. Très probablement, la raison de cela n'est pas une compréhension complète de ce qu'est une «ressource non gérée». Et les lignes sur le fait que dans les finaliseurs, en règle générale, les ressources non gérées sont libérées en raison de cette compréhension incomplète, passent et ne restent pas dans la tête.

Une ressource non gérée est une ressource qui n'est pas gérée (aussi étrange soit-elle). Une ressource gérée , à son tour, est une ressource qui est allouée et libérée automatiquement par la CLI via un processus appelé garbage collection. J'ai effrontément effacé cette définition de la norme CLI. Mais si vous essayez d'expliquer plus simplement, les ressources non gérées sont celles que le garbage collector ne connaît pas. (À strictement parler, nous pouvons fournir au collecteur des informations sur ces ressources à l'aide de GC.AddMemoryPressure et GC.RemoveMemoryPressure, cela peut affecter le réglage interne du collecteur). En conséquence, il ne pourra pas s'occuper lui-même de leur libération et nous devons donc le faire pour lui. Et il peut y avoir de nombreuses approches à cela. Et pour que le code n'éblouit pas avec la diversité de l'imagination des développeurs, 2 approches généralement acceptées sont utilisées.

  1. L'interface IDisposable (et sa version asynchrone de IAsyncDisposable). Il est surveillé par tous les analyseurs de code, il est donc difficile d'oublier son appel. Fournit une seule méthode - Éliminer. Et le support du compilateur est l'instruction using. Un excellent candidat pour le corps de la méthode Dispose est d'appeler une méthode similaire de l'un des champs de la classe ou de libérer une ressource non managée. Appelé explicitement par l'utilisateur de classe. La présence de cette interface dans la classe implique qu'à la fin du travail avec l'instance, vous devez appeler cette méthode.
  2. Finaliseur L'essentiel est l'assurance. Appelé implicitement, à une heure non définie, lors de la récupération de place. Ralentit l'allocation de mémoire, le garbage collector, prolonge la durée de vie des objets au moins jusqu'à la prochaine génération, voire plus, mais il est appelé par lui-même, même si personne ne l'a appelé. En raison de sa nature non déterministe, seules les ressources non gérées doivent y être libérées. Vous pouvez également trouver des exemples dans lesquels le finaliseur a été utilisé pour ressusciter l'objet et organiser le pool d'objets de cette manière. Cependant, une telle implémentation d'un pool d'objets est définitivement une mauvaise idée. Comme essayer de se connecter, lever des exceptions, accéder à la base de données et à des milliers d'actions similaires.

Et vous pouvez facilement imaginer la situation lors de l'écriture d'une bibliothèque critique pour les performances, qui utilise en interne des ressources non gérées, qu'elle peut être gérée simplement par la gestion compétente de cette ressource, libérant soigneusement la mémoire manuellement. Lors de l'écriture de telles bibliothèques hautes performances, la POO, le support et d'autres comme ça, passent à côté.

Et contrairement à l'affirmation selon laquelle Dispose viole le concept selon lequel le CLR fera tout pour nous, nous forcera à faire quelque chose nous-mêmes, à se souvenir de quelque chose, etc., je dirai ce qui suit. Lorsque vous travaillez avec des ressources non gérées, vous devez être prêt à ce qu'elles ne soient gérées par personne d'autre que vous. Et en général, les situations dans lesquelles ces ressources seront utilisées dans les entreprises ne sont presque jamais rencontrées. Et dans la plupart des cas, vous pouvez vous en tirer avec de merveilleuses classes wrapper, telles que SafeHandle, qui fournit une finalisation critique des ressources, empêchant leur assemblage prématuré.

Si, pour une raison ou une autre, votre application nécessite de nombreuses ressources qui nécessitent des étapes supplémentaires pour être libérées, jetez un œil à l'excellent modèle de JetBrains, Lifetime. Mais vous ne devez pas l'utiliser lorsque vous voyez le premier objet IDisposable.

# 6 Pile de flux, pile d'appels, pile informatique et
  Pile <T> 


Le dernier paragraphe a ajouté le rire pour le plaisir, je ne pense pas qu'il y ait ceux qui attribuent ce dernier aux deux précédents. Cependant, il y a beaucoup de confusion sur ce qu'est une pile de flux, une pile d'appels et une pile de calcul.

La pile d'appels est une structure de données, à savoir une pile, pour le stockage d'adresses de retour, pour le retour de fonctions. La pile d'appels est un concept plus logique. Il ne réglemente pas où et comment les informations doivent être stockées pour le retour. Il s'avère que la pile d'appels est la pile la plus courante et la plus native, c'est-à-dire Pile (blague). Des variables locales y sont stockées, des paramètres y sont passés et des adresses de retour y sont stockées lors de l'appel de l'instruction CALL et des interruptions, qui sont ensuite utilisées par l'instruction RET pour revenir de la fonction / interruption. Allez-y. L'une des principales blagues du flux est un pointeur vers l'instruction, qui est exécutée plus loin. Un thread exécute à son tour des instructions qui se combinent en fonctions. Par conséquent, chaque thread a une pile d'appels. Ainsi, il s'avère que la pile de flux est la pile d'appels. Autrement dit, la pile d'appels de ce flux. En général, il est également appelé sous d'autres noms: pile de logiciels, pile de machines.

Il a été examiné en détail dans l' article précédent .
De plus, la définition de la pile d'appels est utilisée pour indiquer la chaîne d'appels de méthodes spécifiques dans un langage particulier.

Pile de calcul (pile d'évaluation) . Comme vous le savez, le code C # est compilé en code IL, qui fait partie des DLL résultantes (dans le cas le plus général). Et juste au cœur du runtime qui absorbe nos DLL et exécute le code IL se trouve la machine de pile. Presque toutes les instructions IL fonctionnent avec une certaine pile. Par exemple, ldloc charge une variable locale sous un index spécifique sur la pile. Ici, la pile fait référence à une certaine pile virtuelle, car au final cette variable peut très probablement se trouver dans des registres. Les instructions arithmétiques, logiques et autres IL fonctionnent sur les variables de la pile et y mettent le résultat. Autrement dit, les calculs sont effectués via cette pile. Ainsi, il s'avère que la pile informatique est une abstraction lors de l'exécution. Soit dit en passant, de nombreuses machines virtuelles sont basées sur la pile.

# 7 Plus de threads - code plus rapide


Il semble intuitivement que le traitement des données en parallèle sera plus rapide qu'alternativement. Par conséquent, armés de connaissances sur l'utilisation des threads, beaucoup essaient de paralléliser n'importe quel cycle et calcul. Presque tout le monde connaît déjà la surcharge, ce qui contribue à la création du thread, ils utilisent donc les threads de ThreadPool et Task de manière célèbre. Mais la surcharge de création d'un flux est loin d'être terminée. Ici, nous avons affaire à une autre abstraction divulguée, le mécanisme que le processeur utilise pour améliorer les performances - le cache. Et comme cela arrive souvent, le cache est une lame à double tranchant. D'une part, il accélère considérablement le travail avec un accès séquentiel aux données d'un flux. Mais d'un autre côté, lorsque plusieurs threads fonctionnent, même sans avoir besoin de les synchroniser, le cache non seulement n'aide pas, mais il ralentit également le travail. Du temps supplémentaire est consacré à l'invalidation du cache, c'est-à-dire maintenir les données pertinentes. Et ne sous-estimez pas ce problème, qui à première vue semble insignifiant. Un algorithme efficace en cache exécutera un thread plus rapidement qu'un algorithme multi-thread, dans lequel le cache est utilisé de manière inefficace.

Essayer de travailler avec un lecteur de plusieurs fils est également un suicide. Le disque est déjà un facteur inhibiteur dans de nombreux programmes qui fonctionnent avec lui. Si vous essayez de travailler avec de nombreux threads, vous devez oublier la vitesse.

Pour toutes les définitions, je recommande de contacter ici:

Spécification du langage C # - ECMA-334
Juste de bonnes sources:
Konrad Kokosa - Gestion de la mémoire Pro .NET
Spécification CLI - ECMA-335
Développeurs CoreCLR sur l'exécution - Book Of The Runtime
De Stanislav Sidristy à propos de la finalisation et plus encore - .NET Platform Architecture

Source: https://habr.com/ru/post/fr463213/


All Articles