Gestion de la mémoire ou moins souvent vous tirer une balle dans le pied

Bonjour, Habr! Dans cet article, je vais essayer de dire ce qu'est la gestion de la mémoire dans les programmes / applications du point de vue d'un programmeur d'application. Ce n'est pas un guide ou un manuel exhaustif, mais simplement un aperçu des problèmes existants et quelques approches pour les résoudre.


Pourquoi est-ce nécessaire? Un programme est une séquence d'instructions de traitement de données (dans le cas le plus général). Ces données doivent être stockées , chargées , transférées , etc. d'une manière ou d'une autre. Toutes ces opérations ne se produisent pas instantanément, par conséquent, elles affectent directement la vitesse de votre application finale. La capacité de gérer de manière optimale les données au cours du travail vous permettra de créer des programmes très simples et très gourmands en ressources.


Remarque: la majeure partie du matériel est présentée avec des exemples de jeux / moteurs de jeu (car ce sujet est plus intéressant pour moi personnellement), cependant, la plupart du matériel peut être appliqué aux serveurs d'écriture, aux applications utilisateur, aux packages graphiques, etc.



Il est impossible de tout garder à l'esprit. Mais si vous n'avez pas réussi à le charger, vous aurez du savon


Dès le départ


Il est arrivé dans l'industrie que les grands projets de jeux AAA soient développés principalement sur des moteurs écrits en C ++. L'une des caractéristiques de ce langage est la nécessité d'une gestion manuelle de la mémoire. Java / C # etc. Ils se vantent de la collecte des ordures (GarbageCollection / GC) - la possibilité de créer des objets et de ne pas libérer la mémoire utilisée à la main. Ce processus simplifie et accélère le développement, mais il peut également causer des problèmes: un ramasse-miettes déclenché périodiquement peut tuer tout le temps réel doux et ajouter des gels désagréables au jeu.


Oui, dans des projets comme "Minecraft", le GC peut ne pas être visible, car ils n'exigent généralement pas les ressources de l'ordinateur, mais des jeux tels que "Red Dead Redemption 2", "God of War", "Last of Us" fonctionnent "presque" au sommet des performances du système et ont donc besoin non seulement de grandes le montant des ressources, mais aussi dans leur répartition compétente.


De plus, en travaillant dans un environnement avec allocation automatique de mémoire et récupération de place, vous pouvez rencontrer un manque de flexibilité dans la gestion des ressources. Ce n'est un secret pour personne que Java cache tous les détails de mise en œuvre et les aspects de son travail sous le capot, donc à la sortie, vous n'avez que l'interface installée pour interagir avec les ressources du système, mais cela peut ne pas être suffisant pour résoudre certains problèmes. Par exemple, le démarrage d'un algorithme avec un nombre non constant d'allocations de mémoire dans chaque image (cela peut être une recherche de chemins pour l'IA, la vérification de la visibilité, l'animation, etc.) conduit inévitablement à une baisse catastrophique des performances.


A quoi ressemblent les allocations dans le code


Avant de poursuivre la discussion, je voudrais montrer comment le travail avec la mémoire en C / C ++ se produit directement avec quelques exemples. En général, l'interface standard et la plus simple pour l'allocation de mémoire de processus est représentée par les opérations suivantes:


//        size  void* malloc(size_t size); //      p void free(void* p); 

Ici, vous pouvez ajouter des fonctions supplémentaires qui vous permettent d'allouer un morceau de mémoire aligné:


 // C11  -     , * alignment void* aligned_alloc(size_t size, size_t alignment); // Posix  -       //        address (*address = allocated_mem_p) int posix_memalign(void** address, size_t alignment, size_t size); 

Veuillez noter que différentes plates-formes peuvent prendre en charge différentes normes de fonction, disponibles par exemple sur macOS et non disponibles sur win.


À l'avenir, des zones de mémoire spécialement alignées peuvent être nécessaires à la fois pour atteindre la ligne de cache du processeur et pour les calculs à l'aide d'un ensemble étendu de registres ( SSE , MMX , AVX , etc.).


Un exemple de programme jouet qui alloue de la mémoire et imprime des valeurs de tampon, les interprétant comme des entiers signés:


 /* main.cpp */ #include <cstdio> #include <cstdlib> int main(int argc, char** argv) { const int N = 10; int* buffer = (int*) malloc(sizeof(int) * N); for(int i = 0; i < N; i++) { printf("%i ", buffer[i]); } free(buffer); return 0; } 

