web-dev-qa-db-fra.com

Pourquoi les tableaux ne peuvent-ils pas être coupés?

Sur le site de documentation MSDN, il indique ce qui suit à propos de Array.Resize méthode:

Si newSize est supérieur à la longueur de l'ancien tableau, un nouveau tableau est alloué et tous les éléments sont copiés de l'ancien tableau vers le nouveau.

Si newSize est inférieur à la longueur de l'ancien tableau, un nouveau tableau est alloué et les éléments sont copiés de l'ancien tableau vers le nouveau jusqu'à ce que le nouveau soit rempli; les autres éléments de l'ancien tableau sont ignorés.

Un tableau est une séquence de blocs de mémoire adjacents. Si nous avons besoin d'un plus grand tableau, je comprends que nous ne pouvons pas y ajouter de mémoire car la mémoire à côté peut déjà être revendiquée par d'autres données. Nous devons donc réclamer une nouvelle séquence de blocs de mémoire adjacents avec la plus grande taille souhaitée, copier nos entrées là-bas et supprimer notre revendication de l'ancien espace.

Mais pourquoi créer un nouveau tableau avec une taille plus petite? Pourquoi le tableau ne peut-il pas simplement supprimer sa revendication des derniers blocs de mémoire? Ce serait alors une opération O(1) au lieu de O (n), comme c'est le cas actuellement.

Cela a-t-il quelque chose à voir avec la façon dont les données sont organisées au niveau architectural ou physique d'un ordinateur?

70
Kjara

Pour répondre à votre question, cela concerne la conception du système de gestion de la mémoire.

En théorie, si vous écriviez votre propre système de mémoire, vous pourriez totalement le concevoir pour qu'il se comporte exactement comme vous l'avez dit.

La question devient alors pourquoi n'a-t-il pas été conçu de cette façon. La réponse est que le système de gestion de la mémoire a fait un compromis entre une utilisation efficace de la mémoire et des performances.

Par exemple, la plupart des systèmes de gestion de la mémoire ne gèrent pas la mémoire jusqu'à l'octet. Au lieu de cela, ils décomposent la mémoire en morceaux de 8 Ko. Il existe de nombreuses raisons à cela, la plupart étant liées aux performances.

Certaines raisons sont liées à la façon dont le processeur déplace la mémoire. Par exemple, disons que le processeur était bien meilleur pour copier 8 Ko de données à la fois que pour copier 4 Ko. Ensuite, il y a un avantage en termes de performances à stocker les données dans des blocs de 8 Ko. Ce serait un compromis de conception basé sur l'architecture du processeur.

Il existe également des compromis algorithmiques en matière de performances. Par exemple, si vous étudiez le comportement de la plupart des applications, vous constaterez que 99% du temps, les applications allouent des blocs de données de 6 à 8 Ko.

Si le système de mémoire vous permettait d'allouer et de libérer 4 Ko, il resterait un morceau de 4 Ko gratuit que 99% des allocations ne pourront pas utiliser. Si au lieu d'être suralloué à 8 Ko, même si seulement 4 Ko étaient nécessaires, ce serait beaucoup plus réutilisable.

Considérez encore une autre conception. Supposons que vous disposiez d'une liste d'emplacements de mémoire libres pouvant être de n'importe quelle taille et qu'une demande d'allocation de 2 Ko de mémoire a été effectuée. Une approche consisterait à parcourir votre liste de mémoire libre et à en trouver une d'au moins 2 Ko, mais parcourez-vous toute la liste pour trouver le plus petit bloc, ou vous trouvez le premier qui est assez grand et utilisez cette.

La première approche est plus efficace, mais plus lente, la seconde approche est moins efficace mais plus rapide.

Il devient encore plus intéressant dans des langages comme C # et Java qui ont une "mémoire gérée". Dans un système de mémoire gérée, la mémoire n'est même pas libérée; elle cesse simplement de s'utiliser, ce que le garbage collector plus tard, dans certains cas beaucoup plus tard, détecte et libère.

Pour plus d'informations sur la gestion et l'allocation de la mémoire, vous pouvez consulter cet article sur Wikipedia:

https://en.wikipedia.org/wiki/Memory_management

22
Luis Perez

La mémoire inutilisée n'est pas réellement utilisée. C'est le travail de toute implémentation de tas de garder une trace des trous dans le tas. Au minimum, le gestionnaire doit connaître la taille du trou et doit garder une trace de leur emplacement. Cela coûte toujours au moins 8 octets.

