Spiegherò subito il titolo dell'articolo. Inizialmente era previsto fornire un buon e affidabile consiglio su come velocizzare l'uso della riflessione con un esempio semplice ma realistico, tuttavia durante il benchmarking è emerso che la riflessione non è così lenta come pensavo, mentre LINQ funziona più lentamente di quanto avessi mai sognato. Alla fine è risultato che ho anche commesso un errore nelle misurazioni... I dettagli di questa storia di vita sono nel seguito e nei commenti. Poiché l'esempio è abbastanza comune e realizzato come di consueto in un ambiente enterprise, mi sembra ci sia una dimostrazione abbastanza interessante della vita: l'influenza sulla velocità della parte principale dell'articolo non era percettibile a causa della logica esterna: Moq, Autofac, EF Core e altre "incrostazioni".
Ho iniziato a lavorare impressionato da questo articolo:
Come si può vedere, l'autore suggerisce di utilizzare delegati compilati invece di un accesso diretto ai metodi dei tipi di riflessione come un ottimo modo per accelerare notevolmente il funzionamento dell'applicazione. Certo, c'è anche l'emissione IL, ma sarebbe preferibile evitarla, poiché è il modo più dispendioso in termini di risorse per completare il compito, soggetto a errori.
Dato che ho sempre avuto un'opinione simile sulla velocità della riflessione, non avevo intenzione di mettere in dubbio le conclusioni dell'autore.
Spesso incontro un uso naive della riflessione in ambienti enterprise. Si prende un tipo. Si prende informazione sulla proprietà. Si chiama il metodo SetValue e tutti sono felici. Il valore è arrivato nel campo target, tutti sono soddisfatti. Persone di grande intelligenza — senior e team leader — scrivono le loro estensioni su object, basandosi su una tale implementazione ingenua di mapper "universali" di un tipo in un altro. Di solito funziona così: prendi tutti i campi, prendi tutte le proprietà, itera su di essi: quando i nomi dei membri coincidono, esegui SetValue. Periodicamente si catturano eccezioni per le mancanze quando un certo tipo di proprietà non è stato trovato, ma qui c'è anche un modo per migliorare le prestazioni. Try/catch.
Ho visto persone reinventare parser e mapper senza avere a disposizione tutte le informazioni su come funzionano le biciclette inventate prima di loro. Ho visto persone nascondere le loro implementazioni naive dietro strategie, interfacce e iniezioni, come se ciò potesse scusare la successiva confusione. Di fronte a queste implementazioni ho storcato il naso. In effetti, non ho mai misurato una reale perdita di prestazioni e, quando possibile, cambiavo semplicemente l'implementazione con una più "ottimale", se ne avevo l'opportunità. Perciò, le prime misurazioni di cui si parla qui di seguito mi hanno seriamente confuso.
Penso che molti di voi, leggendo Richter o altri ideologi, si siano imbattuti nell'affermazione del tutto giustificata che la riflessione nel codice è un fenomeno che influisce negativamente sulle prestazioni dell'applicazione.
La chiamata alla riflessione costringe il CLR a navigare tra le assembly in cerca di quella giusta, caricando i loro metadati, analizzandoli, ecc. Inoltre, la riflessione durante la navigazione delle sequenze comporta l'allocazione di una grande quantità di memoria. Consumando memoria, il CLR attiva il GC e iniziano a verificarsi dei lag. Questo dovrebbe essere notevolmente lento, credetemi. Le enormi quantità di memoria dei moderni server di produzione o delle macchine cloud non proteggono da alti ritardi nel processo. Infatti, maggiore è la memoria, maggiore è la probabilità che notarai come funziona il GC. La riflessione è in teoria un'inutile bandiera rossa per essa.
Tuttavia, usiamo tutti i contenitori IoC e i data mapper, il cui principio di funzionamento si basa anch'esso sulla riflessione, ma di solito non si sollevano interrogativi sulle loro prestazioni. No, non perché l'iniezione delle dipendenze e l'astrazione dai modelli di contesto limitato siano cose così necessarie da dover sacrificare le prestazioni. È molto più semplice: in effetti non influiscono significativamente sulle prestazioni.
Il punto è che i framework più comuni, che si basano sulla tecnologia di riflessione, utilizzano vari stratagemmi per lavorare con essa in modo più ottimale. Di solito si tratta di caching. Di solito — sono le Expressions e i delegati compilati dall'albero delle espressioni. Lo stesso automapper mantiene un dizionario concorrente che associa i tipi alle funzioni che possono convertire da una parte all'altra già senza la chiamata alla riflessione.
Come si ottiene tutto questo? In sostanza, non è diverso dalla logica utilizzata dalla piattaforma stessa per la generazione del codice JIT. Al primo invio di un metodo, questo viene compilato (e, sì, questo processo non è rapido), mentre per i successivi invii il controllo viene trasferito al metodo già compilato, senza bruschi cali delle prestazioni.
Nel nostro caso, possiamo anche sfruttare la compilazione JIT e poi utilizzare il comportamento compilato con le stesse prestazioni dei suoi analoghi AOT. In questo caso, ci aiuteranno le espressioni.
In sintesi, il principio di cui stiamo parlando può essere formulato come segue:
È opportuno memorizzare nella cache il risultato finale del lavoro della riflessione sotto forma di delegato che contiene la funzione compilata. Ha senso memorizzare in cache anche tutti gli oggetti necessari con informazioni sui tipi, conservandoli nei campi esterni agli oggetti del vostro tipo – worker.
C'è una logica in tutto ciò. Il buon senso ci dice che se qualcosa può essere compilato e memorizzato nella cache, allora dovrebbe essere fatto.
Per anticipare i fatti, va detto che la cache nel lavoro con la riflessione ha i suoi vantaggi, anche senza utilizzare il metodo di compilazione delle espressioni proposto. Qui semplicemente ripeterò le tesi dell'autore dell'articolo citato sopra.
Ora parliamo di codice. Consideriamo un esempio basato sulla mia recente difficoltà, che ho dovuto affrontare in una produzione seria di un'importante istituzione creditizia. Tutte le entità sono inventate, per non far scoprire a nessuno.
C'è un'entità. Chiamiamola Contatto. Ci sono email con un corpo standardizzato, da cui il parser e il costruttore creano questi contatti. Arriva un'email, la leggiamo, la analizziamo in coppie chiave-valore, creiamo il contatto e lo salviamo nel database.
È elementare. Supponiamo che il contatto abbia proprietà come Nome e Cognome, Età e numero di telefono. Questi dati vengono trasmessi nell'email. Inoltre, l'azienda desidera che i supporti possano aggiungere rapidamente nuove chiavi per mappare le proprietà dell'entità in coppie nel corpo dell'email. Nel caso qualcuno commetta errori nel modello o se, prima del rilascio, sarà necessario avviare rapidamente il mapping da un nuovo partner, adattandosi a un nuovo formato. Quindi, potremo aggiungere la nuova correlazione di mapping come un semplice data fix. Cioè, un esempio della vita reale.
Realizziamo e creiamo test. Funziona.
Non fornirò il codice: ci sono molti sorgenti disponibili su GitHub tramite il link alla fine dell'articolo. Puoi caricarli, testarli a fondo e misurare come si comporterebbero nel tuo caso. Riporterò solo il codice di due metodi template, che differenziano l'idratore che doveva essere veloce da quello che doveva essere lento.
La logica è la seguente: il metodo template riceve coppie formate dalla logica di base del parser. Il livello LINQ è il parser e la logica di base dell'idratore, che fa una richiesta al contesto del database e abbina le chiavi con le coppie dal parser (per queste funzioni esiste un codice senza LINQ per un confronto). Le coppie vengono quindi passate al metodo principale di idratazione e i valori delle proprietà vengono impostati negli attributi corrispondenti dell'entità.
«Veloce» (Prefisso Fast nei benchmark):
protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
{
var contact = new Contact();
foreach (var setterMapItem in _proprtySettersMap)
{
var correlation = correlations.FirstOrDefault(x => x.PropertyName == setterMapItem.Key);
setterMapItem.Value(contact, correlation?.Value);
}
return contact;
}
Come possiamo vedere, viene utilizzata una collezione statica con setter delle proprietà – lambda compilati che richiamano il setter dell'entità. Sono creati con il seguente codice:
static FastContactHydrator()
{
var type = typeof(Contact);
foreach (var property in type.GetProperties())
{
_proprtySettersMap[property.Name] = GetSetterAction(property);
}
}
private static Action GetSetterAction(PropertyInfo property)
{
var setterInfo = property.GetSetMethod();
var paramValueOriginal = Expression.Parameter(property.PropertyType, "value");
var paramEntity = Expression.Parameter(typeof(Contact), "entity");
var setterExp = Expression.Call(paramEntity, setterInfo, paramValueOriginal).Reduce();
var lambda = (Expression<Action>)Expression.Lambda(setterExp, paramEntity, paramValueOriginal);
return lambda.Compile();
}
In generale è chiaro. Scorriamo le proprietà, creiamo delegati che richiamano i setter, e li memorizziamo. Poi li chiamiamo quando necessario.
«Lento» (Prefisso Slow nei benchmark):
protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
{
var contact = new Contact();
foreach (var property in _properties)
{
var correlation = correlations.FirstOrDefault(x => x.PropertyName == property.Name);
if (correlation?.Value == null)
continue;
property.SetValue(contact, correlation.Value);
}
return contact;
}
Qui passiamo immediatamente attraverso le proprietà e chiamiamo direttamente SetValue.
Per maggiore chiarezza e come riferimento, ho implementato un metodo naive che scrive i valori delle coppie di correlazione direttamente nei campi dell'entità. Prefisso – Manual.
Ora prendiamo BenchmarkDotNet ed esaminiamo le prestazioni. E all'improvviso… (spoiler — questo non è il risultato corretto, dettagli — qui sotto)