Sur macOS 10.14, ce programme peut être créé et exécuté avec l'ensemble de commandes suivant:


 $ clang++ main.cpp -o main $ ./main 

Remarque: ci-après, je ne veux pas vraiment couvrir les opérations C ++ telles que new / delete, car elles sont plus susceptibles d'être utilisées pour construire / détruire des objets directement, mais elles utilisent les opérations habituelles avec de la mémoire comme malloc / free.


Problèmes de mémoire


Plusieurs problèmes surviennent lors de l'utilisation de la mémoire RAM de l'ordinateur. Tous, d'une manière ou d'une autre, sont causés non seulement par les fonctionnalités de l'OS et du logiciel, mais également par l'architecture du fer sur lequel tout cela fonctionne.


1. Quantité de mémoire


Malheureusement, la mémoire est physiquement limitée. Sur PlayStation 4, il s'agit de 8 Gio GDDR5, 3,5 Gio dont le système d'exploitation réserve à ses besoins . La mémoire virtuelle et l'échange de pages n'aideront pas beaucoup, car l'échange de pages sur le disque est une opération très lente (dans un nombre fixe de N images par seconde, si nous parlons de jeux).


Il convient également de noter le " budget " limité - une limitation artificielle de la quantité de mémoire utilisée, créée pour exécuter l'application sur plusieurs plates-formes. Si vous créez un jeu pour une plate-forme mobile et que vous souhaitez prendre en charge non pas un, mais toute une gamme d'appareils, vous devrez limiter votre appétit afin d'offrir un marché plus large. Cela peut être réalisé à la fois en limitant simplement la consommation de RAM et en configurant cette restriction en fonction du gadget sur lequel le jeu démarre réellement.


2. Fragmentation


Un effet désagréable qui apparaît au cours du processus d'allocations multiples de morceaux de mémoire de différentes tailles. Par conséquent, vous obtenez un espace d'adressage fragmenté en plusieurs parties distinctes. La combinaison de ces parties en blocs simples de plus grande taille ne fonctionnera pas, car une partie de la mémoire est occupée et nous ne pouvons pas la déplacer librement.



Fragmentation par l'exemple d'allocations séquentielles et de libérations de blocs de mémoire


Résultat: nous pouvons avoir suffisamment de mémoire libre quantitativement, mais pas qualitativement. Et la demande suivante, par exemple, "allouer de l'espace pour la piste audio", l'allocateur ne sera pas en mesure de satisfaire, car il n'y a tout simplement pas un seul morceau de mémoire de cette taille.


3. Cache CPU



Hiérarchie de la mémoire informatique


Le cache d'un processeur moderne est un lien intermédiaire qui relie la mémoire principale (RAM) et le processeur s'inscrit directement. Il se trouve que l'accès en lecture / écriture à la mémoire est une opération très lente (si l'on parle du nombre de cycles d'horloge CPU requis pour s'exécuter). Par conséquent, il existe une certaine hiérarchie de cache (L1, L2, L3, etc.), qui permet, pour ainsi dire, "selon certaines prévisions" de charger des données à partir de la RAM, ou de les pousser lentement dans une mémoire plus lente.


Le fait de placer des objets du même type dans une rangée en mémoire vous permet d'accélérer "de manière significative" le processus de leur traitement (si le traitement se fait séquentiellement), car dans ce cas, il est plus facile de prédire quelles données seront ensuite nécessaires. Et par «significatif», on entend parfois des gains de productivité. Les développeurs du moteur Unity en ont parlé à plusieurs reprises dans leurs rapports au GDC .


4. Multi-threading


Assurer un accès sécurisé à la mémoire partagée dans un environnement multi-thread est l'un des principaux problèmes que vous devrez résoudre lors de la création de votre propre moteur de jeu / jeu / toute autre application qui utilise plusieurs threads pour obtenir de meilleures performances. Les ordinateurs modernes sont disposés de manière très simple. Nous avons à la fois une structure de cache complexe et plusieurs cœurs de calculatrice. Tout cela, s'il n'est pas utilisé correctement, peut conduire à des situations où les données partagées de votre processus seront endommagées à la suite de plusieurs threads (s'ils essaient simultanément de travailler avec ces données sans contrôle d'accès). Dans le cas le plus simple, cela ressemblera à ceci:

Je ne veux pas m'attarder sur le sujet de la programmation multithread, car nombre de ses aspects dépassent largement le cadre de l'article ou même de l'ensemble du livre.


5. Malloc / gratuit


Les opérations d'allocation / libération ne se produisent pas instantanément. Sur les systèmes d'exploitation modernes, si nous parlons de Windows / Linux / MacOS, ils sont bien mis en œuvre et fonctionnent rapidement dans la plupart des situations . Mais cela peut potentiellement prendre beaucoup de temps. Non seulement c'est un appel système, mais selon l'implémentation, cela peut prendre un certain temps pour trouver une mémoire appropriée (First Fit, Best fit, etc.) ou pour trouver un endroit pour insérer et / ou fusionner la zone libérée.


En outre, la mémoire fraîchement allouée peut ne pas réellement être mappée sur de vraies pages physiques, ce qui peut également prendre un certain temps lors du premier accès.


Ce sont des détails d'implémentation, mais qu'en est-il de l'applicabilité? Malloc / new n'a aucune idée d'où, comment ou pourquoi vous les avez appelés. Ils allouent de la mémoire (dans le pire des cas) de 1 Kio et 100 Mio tout aussi ... tout aussi mauvais. Directement, la stratégie d'utilisation est laissée au programmeur ou à celui qui a implémenté le runtime de votre programme.


6. Corruption de mémoire


Comme le dit le wiki , c'est l'une des erreurs les plus imprévisibles qui n'apparaît qu'au cours du programme, et est le plus souvent causée directement par des erreurs dans l'écriture de ce programme. Mais quel est ce problème? Heureusement (ou malheureusement), cela n'est pas lié à la corruption de votre ordinateur. Au contraire, il affiche une situation dans laquelle vous essayez de travailler avec une mémoire qui ne vous appartient pas . J'expliquerai maintenant:


  1. Cela peut être une tentative de lecture / écriture sur une partie de la mémoire non allouée.
  2. Aller au-delà des limites du bloc de mémoire qui vous est fourni. Ce problème est une sorte de cas particulier de problème (1), mais il est pire car le système vous dira que vous avez dépassé les limites uniquement lorsque vous quittez la page affichée pour vous. Autrement dit, ce problème est potentiellement très difficile à détecter, car le système d'exploitation ne peut répondre que si vous laissez les limites des pages virtuelles affichées. Vous pouvez gâcher la mémoire du processus et obtenir une erreur très étrange de l'endroit d'où elle n'était pas attendue du tout.
  3. Libération d'une mémoire déjà libérée (cela semble étrange) ou pas encore allouée
  4. etc.

En C / C ++, où il y a de l'arithmétique des pointeurs, vous rencontrerez cela une ou deux fois. Cependant, dans Java Runtime, vous devez transpirer assez fort pour obtenir ce genre d'erreur (je ne l'ai pas essayé moi-même, mais je pense que c'est possible, sinon la vie serait trop simple).


7. Fuites de mémoire


Il s'agit d'un cas particulier d'un problème plus général qui se produit dans de nombreux langages de programmation. La bibliothèque C / C ++ standard permet d'accéder aux ressources du système d'exploitation. Il peut s'agir de fichiers, de sockets, de mémoire, etc. Après utilisation, la ressource doit être correctement fermée et
la mémoire occupée par lui doit être libérée. Et si nous parlons spécifiquement de libérer de la mémoire - les fuites accumulées à la suite du programme peuvent conduire à une erreur de «mémoire insuffisante» lorsque le système d'exploitation ne pourra pas satisfaire la prochaine demande d'allocation. Souvent, le développeur oublie simplement de libérer la mémoire utilisée pour une raison ou une autre.


Ici, il vaut la peine d'ajouter des informations sur la fermeture et la libération correctes des ressources sur le GPU, car les premiers pilotes ne permettaient pas de reprendre le travail avec la carte vidéo si la session précédente ne s'était pas terminée correctement. Seul le redémarrage du système pourrait résoudre ce problème, ce qui est très douteux - pour forcer l'utilisateur à redémarrer le système après avoir exécuté votre application.


8. Pointeur suspendu


Un pointeur pendant est un jargon qui décrit une situation où un pointeur fait référence à une valeur non valide. Une situation similaire peut facilement se produire lors de l'utilisation de pointeurs de style C classiques dans un programme C / C ++. Supposons que vous ayez alloué de la mémoire, enregistré l'adresse dans le pointeur p, puis libéré la mémoire (voir l'exemple de code):


 //   void* p = malloc(size); // ...  -    //   free(p); //    p? // *p == ? 

Le pointeur stocke une valeur, que nous pouvons interpréter comme l'adresse du bloc de mémoire. Il se trouve que nous ne pouvons pas dire si ce bloc de mémoire est valide ou non. Seul un programmeur, basé sur certains accords, peut fonctionner avec un pointeur. À partir de C ++ 11, un certain nombre de pointeurs «pointeurs intelligents» supplémentaires ont été introduits dans la bibliothèque standard, ce qui permet en quelque sorte d'affaiblir le contrôle des ressources par le programmeur en utilisant des méta-informations supplémentaires en lui-même (plus de détails plus loin).


Comme solution partielle, vous pouvez utiliser la valeur spéciale du pointeur, qui nous signalera qu'il n'y a rien à cette adresse. En C, la macro NULL est utilisée comme valeur de cette valeur et en C ++, le mot clé de langage nullptr est utilisé. La solution est partielle, car:


  1. La valeur du pointeur doit être définie manuellement, afin que le programmeur puisse simplement oublier de le faire.
  2. nullptr ou juste 0x0 est inclus dans l'ensemble des valeurs acceptées par le pointeur, ce qui n'est pas bon lorsque l'état spécial d'un objet est exprimé par son état habituel. Il s'agit d'une sorte d'héritage, et par accord, le système d'exploitation ne vous allouera pas un morceau de mémoire dont l'adresse commence par 0x0.

Exemple de code avec null:


 //  -  p free(p); p = nullptr; //   p == nullptr   ,        

Vous pouvez automatiser ce processus dans une certaine mesure:


 void _free(void* &p) { free(p); p = nullptr; } //  -  p _free(p); //   p == nullptr,     //    

9. Type de mémoire


La RAM est une mémoire ordinaire à accès aléatoire à usage général, à laquelle l'accès via le bus central possède tous les cœurs de votre processeur et de vos périphériques. Son volume varie, mais le plus souvent nous parlons de N gigaoctets, où N est 1,2,4,8,16 et ainsi de suite. Les appels malloc / free cherchent à placer le bloc de mémoire que vous voulez directement dans la RAM de l'ordinateur.


VRAM (mémoire vidéo) - mémoire vidéo, fournie avec la carte vidéo / l'accélérateur vidéo de votre PC. En règle générale, il est plus petit que la RAM (environ 1,2,4 Gio), mais il a une vitesse élevée. La distribution de ce type de mémoire est gérée par le pilote de la carte vidéo, et le plus souvent vous n'y avez pas directement accès.


Il n'y a pas une telle séparation sur la PlayStation 4, et toute la RAM est représentée par un seul 8 gigaoctets sur GDDR5. Par conséquent, toutes les données du processeur et de l'accélérateur vidéo sont à proximité.


Une bonne gestion des ressources dans le moteur de jeu comprend une allocation de mémoire compétente à la fois dans la RAM principale et du côté VRAM. Ici, vous pouvez rencontrer une duplication lorsque les mêmes données sont là et là, ou avec un transfert excessif de données de la RAM vers la VRAM et vice versa.


Pour illustrer tous les problèmes évoqués : vous pouvez regarder les aspects des ordinateurs de l'appareil sur l'exemple de l'architecture PlayStation 4 (Fig.). Voici le processeur central, 8 cœurs, les caches de niveau L1 et L2, les bus de données, la RAM, l'accélérateur graphique, etc. Pour une description complète et détaillée, voir «Game Engine Architecture» de Jason Gregory.



Architecture PlayStation 4


Approches générales


Il n'y a pas de solution universelle. Mais il existe un ensemble de points sur lesquels vous devez vous concentrer si vous envisagez d'implémenter manuellement l'allocation et la gestion de la mémoire dans votre application. Cela inclut les conteneurs et les allocateurs spécialisés, les stratégies d'allocation de mémoire, la conception de systèmes / jeux, les gestionnaires de ressources, etc.


Types d'allocateurs


L'utilisation d'allocateurs de mémoire spéciaux est basée sur l'idée suivante: vous savez quelle taille, à quels moments de travail et à quel endroit vous aurez besoin de morceaux de mémoire. Par conséquent, vous pouvez allouer la mémoire nécessaire, la structurer d'une manière ou d'une autre et l'utiliser / la réutiliser. C'est l'idée / le concept général de l'utilisation d'allocateurs spéciaux. Ce qu'ils sont (bien sûr, pas tous) peut être vu plus loin:


  1. Allocateur linéaire
    Représente un tampon d'espace d'adressage contigu. Au cours du travail, il vous permet d'allouer des sections de mémoire de taille arbitraire (telles qu'elles tiennent dans un tampon). Mais vous ne pouvez libérer toute la mémoire allouée qu'une seule fois. Autrement dit, un morceau de mémoire arbitraire ne peut pas être libéré - il restera comme s'il était occupé jusqu'à ce que la totalité du tampon soit marquée comme propre. Cette conception permet l'allocation et la libération d'O (1), ce qui donne une garantie de vitesse dans toutes les conditions.

    Cas d'utilisation typique: dans le processus de mise à jour de l'état du processus (chaque image du jeu), vous pouvez utiliser LinearAllocator pour allouer des tampons tmp pour tous les besoins techniques: traitement d'entrée, travail avec des chaînes, analyse des commandes ConsoleManager en mode débogage, etc.


  2. Allocateur de pile
    Modification d'un allocateur linéaire. Vous permet de libérer de la mémoire dans l'ordre inverse de l'allocation, en d'autres termes, se comporte comme une pile régulière selon le principe LIFO. Il peut être très utile pour effectuer des calculs mathématiques chargés (hiérarchie des transformations), pour implémenter le travail du sous-système de script, pour tout calcul où la procédure indiquée pour libérer de la mémoire est connue à l'avance.

    La simplicité de la conception fournit l'allocation de mémoire O (1) et la vitesse de libération.


  3. Allocateur de pool
    Vous permet d'allouer des blocs de mémoire de la même taille. Il peut être implémenté comme un tampon d'espace d'adressage continu, divisé en blocs d'une taille prédéterminée. Ces blocs peuvent former une liste chaînée. Et nous savons toujours quel bloc donner dans la prochaine allocation. Ces méta-informations peuvent être stockées dans les blocs eux-mêmes, ce qui impose une restriction sur la taille minimale des blocs (sizeof (void *)). En réalité, ce n'est pas critique.

    Étant donné que tous les blocs sont de la même taille, peu importe le bloc à renvoyer, et par conséquent, toutes les opérations d'allocation / désallocation peuvent être effectuées dans O (1).


  4. Allocateur de trames
    Allocateur linéaire mais uniquement en référence à la trame actuelle - vous permet de faire l'allocation de mémoire tmp puis de tout libérer automatiquement lors du changement de trame. Il convient de le distinguer séparément, car il s'agit d'une entité globale et unique dans le cadre du jeu d'exécution, et donc il peut être de taille très impressionnante, disons quelques dizaines de MiB, ce qui sera très utile lors du chargement des ressources et de leur traitement.


  5. Allocateur de trames doubles
    Il s'agit d'un allocateur de double trame, mais avec certaines fonctionnalités. Il vous permet d'allouer de la mémoire dans l'image actuelle et de l'utiliser à la fois dans l'image actuelle et dans l'image suivante. Autrement dit, la mémoire que vous avez allouée dans la trame N ne sera libérée qu'après N + 1 trame. Ceci est réalisé en commutant la trame active pour la mettre en surbrillance à la fin de chaque trame.

    Mais ce type d'allocateur, comme le précédent, impose un certain nombre de restrictions sur la durée de vie des objets créés dans la mémoire qui lui est allouée. Par conséquent, vous devez savoir qu'à la fin de la trame, les données deviennent tout simplement invalides et qu'un accès répété à celles-ci peut provoquer de graves problèmes.


  6. Allocateur statique
    Ce type d'allocateur alloue de la mémoire à partir d'un tampon obtenu, par exemple, au stade du lancement du programme, ou capturé sur la pile dans un cadre de fonction. Par type, il peut s'agir de n'importe quel allocateur: linéaire, pool, stack. Pourquoi est-il appelé statique ? La taille de la mémoire tampon capturée doit être connue au stade de la compilation du programme. Cela impose une limitation importante: la quantité de mémoire disponible pour cet allocateur ne peut pas être modifiée pendant le fonctionnement. Mais quels en sont les avantages? Le tampon utilisé sera automatiquement capturé puis libéré (soit à la fin du travail, soit à la sortie de la fonction). Cela ne charge pas le tas, vous évite la fragmentation, vous permet d'allouer rapidement la mémoire en place.
    Vous pouvez regarder l'exemple de code utilisant cet allocateur, si vous avez besoin de diviser la chaîne en sous-chaînes et de faire quelque chose avec elles:

    On peut également noter que l'utilisation de la mémoire de la pile en théorie est beaucoup plus efficace, car empiler la trame de la fonction actuelle avec une forte probabilité sera déjà dans le cache du processeur.



Tous ces allocateurs résolvent en quelque sorte les problèmes de fragmentation, de manque de mémoire, de vitesse de réception et de libération de blocs de la taille requise, de durée de vie des objets et de la mémoire qu'ils occupent.


Il convient également de noter que la bonne approche de la conception d'interface vous permettra de créer une sorte de hiérarchie d' allocateurs lorsque, par exemple: le pool alloue de la mémoire à partir de l'allocation de trames, et l'allocation de trames alloue à son tour la mémoire à partir de l'allocation linéaire. Une structure similaire peut être poursuivie, s'adaptant à vos tâches et besoins.



Je vois une interface similaire pour créer des hiérarchies comme suit:


 class IAllocator { public: virtual void* alloc(size_t size) = 0; virtual void* alloc(size_t size, size_t alignment) = 0; virtual void free (void* &p) = 0; } 

malloc/free , . , , . / , .



Smart pointer — C++ ++11 ( boost, ). -, , - , . .


? :


  1. (/)

:


  1. Unique pointer
    1 ( ).
    unique pointer , . , .. 1 / .
    uniquePtr1 uniquePtr2, uniquePtr1 , . 1 .


  2. Shared pointer
    (reference counting). , , . , , , .

    . -, , . . -, - .


  3. Weak pointer
    . , . Qu'est-ce que cela signifie? shared pointer. , shared pointer , . , shared pointer weak pointer. , (shared) , weak pointer shared pointer. — weak pointer , , , .

    shared, weak pointer meta-data . - , .. , O(N) overhead , N — - . , . , . .



