QEMU.js: ora è serio e con WASM

Tempo fa ho deciso per divertimento di dimostrare l'invertibilità del processo e imparare a generare JavaScript (o più precisamente, Asm.js) dal codice macchina. Per l'esperimento, ho scelto QEMU e dopo un po' ho scritto un articolo su Habr. Nei commenti mi è stato consigliato di rifare il progetto su WebAssembly, e lasciare un progetto quasi finito non era molto gradito... Il lavoro procedeva, ma molto lentamente, e recentemente in quell'articolo è comparsa почти законченный проект как-то не хотелось… Работа шла, но уж очень медленно, и вот, недавно в той статье появился commento riguardo a «E poi, come è andata a finire?». Alla mia risposta dettagliata ho sentito dire: «Questo merita un articolo». Beh, se merita, allora sarà un articolo. Magari a qualcuno sarà utile. Da esso il lettore scoprirà alcuni fatti sul funzionamento dei backend per la generazione di codice QEMU, nonché su come scrivere un compilatore Just-in-Time per un'applicazione web.

Attività

Poiché avevo già imparato a «portare a malapena» QEMU su JavaScript, questa volta ho deciso di fare le cose per bene e non ripetere vecchi errori.

Errore numero uno: distaccarsi da un release point

Il mio primo errore è stato distaccare la mia versione dalla versione upstream 2.4.1. All'epoca mi sembrava una buona idea: se esiste un release point, significherà che è probabilmente più stabile del semplice 2.4, e tanto meno della branch master. E poiché avevo in mente di aggiungere un bel po' dei miei bug, quelli altrui non mi servivano proprio. Così è andata, probabilmente. Ma ecco il problema: QEMU non si ferma, e a un certo punto hanno persino annunciato un'ottimizzazione del codice generato del dieci percento. "Aha, adesso lo implemento", ho pensato, e mi sono bloccato. Qui bisogna fare una digressione: a causa della natura monothread di QEMU.js e del fatto che il QEMU originale non prevede l'assenza di multithreading (cioè è critica la possibilità di eseguire simultaneamente diversi percorsi di codice non collegati, e non semplicemente "sfruttare tutti i core"), le funzioni principali dei thread hanno dovuto essere "stravolte" per consentire le chiamate dall'esterno. Questo ha creato alcune problematiche naturali durante la fusione. Tuttavia, il fatto che parte delle modifiche dal ramo master, da cui stavo cercando di unire il mio codice, siano state anche cherry picked nella point release (e quindi nella mia branch) probabilmente non avrebbe semplificato le cose.

In generale, ho deciso che aveva comunque senso scartare il prototipo, smontarlo e costruire una nuova versione da zero basata su qualcosa di più recente e ora già di master.

Errore numero due: metodologie TLP

In sostanza, non è nemmeno un errore, in un certo senso — è solo una peculiarità dello sviluppo di un progetto in condizioni di completa incomprensione su "dove e come andare?" e in generale "ma arriveremo?". In queste circostanze programmazione approssimativa era una scelta giustificabile, ma, ovviamente, non si voleva ripeterla senza necessità. Questa volta si voleva fare in modo intelligente: commit atomici, modifiche consapevoli del codice (e non "mettere insieme caratteri a caso finché non compila (con avvisi), come una volta disse Linus Torvalds di qualcuno, se si crede a Wikiquote) e così via.

Errore numero tre: non sapere dove si sta andando e tuffarsi nell'acqua

Da questo non mi sono ancora liberato del tutto, ma ora ho deciso di non seguire del tutto il percorso di minore resistenza e di fare le cose "da adulti", cioè, scrivere il mio backend TCG da zero, così da non dover poi dire "Sì, certo, è lento, ma non posso controllare tutto — TCI è così scritto...". Inoltre, inizialmente sembrava una soluzione ovvia, dato che genero codice binario. Come si suol dire, "Ho raccolto Genta, ma non è così facile: il codice è binario, ma non è possibile trasferirne il controllo senza un intervento esplicito nel browser per la compilazione, ottenendo così un oggetto dal mondo JS che deve poi essere salvato in qualche modo. Tuttavia, su architetture RISC normali, a quanto ne so, è comune dover azzerare esplicitamente la cache delle istruzioni per il codice rigenerato — se questo non è precisamente ciò che ci serve, è comunque molto vicino. Inoltre, dalla mia esperienza precedente ho imparato che il controllo non viene trasferito a metà del blocco di traduzione, quindi il bytecode interpretato da qualsiasi offset non è necessario, e possiamo semplicemente generare secondo la funzione su TB.

Sono arrivati e hanno dato un calcio

Anche se ho iniziato a riscrivere il codice a luglio, il colpo di grazia è arrivato inaspettatamente: di solito le email da GitHub arrivano come notifiche di risposte a Issues e Pull requests, ma qui, improvvisamente menzione nella discussione Binaryen come backend per qemu nel contesto, 'Ecco, ha fatto qualcosa di simile, forse dirà qualcosa'. Si parlava dell'uso della libreria affine a Emscripten Binaryen per creare WASM JIT. E io ho detto che avete lì una licenza Apache 2.0, mentre QEMU è distribuito come un tutto sotto GPLv2, e non sono molto compatibili. Improvvisamente è emerso che la licenza può essere in qualche modo corretta (non lo so: magari cambiata, forse doppia licenza, o magari qualcos'altro…). Questo mi ha ovviamente reso felice, perché avevo già dato un'occhiata più volte a un formato binario WebAssembly, e mi sentivo un po' triste e confuso. Qui c'era una libreria che poteva gestire i blocchi di base con il grafo delle transizioni, restituire bytecode e persino eseguirlo in un interprete, se necessario.