Cosa vediamo qui? I metodi, trionfalmente contrassegnati con il prefisso Fast, risultano più lenti in quasi tutti i passaggi rispetto ai metodi con il prefisso Slow. Questo vale sia per l'allocazione che per la velocità di esecuzione. D'altra parte, una bella ed elegante implementazione del mapping utilizzando ovunque possibile i metodi LINQ per questo scopo, al contrario, riduce drasticamente le prestazioni. La differenza è notevole. La tendenza non cambia con un diverso numero di passaggi. La variazione è solo di scala. Con LINQ, è 4 — 200 volte più lento, con più spazzatura in scale simili.
AGGIORNATO
Non potevo credere ai miei occhi, ma ciò che è più importante, né i miei occhi né il mio codice hanno creduto al nostro collega — . Controllando nuovamente la mia soluzione, ha brillantemente scoperto e indicato l'errore che ho trascurato a causa di una serie di modifiche dall'implementazione iniziale a quella finale. Dopo aver corretto il bug trovato nella configurazione di Moq, tutti i risultati sono tornati al loro posto. Dai risultati del retest, la tendenza principale non cambia — LINQ influisce sulle prestazioni comunque più di quanto faccia la riflessione. Tuttavia, è piacevole notare che il lavoro con la compilazione degli Expression non è stato vano, e il risultato è visibile sia per l'allocazione che per il tempo di esecuzione. Il primo avvio, quando vengono inizializzati i campi statici, è ovviamente più lento per il metodo “veloce”, ma poi la situazione cambia.
Ecco il risultato del retest:

Conclusione: quando si utilizza la riflessione in un contesto enterprise, non è necessario ricorrere a trucchi particolari — LINQ consumerà maggiormente le prestazioni. Tuttavia, nei metodi ad alta intensità di carico, che richiedono ottimizzazione, si può mantenere la riflessione sotto forma di inizializzatori e compilatori di delegate, che garantiranno poi una logica “veloce”. In questo modo puoi mantenere sia la flessibilità della riflessione che la velocità di esecuzione dell'applicazione.
Il codice con il benchmark è disponibile qui. Tutti coloro che desiderano possono verificare le mie affermazioni:
PS: il codice nei test utilizza IoC, mentre nei benchmark – una costruzione esplicita. Il fatto è che nella mia implementazione finale ho escluso tutti i fattori in grado di influenzare le prestazioni e di compromettere il risultato.
PPS: Grazie all'utente per aver scovato il mio errore nella configurazione di Moq, che ha influito sulle prime misurazioni. Se qualcuno dei lettori ha abbastanza karma, per favore mettetegli un like. La persona si è fermata, ha letto attentamente, ha verificato e ha segnalato l'errore. Ritengo che questo meriti rispetto e simpatia.
PPPS: grazie a quel lettore pignolo che ha fatto notare la stilistica e la formattazione. Sono per l'uniformità e la comodità. La diplomaticità nell'esposizione lascia a desiderare, ma ho preso in considerazione la critica. Vi invito a segnalare.
Fonte: habr.com