: . , shared pointer, , ( ) - - - . . meta-info , , . Un exemple:


 /*     */ /*   ,  shared pointer */ Array<TSharedPtr<Object>> objects; objects.add(newShared<Object>(...)); ... objects.add(newShared<Object>(...)); 

 /*      (   meta-info    ) */ Array<Object> objects; objects.emplace(...); ... objects.emplace(...); 

. . À ce sujet plus loin.


Unique id


, . (id/identificator), , , -. :



  1. , id. , , , id.

  2. , ( , )

  3. id , , id.

  4. . , id, .

: id, , id, .


id , (Vulkan, OpenGL), (Godot, CryEngine). EntityID CryEngine .


, id : . , ( ), , .


 /*    */ class ID { uint32 index; uint32 generation; } 

 /*  - /  */ class ObjectManager { public: ID create(...); void destroy(ID); void update(ID id, ...); private: Array<uint32> generations; Array<Objects> objects; } 

ID , ID . :


 generation = generations[id.index]; if (generation == id.generation) then /*    */ else /*  ,     */ 

id generation 1 id ids.



C++ , . std, , . :


  • Linked list —
  • Array — /
  • Queue —
  • Stack —
  • Map —
  • Set —

? memory corruption. / , , , , .



, , . , , / .



, , . , ( ) . , malloc/free , , .


? , (/ ), , , . , , , .



ryEngine Sandbox:


, Unreal, Unity, CryEngine ., , . , , , — , .


Pre-allocating


, / .


: malloc/free . , "run out of memory", . . , (, , .).


. . , - . , malloc/free, : , , .



. : , , , .. .


: , , , . open-source , , . , , — malloc/free.



GDC CD Project Red , , "The Witcher: Blood and Wine" () . , , , , .


Naughty Dog , "Uncharted 4: A Thief's End" , (, ) .


Conclusion


, , , . , . / , , - .. , (, ).



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


All Articles