Poi c'è stata anche una lettera nella mailing list di QEMU, ma questo riguardava più la questione: «A chi serve davvero?». Eppure, improvvisamentesi è rivelato utile. Almeno, si possono sfruttare tali possibilità di utilizzo, se funziona più o meno agilmente:

  • l'esecuzione di qualcosa di educativo senza installazione
  • virtualizzazione su iOS, dove secondo voci l'unica applicazione che ha il diritto di generare codice al volo è il motore JS (è vero?)
  • dimostrazione di un mini-sistema operativo — dischetti singoli, incorporati, vari firmware, ecc...

Caratteristiche dell'ambiente di esecuzione nel browser

Come ho già detto, QEMU è progettato per la multithreading, mentre nel browser non c'è. Beh, non che non ci sia... Inizialmente non c'era affatto, poi sono arrivati i WebWorkers — per quanto ne so, questo è un tipo di multithreading basato sulla comunicazione tra processi. senza variabili condivise. Ovviamente, questo crea notevoli problemi durante il porting di codice esistente basato sul modello di memoria condivisa. Successivamente, sotto pressione dell'opinione pubblica, è stata implementata nel browser una funzionalità chiamata SharedArrayBuffers. È stata gradualmente introdotta, celebrando il suo lancio nei vari browser, poi festeggiato il Capodanno, e poi Meltdown... Dopo di che si è giunti alla conclusione che, indipendentemente dalle misurazioni del tempo, attraverso la memoria condivisa e un thread che incrementa un contatore, si può comunque ottenere risultati abbastanza precisi. Così hanno disabilitato la multithreading con memoria condivisa. Sembrerebbe che poi l'abbiano riattivata, ma, come si è capito dal primo esperimento, anche senza di essa la vita continua, e quindi proviamo a fare senza contare sulla multithreading.