Dans .NET, System.Object joue un rôle clé. Tout le monde sait ce qu'il fait, ce qui n'est pas si évident qu'il continue de vivre après la collecte d'un objet. Les deux champs supplémentaires dans l'en-tête de l'objet (bloc de synchronisation et poignée de type) se transforment ensuite en un pointeur vers l'arrière et vers l'avant vers le bloc libre précédent/suivant. Il a également une taille minimale, 12 octets en mode 32 bits. Garantit qu'il y a toujours suffisamment d'espace pour stocker la taille de bloc libre après la collecte de l'objet.

Donc, vous voyez probablement le problème maintenant, la réduction de la taille d'un tableau ne garantit pas la création d'un trou suffisamment grand pour s'adapter à ces trois champs. Il ne pouvait rien faire d'autre que de lever une exception "ne peut pas faire ça". Dépend également de la durée du processus. Entièrement trop moche à considérer.

35
Hans Passant

Je cherchais une réponse à votre question car je l'ai trouvée très intéressante. J'ai trouvé cette réponse qui a une première ligne intéressante:

Vous ne pouvez pas libérer une partie d'un tableau - vous ne pouvez que free() un pointeur que vous avez obtenu de malloc() et lorsque vous faites cela, vous libérez toute l'allocation que vous avez demandée .

Donc, en réalité, le problème est le registre qui conserve la mémoire allouée. Vous ne pouvez pas simplement libérer une partie du bloc que vous avez alloué, vous devez le libérer entièrement ou vous ne le libérez pas du tout. Cela signifie que pour libérer cette mémoire, vous devez d'abord déplacer les données. Je ne sais pas si la gestion de la mémoire .NET fait quelque chose de spécial à cet égard, mais je pense que cette règle s'applique également au CLR.

21
Patrick Hofman

Je pense que c'est parce que l'ancien tableau n'est pas détruit. Il est toujours là s'il est référencé ailleurs et il est toujours accessible. C'est pourquoi le nouveau tableau est créé dans un nouvel emplacement mémoire.

Exemple:

int[] original = new int[] { 1, 2, 3, 4, 5, 6 };
int[] otherReference = original; // currently points to the same object

Array.Resize(ref original, 3);

Console.WriteLine("---- OTHER REFERENCE-----");

for (int i = 0; i < otherReference.Length; i++)
{
    Console.WriteLine(i);
}

Console.WriteLine("---- ORIGINAL -----");

for (int i = 0; i < original.Length; i++)
{
    Console.WriteLine(i);
}

Tirages:

---- OTHER REFERENCE-----
0
1
2
3
4
5
---- ORIGINAL -----
0
1
2
6
Zein Makki

Seuls les concepteurs du runtime .NET peuvent vous donner leur raisonnement réel. Mais je pense que la sécurité de la mémoire est primordiale dans .NET, et il serait très coûteux de maintenir à la fois la sécurité de la mémoire et les longueurs de tableau modifiables, sans parler de la complexité de tout code avec des tableaux.

Prenons le cas simple:

var fun = 0;
for (var i = 0; i < array.Length; i++)
{
  fun ^= array[i];
}

Pour maintenir la sécurité de la mémoire, chaque accès array doit être contrôlé par les limites, tout en garantissant que la vérification des limites n'est pas interrompue par d'autres threads (le runtime .NET a des garanties beaucoup plus strictes que, par exemple, le compilateur C).

Vous avez donc besoin d'une opération thread-safe qui lit les données du tableau, tout en vérifiant les limites en même temps. Il n'y a pas une telle instruction sur le CPU, donc votre seule option est une primitive de synchronisation en quelque sorte. Votre code se transforme en:

var fun = 0;
for (var i = 0; i < array.Length; i++)
{
  lock (array)
  {
    if (i >= array.Length) throw new IndexOutOfBoundsException(...);

    fun ^= array[i];
  }
}

Inutile de dire que c'est horriblement cher. Rendre la longueur du tableau immuable vous donne deux gains de performances massifs:

  • Comme la longueur ne peut pas changer, la vérification des limites n'a pas besoin d'être synchronisée. Cela rend chaque vérification individuelle des limites beaucoup moins chère.
  • ... et vous pouvez omettre la vérification des limites si vous pouvez prouver la sécurité de le faire.

En réalité, ce que fait réellement l'exécution finit par ressembler à ceci:

var fun = 0;
var len = array.Length; // Provably safe

for (var i = 0; i < len; i++)
{
  // Provably safe, no bounds checking needed
  fun ^= array[i];
}

Vous finissez par avoir une boucle serrée, pas différente de ce que vous auriez en C - mais en même temps, c'est entièrement sûr.

Maintenant, voyons les avantages et les inconvénients de l'ajout d'un tableau rétrécissant comme vous le souhaitez:

Avantages:

  • Dans le scénario très rare où vous souhaitez réduire la taille d'un tableau, cela signifie que le tableau n'a pas besoin d'être copié pour modifier sa longueur. Cependant, cela nécessiterait toujours un compactage du tas à l'avenir, ce qui implique beaucoup de copie.
  • Si vous stockez des références d'objet dans le tableau, vous pourriez bénéficier de la localisation du cache si l'allocation du tableau et des éléments se trouve être colocalisée. Inutile de dire que c'est encore plus rare que Pro # 1.

Les inconvénients:

  • Tout accès à la baie deviendrait horriblement cher, même dans les boucles étroites. Donc, tout le monde utiliserait le code unsafe à la place, et la sécurité de votre mémoire s'en ressent.
  • Chaque morceau de code traitant des tableaux devrait s'attendre à ce que la longueur du tableau puisse changer à tout moment. Chaque accès à un tableau unique aurait besoin d'une try ... catch (IndexOutOfRangeException), et tout le monde itérant sur un tableau devrait être en mesure de faire face à la taille changeante - vous vous êtes toujours demandé pourquoi vous ne pouvez pas ajouter ou supprimer des éléments de List<T> vous répétez?
  • Une énorme quantité de travail pour l'équipe CLR qui ne pouvait pas être utilisée sur une autre fonctionnalité plus importante.

Certains détails de mise en œuvre rendent cela encore moins avantageux. Plus important encore, le segment de mémoire .NET n'a rien à voir avec les modèles malloc/free. Si nous excluons la LOH, le tas MS.NET actuel se comporte de manière complètement différente:

  • Les allocations sont toujours à partir du haut, comme dans une pile. Cela rend les allocations presque aussi bon marché que l'allocation de pile, contrairement à malloc.
  • En raison du modèle d'allocation, pour réellement "libérer" la mémoire, vous devez compact le tas après avoir fait une collection. Cela déplacera les objets de sorte que les espaces libres du tas soient remplis, ce qui rend le "haut" du tas plus bas, ce qui vous permet d'allouer plus d'objets dans le tas, ou simplement de libérer la mémoire pour une utilisation par d'autres applications sur le système .
  • Pour aider à maintenir la localité du cache (en supposant que les objets qui sont couramment utilisés ensemble sont également alloués à proximité les uns des autres, ce qui est une assez bonne hypothèse), cela peut impliquer de déplacer chaque objet du tas au-dessus de l'espace libéré vers le bas. Donc, vous vous êtes peut-être enregistré une copie d'un tableau de 100 octets, mais vous devez quand même déplacer 100 Mo d'autres objets.

De plus, comme Hans l'a très bien expliqué dans sa réponse, ce n'est pas parce que le tableau est plus petit qu'il y a suffisamment d'espace pour un tableau plus petit dans la même quantité de mémoire, en raison des en-têtes d'objet (rappelez-vous comment .NET est conçu pour sécurité de la mémoire? Connaître le bon type d'objet est indispensable pour l'exécution). Mais ce qu'il ne signale pas, c'est que même si vous avez suffisamment de mémoire, vous devez toujours déplacer le tablea. Considérez un tableau simple:

ObjectHeader,1,2,3,4,5

Maintenant, nous supprimons les deux derniers éléments:

OldObjectHeader;NewObjectHeader,1,2,3

Oops. Nous avons besoin de l'ancien en-tête d'objet pour conserver la liste d'espace libre, sinon nous ne pourrions pas compacter correctement le tas. Maintenant, il pourrait être fait que l'ancien en-tête d'objet soit déplacé au-delà le tableau pour éviter la copie, mais c'est encore une autre complication. Cela s'avère être une fonctionnalité assez coûteuse pour quelque chose que personne n'utilisera jamais vraiment.

Et c'est tout encore dans le monde managé. Mais .NET est conçu pour vous permettre de passer au code non sécurisé si nécessaire - par exemple, lors de l'interopérabilité avec du code non managé. Maintenant, lorsque vous souhaitez transmettre des données à une application native, vous avez deux options: soit vous épinglez la poignée gérée, pour empêcher qu'elle ne soit collectée et déplacée, soit vous copiez les données. Si vous effectuez un appel court et synchrone, l'épinglage est très bon marché (bien que plus dangereux - le code natif n'a aucune garantie de sécurité). Il en va de même pour par exemple manipuler des données dans une boucle serrée, comme dans le traitement d'image - la copie des données n'est clairement pas une option. Si vous autorisez Array.Resize À modifier le tableau existant, cela se briserait complètement - donc Array.Resize Devrait vérifier s'il y a un descripteur associé au tableau que vous essayez de redimensionner et lever une exception si cela arrive.

Plus de complications, beaucoup plus difficiles à raisonner (vous allez vous amuser avec le suivi du bogue qui ne se produit que de temps en temps lorsque cela arrive que Array.Resize Essaie de redimensionner un tableau qui se produit juste ainsi) pour être maintenant épinglé en mémoire).

Comme d'autres l'ont expliqué, le code natif n'est pas beaucoup mieux. Bien que vous n'ayez pas besoin de maintenir les mêmes garanties de sécurité (ce que je ne considérerais pas vraiment comme un avantage, mais bon), il y a encore des complications liées à la façon dont vous allouez et gérez la mémoire. Appelé realloc pour faire un tableau de 10 éléments à 5 éléments? Eh bien, soit il va être copié, soit il aura toujours la taille d'un tableau de 10 éléments, car il n'y a aucun moyen de récupérer la mémoire restante de manière raisonnable.

Donc, pour faire un résumé rapide: vous demandez une fonctionnalité très chère, qui serait très limitée (le cas échéant) dans un scénario extrêmement rare, et pour laquelle il existe une solution simple (créer votre propre classe de tableau) . Je ne vois pas que passer la barre pour "Bien sûr, implémentons cette fonctionnalité!" :)

5
Luaan

Il y a deux raisons pour la définition de realloc telle qu'elle est: Premièrement, il est absolument clair qu'il n'y a aucune garantie que l'appel à realloc avec une taille plus petite retournera le même pointeur. Si votre programme fait cette hypothèse, votre programme est interrompu. Même si le pointeur est le même 99,99% du temps. S'il y a un gros bloc juste au milieu de beaucoup d'espace vide, provoquant une fragmentation du tas, alors realloc est libre de le déplacer si possible.

Deuxièmement, il existe des implémentations où il est absolument nécessaire de le faire. Par exemple, MacOS X a une implémentation où un grand bloc de mémoire est utilisé pour allouer des blocs malloc de 1 à 16 octets, un autre grand bloc de mémoire pour les blocs malloc de 17 à 32 octets, un pour les blocs malloc de 33 à 48 octets, etc. Cela fait très naturellement que tout changement de taille qui reste dans la plage disons 33 à 48 octets renvoie le même bloc, mais en passant à 32 ou 49 octets doit réallouer le bloc.

Il n'y a aucune garantie pour les performances de réallocation. Mais en pratique, les gens ne font pas un peu plus petit. Les principaux cas sont les suivants: allouer de la mémoire à une limite supérieure estimée de la taille requise, la remplir, puis redimensionner à la taille requise réelle beaucoup plus petite. Ou allouez de la mémoire, puis redimensionnez-la à quelque chose de très petit lorsqu'elle n'est plus nécessaire.

5
gnasher729

Il peut y avoir de nombreuses structures de données sophistiquées fonctionnant "sous le capot" dans tout système de gestion de tas. Ils peuvent, par exemple, stocker des blocs en fonction de leur taille actuelle. Cela ajouterait un lot de complications si les blocs étaient autorisés à "être divisés, croître et rétrécir". (Et cela ne rendrait vraiment pas les choses plus rapides.)

Par conséquent, l'implémentation fait la chose toujours -sûre: elle alloue un nouveau bloc et déplace les valeurs selon les besoins. On sait que "cette stratégie fonctionnera toujours de manière fiable, sur n'importe quel système". Et cela ne ralentira pas du tout les choses.

3
Mike Robinson

Sous le capot, les tableaux sont stockés dans un bloc de mémoire continue mais sont toujours de type primitif dans de nombreuses langues.

Pour répondre à votre question, l'espace alloué à un tableau est considéré comme un seul bloc et stocké dans stack en cas de variables locales ou bss/data segments quand il est global. AFAIK, lorsque vous accédez à un tableau comme array[3], à bas niveau, OS vous obtiendra un pointeur sur le premier élément et saute/saute jusqu'à ce qu'il atteigne (trois fois dans le cas de l'exemple ci-dessus) le bloc requis. Il peut donc être une décision architecturale qu'une taille de tableau ne peut pas être modifiée une fois qu'elle est déclarée.

De la même manière, le système d'exploitation ne peut pas savoir s'il s'agit d'un index valide d'un tableau avant d'accéder à l'index requis. Lorsqu'il essaie d'accéder à l'index demandé en atteignant le bloc de mémoire après le processus jumping et découvre que le bloc de mémoire atteint ne fait pas partie du tableau, il lance un Exception

2
Vishnu Prasad V