La seconda caratteristica è l'impossibilità di effettuare manipolazioni a basso livello dello stack: non è possibile semplicemente prendere, salvare il contesto attuale e passare a uno nuovo con uno stack diverso. Lo stack delle chiamate è gestito dalla macchina virtuale JS. Qual è il problema, dato che abbiamo comunque deciso di gestire i thread esistenti completamente a mano? Il fatto è che l'input/output bloccante in QEMU è realizzato tramite coroutine, e qui ci sarebbero utili le manipolazioni a basso livello dello stack. Fortunatamente, Emscripten ha già un meccanismo per le operazioni asincrone, persino due: Asyncify e Emterpreter. Il primo funziona attraverso un notevole gonfiaggio del codice JavaScript generato e non è più supportato. Il secondo è il 'modo corretto' attuale e funziona tramite la generazione di bytecode per il proprio interprete. Funziona, naturalmente, lentamente, ma non gonfia il codice. Tuttavia, è stato necessario contribuire autonomamente al supporto delle coroutine per questo meccanismo (esistevano già coroutine scritte per Asyncify e c'era una realizzazione approssimativa della stessa API per Emterpreter; era solo necessario collegarle).

Al momento non ho ancora separato il codice in quello compilabile in WASM e quello interpretato tramite Emterpreter, quindi i dispositivi a blocchi non funzionano ancora (resta sintonizzato per i prossimi episodi, come si suol dire…). Quindi, alla fine dovrebbe risultare qualcosa di divertente e stratificato:

  • input/output a blocchi interpretati. E cosa, veramente ti aspettavi un NVMe emulato con prestazioni native? 🙂
  • codice principale di QEMU compilato staticamente (traslittore, altri dispositivi emulati, ecc.)
  • codice guest compilato dinamicamente in WASM

Caratteristiche del codice sorgente di QEMU

Come probabilmente hai già indovinato, il codice per l'emulazione delle architetture guest e il codice per la generazione delle istruzioni macchina host in QEMU sono separati. In realtà, c'è anche un po' di furbizia in più:

  • ci sono architetture guest
  • ci siano acceleratori, ovvero, KVM per la virtualizzazione hardware su Linux (per sistemi guest e host compatibili tra loro), TCG per la generazione di codice JIT ovunque. A partire da QEMU 2.9 è stato introdotto il supporto per lo standard di virtualizzazione hardware HAXM su Windows (dettagli)
  • se si utilizza TCG e non virtualizzazione hardware, ha un supporto specifico per la generazione di codice per ogni architettura host, oltre a un interprete universale
  • … e attorno a tutto questo — periferiche emulate, interfaccia utente, migrazione, registrazione-replay, ecc.

A proposito, lo sapevate: QEMU può emulare non solo l'intero computer, ma anche il processore per un singolo processo utente nel kernel host, utilizzato ad esempio da AFL fuzzer per l'istrumentazione dei binari. Forse qualcuno vorrà portare questa modalità di funzionamento di QEMU su JS? 😉

Come la maggior parte dei programmi open source esistenti da tempo, QEMU viene compilato tramite chiamata configure e make. Supponiamo che tu voglia aggiungere qualcosa: un backend TCG, un'implementazione dei thread, qualcosa d'altro. Non affrettarti a rallegrarti/terrorizzarti (sottolinea quello che serve) all'idea di interagire con Autoconf — in realtà, configure QEMU, a quanto pare, ha un sistema proprietario che non viene generato da nulla.

WebAssembly

Che cos'è esattamente WebAssembly (alias WASM)? È l'alternativa ad Asm.js, che ora non si maschera più da codice JavaScript valido. Al contrario, è puramente binario e ottimizzato, e persino scrivere un intero al suo interno non è così semplice: viene memorizzato in un formato compatto. LEB128.

Forse hai sentito parlare dell'algoritmo di relooping per Asm.js: si tratta del ripristino delle istruzioni di controllo del flusso 'alto livello' (vale a dire if-then-else, cicli, ecc.) su cui sono ottimizzati i motori JS, a partire dal basso livello LLVM IR, più vicino al codice macchina eseguito dal processore. Naturalmente, la rappresentazione intermedia di QEMU è più vicina alla seconda. Sembrerebbe che il bytecode sia qui, la fine delle sofferenze... E poi ci sono i blocchi, if-then-else e cicli!

E questa è un'altra ragione per cui Binaryen è utile: può naturalmente gestire blocchi di alto livello, simili a ciò che verrà memorizzato in WASM. Ma può anche generare codice a partire dal grafo dei blocchi di base e delle transizioni tra di essi. E riguardo a ciò che nasconde dietro un comodo API in C/C++ la formattazione del WebAssembly, lo ho già accennato.

TCG (Tiny Code Generator)

TCG è nato un backend per il compilatore C. Successivamente, ha evidentemente perso la competizione con GCC, ma alla fine ha trovato il suo posto all'interno di QEMU come meccanismo di generazione del codice per la piattaforma host. Esiste anche un backend TCG, che genera un certo bytecode astratto che viene immediatamente eseguito dall'interprete, ma ho deciso di non utilizzarlo questa volta. D'altro canto, il fatto che in QEMU ci sia già la possibilità di passare al TB generato tramite la funzione tcg_qemu_tb_exec, mi è stato molto utile.

Per aggiungere un nuovo backend TCG in QEMU, è necessario creare una sottocartella tcg/ (in questo caso, tcg/binaryen), e all'interno di essa due file: tcg-target.h e tcg-target.inc.c e specificare tutto questo in configure. È possibile inserirvi anche altri file, ma, come si può intuire dai nomi di questi due, entrambi saranno inclusi da qualche parte: uno come file di intestazione comune (viene incluso in tcg/tcg.h, e quello già in altri file nelle cartelle tcg, accel e non solo), l'altro — solo come codice snippet in tcg/tcg.c, ma esso ha accesso alle sue funzioni statiche.

Avendo deciso di non spendere troppo tempo a capire nei dettagli come funziona, ho semplicemente copiato i "scheletri" di questi due file da un'altra implementazione del backend, dichiarandolo onestamente nell'intestazione della licenza.

File tcg-target.h contiene prevalentemente impostazioni sotto forma di #define- di:

  • quanti registri e di quale larghezza ci sono sull'architettura di destinazione (per noi — quanti ne vogliamo, più che altro è una questione di cosa verrà generato in un codice più efficiente dal browser sull'architettura "completamente target"...)
  • allineamento delle istruzioni host: su x86, e anche in TCI, le istruzioni in realtà non sono allineate, io intendo mettere nel buffer del codice non le istruzioni stesse, ma puntatori alle strutture della libreria Binaryen, quindi dirò: 4 byte
  • quali istruzioni opzionali può generare il backend — includiamo tutto ciò che troviamo in Binaryen, il resto lasciamo che l'acceleratore lo rompa in forme più semplici
  • qual è approssimativamente la dimensione della cache TLB richiesta dal backend. Il fatto è che in QEMU tutto è serio: anche se ci sono funzioni di supporto che effettuano load/store tenendo conto del MMU guest (come si può fare a meno di esso oggi?), la propria cache di traduzione viene mantenuta come una struttura, la cui elaborazione è conveniente integrare direttamente nei blocchi di traduzione. La questione è quale offset in questa struttura viene elaborato in modo più efficace da una piccola e veloce sequenza di comandi.
  • qui si può anche regolare l'assegnazione di uno o due registri riservati, abilitare la chiamata TB attraverso una funzione e opzionalmente descrivere un paio di funzioni minori. inline-funzioni come flush_icache_range (ma questo non è il nostro caso).

File tcg-target.inc.cNaturalmente, in genere, è molto più grande e contiene diverse funzioni obbligatorie:

  • inizializzazione, che indica anche le limitazioni su quale istruzione può operare con quali operandi. Copiata spudoratamente da un altro backend.
  • funzione che riceve un'istruzione di bytecode interno.
  • Qui si possono includere anche funzioni ausiliarie, e si possono utilizzare funzioni statiche da tcg/tcg.c

Per me, ho scelto la seguente strategia: nelle prime parole di ogni nuovo blocco di trasmissione, annotavo quattro indicatori: un'etichetta d'inizio (un valore specifico nell'area 0xFFFFFFFF, utilizzata per determinare lo stato attuale di TB), il contesto, il modulo generato, e un numero magico per il debug. Inizialmente, l'etichetta veniva impostata a 0xFFFFFFFF - n, dove n — un piccolo numero positivo, e ad ogni esecuzione tramite l'interprete veniva incrementata di 1. Quando raggiungeva 0xFFFFFFFE, si procedeva alla compilazione, il modulo veniva salvato nella tabella delle funzioni importata in un piccolo "launcher", nel quale si trasferiva l'esecuzione da tcg_qemu_tb_exec, e il modulo veniva rimosso dalla memoria di QEMU.

Para-fraseando un clásico, 'Qué mucho hay en este sonido para el corazón del programador...'. Sin embargo, la memoria se escapaba a algún lugar. Y era una memoria gestionada por QEMU. Tenía un código que al escribir otra instrucción (o sea, un puntero) eliminaba la que referenciaba esa ubicación anteriormente, pero no ayudaba. En realidad, en el caso más básico, QEMU asigna memoria al inicio y escribe el código generado ahí. Cuando el búfer se llena, el código se descarta y el siguiente comienza a escribirse en su lugar.

Después de estudiar el código, me di cuenta de que el truco con el número mágico evitaba un colapso en la pila, liberando algo incorrecto en un búfer no inicializado durante la primera pasada. Pero, ¿quién reescribe el búfer ignorando mi función después? Como sugieren los desarrolladores de Emscripten, al enfrentarte a un problema, porté el código resultante de vuelta a una aplicación nativa y lo sometí a Mozilla Record-Replay... En resumen, al final entendí algo simple: para cada bloque se asigna struct TranslationBlock con la sua descrizione. Indovinate dove… Esatto, proprio prima del blocco direttamente nel buffer. Realizzando ciò, ho deciso di smettere di usare dei palliativi (almeno alcuni) e ho semplicemente eliminato il numero magico, e le parole rimanenti le ho trasferite in struct TranslationBlock, creando una lista collegata singola, attraverso la quale posso passare rapidamente durante il reset della cache di trasmissione e liberare memoria.

Alcuni palliativi sono rimasti: ad esempio, i puntatori contrassegnati nel buffer di codice — parte di essi sono semplicemente BinaryenExpressionRef, ovvero puntano a espressioni che devono essere disposte linearmente nel blocco di base generato, parte — condizione di transizione tra i blocchi di base, parte — dove andare. E ci sono già blocchi preparati per Relooper, che devono essere collegati in base alle condizioni. Per distinguerli, si usa l'assunzione che tutti siano allineati almeno a quattro byte, quindi si possono tranquillamente usare i due bit meno significativi come etichetta, basta ricordarsi di rimuoverla quando necessario. A proposito, tali etichette sono già utilizzate in QEMU per indicare il motivo dell'uscita dal ciclo TCG.

Utilizzo di Binaryen

I moduli in WebAssembly contengono funzioni, ognuna delle quali ha un corpo che è essenzialmente un'espressione. Le espressioni possono essere operazioni unarie e binarie, blocchi composti da elenchi di altre espressioni, flusso di controllo, ecc. Come ho già accennato, qui il flusso di controllo è organizzato come ramificazioni di alto livello, cicli, chiamate di funzioni, ecc. I parametri vengono passati alle funzioni in modo esplicito, non tramite stack, proprio come in JS. Ci sono anche variabili globali, ma non le ho utilizzate, quindi non ne parlerò.

Inoltre, le funzioni hanno variabili locali numerate a partire da zero, con i tipi: int32 / int64 / float / double. Le prime n variabili locali sono gli argomenti passati alla funzione. Si noti che, sebbene qui tutto non sia esattamente a basso livello in termini di flusso di controllo, gli interi non portano con sé un'indicazione 'firmato/non firmato': come si comporta un numero dipende dal codice dell'operazione.

In linea di massima, Binaryen offre una semplice API C: si crea un modulo, al suo interno crei espressioni — unarie, binarie, blocchi di altre espressioni, flusso di controllo, ecc. Poi crei una funzione, in cui il corpo deve specificare l'espressione. Se hai, come me, un grafo di transizioni a basso livello, il componente relooper ti sarà utile. Per quanto ho capito, è possibile usare un controllo del flusso di esecuzione ad alto livello all'interno di un blocco, finché non esce dai confini del blocco — ciò significa che puoi fare una ramificazione interna fast path / slow path nel codice incorporato per gestire la cache TLB, ma non puoi intrometterti nel flusso di controllo esterno. Quando liberate relooper, si liberano i suoi blocchi; quando liberate il modulo, scompaiono le espressioni, le funzioni, ecc., allocate in esso. arena.

Tuttavia, se vuoi interpretare il codice al volo senza creare e rimuovere istanze dell'interprete, potrebbe avere senso spostare questa logica in un file C++, e gestire direttamente tutto l'API C++ della libreria, bypassando i wrapper già pronti.

Pertanto, per generare codice, è necessario

// настроить глобальные параметры (можно поменять потом)
BinaryenSetAPITracing(0);

BinaryenSetOptimizeLevel(3);
BinaryenSetShrinkLevel(2);

// создать модуль
BinaryenModuleRef MODULE = BinaryenModuleCreate();

// описать типы функций (как создаваемых, так и вызываемых)
helper_type  BinaryenAddFunctionType(MODULE, "helper-func", BinaryenTypeInt32(), int32_helper_args, ARRAY_SIZE(int32_helper_args));
// (int23_helper_args приоб^Wсоздаются отдельно)

// сконструировать супер-мега выражение
// ... ну тут уж вы как-нибудь сами :)

// потом создать функцию
BinaryenAddFunction(MODULE, "tb_fun", tb_func_type, func_locals, FUNC_LOCALS_COUNT, expr);
BinaryenAddFunctionExport(MODULE, "tb_fun", "tb_fun");
...
BinaryenSetMemory(MODULE, (1 << 15) - 1, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
BinaryenAddMemoryImport(MODULE, NULL, "env", "memory", 0);
BinaryenAddTableImport(MODULE, NULL, "env", "tb_funcs");

// запросить валидацию и оптимизацию при желании
assert (BinaryenModuleValidate(MODULE));
BinaryenModuleOptimize(MODULE);

… se ho dimenticato qualcosa — scusami, è solo per dare un'idea delle proporzioni, mentre i dettagli — sono nella documentazione.

E ora inizia il kriks-feks-peks, più o meno così:

static char buf[1 << 20];
BinaryenModuleOptimize(MODULE);
BinaryenSetMemory(MODULE, 0, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
int sz = BinaryenModuleWrite(MODULE, buf, sizeof(buf));
BinaryenModuleDispose(MODULE);
EM_ASM({
  var module = new WebAssembly.Module(new Uint8Array(wasmMemory.buffer, $0, $1));
  var fptr = $2;
  var instance = new WebAssembly.Instance(module, {
      'env': {
          'memory': wasmMemory,
          // ...
      }
  );
  // e ora avete un'istanza!
}, buf, sz);

Per collegare in qualche modo il mondo QEMU e JS e accedere rapidamente alle funzioni compilate, è stato creato un array (una tabella di funzioni per l'importazione nel launcher), in cui venivano inserite le funzioni generate. Per calcolare rapidamente l'indice, inizialmente si utilizzava l'indice della parola zero del blocco di traduzione, ma poi l'indice calcolato con una formula del genere è stato semplicemente scritto nel campo struct TranslationBlock.

A proposito, demo (per ora con licenza poco chiara) funziona correttamente solo su Firefox. Gli sviluppatori di Chrome erano in qualche modo non preparati al fatto che qualcuno volesse creare più di mille istanze di moduli WebAssembly, quindi assegnavano semplicemente un gigabyte di spazio indirizzabile virtuale per ciascuno...

Per ora è tutto. Potrebbe esserci un altro articolo, se a qualcuno interessa. In particolare, resta almeno soltanto far funzionare i dispositivi a blocchi. Potrebbe avere senso rendere anche la compilazione dei moduli WebAssembly asincrona, come comunemente si fa nel mondo JS, dato che c'è già un interprete in grado di eseguire tutto ciò mentre il modulo nativo non è pronto.

Infine, un enigma: hai compilato un binario per un'architettura a 32 bit, ma il codice attraverso le operazioni sulla memoria esce da Binaryen, in cima allo stack o altrove nei 2 GB superiori dello spazio indirizzabile a 32 bit. Il problema è che, dal punto di vista di Binaryen, questa operazione si riferisce a un indirizzo risultato troppo grande. Come si può aggirare questo problema?

In termini di amministrazione

Non l'ho testato alla fine, ma la prima idea è stata 'E se installassi un Linux a 32 bit?' Allora la parte superiore dello spazio indirizzabile sarà occupata dal kernel. La domanda è solo quanto sarà occupata: 1 o 2 Gb.

In termini di programmazione (opzione per i praticanti)

Generiamo una bolla nella parte superiore dello spazio indirizzabile. Non capisco nemmeno perché funzioni — è strano. ha già deve esserci un stack. Ma "noi siamo pratici: tutto funziona, ma nessuno sa perché...".

// 2gbubble.c
// Usage: LD_PRELOAD=2gbubble.so <program>

#include <sys/mman.h>
#include <assert.h>

void __attribute__((constructor)) constr(void)
{
  assert(MAP_FAILED != mmap(1u >> 31, (1u >> 31) - (1u >> 20), PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0));
}

… non è compatibile con Valgrind, ma fortunatamente Valgrind riesce a espellere tutti da lì molto efficacemente 🙂

Forse qualcuno darà una spiegazione migliore su come funziona questo mio codice…

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster