Le linee guida per l'uso asincrono / in attesa in C # contraddicono i concetti di buona architettura e stratificazione dell'astrazione?


103

Questa domanda riguarda il linguaggio C #, ma mi aspetto che copra altri linguaggi come Java o TypeScript.

Microsoft consiglia le migliori pratiche sull'uso delle chiamate asincrone in .NET. Tra questi consigli, scegline due:

  • cambia la firma dei metodi asincroni in modo che restituiscano Task o Task <> (in TypeScript, sarebbe una Promessa <>)
  • cambiare i nomi dei metodi asincroni per terminare con xxxAsync ()

Ora, quando si sostituisce un componente sincrono di basso livello con un componente asincrono, questo ha un impatto sull'intero stack dell'applicazione. Poiché async / await ha un impatto positivo solo se utilizzato "completamente", significa che è necessario modificare i nomi di firma e metodo di ogni livello nell'applicazione.

Una buona architettura comporta spesso il posizionamento di astrazioni tra ogni livello, in modo tale che la sostituzione di componenti di basso livello con altri non sia vista dai componenti di livello superiore. In C #, le astrazioni assumono la forma di interfacce. Se introduciamo un nuovo componente asincrono di basso livello, ogni interfaccia nello stack di chiamate deve essere modificata o sostituita da una nuova interfaccia. Il modo in cui un problema viene risolto (asincrono o sincronizzato) in una classe di implementazione non viene più nascosto (sottratto) ai chiamanti. I chiamanti devono sapere se è sincronizzato o asincrono.

Gli asincroni / attendono le migliori pratiche in contraddizione con i principi della "buona architettura"?

Significa che ogni interfaccia (diciamo IEnumerable, IDataAccessLayer) ha bisogno della sua controparte asincrona (IAsyncEnumerable, IAsyncDataAccessLayer) in modo tale da poter essere sostituita nello stack quando si passa alle dipendenze asincrone?

Se spingiamo il problema un po 'oltre, non sarebbe più semplice assumere ogni metodo asincrono (per restituire un'attività <> o Promessa <>) e per i metodi per sincronizzare le chiamate asincrone quando in realtà non sono async? Ci si aspetta qualcosa dai futuri linguaggi di programmazione?


5
Anche se questa sembra una grande domanda di discussione, penso che sia troppo basata sull'opinione per poter rispondere qui.
Euforico,

22
@Euforico: penso che il problema risolto qui sia più profondo delle linee guida C #, questo è solo un sintomo del fatto che cambiare parti di un'applicazione in un comportamento asincrono può avere effetti non locali sul sistema complessivo. Quindi il mio istinto mi dice che ci deve essere una risposta non supponente per questo, basata su fatti tecnici. Quindi, incoraggio tutti qui a non chiudere troppo presto questa domanda, invece aspettiamo quali risposte arriveranno (e se sono troppo supposte, possiamo ancora votare per la chiusura).
Doc Brown,

25
@DocBrown Penso che la domanda più profonda qui sia "È possibile cambiare parte del sistema da sincrona ad asincrona senza che anche le parti che dipendono da essa debbano cambiare?" Penso che la risposta sia chiara "no". In tal caso, non vedo come "concetti di buona architettura e stratificazione" si applichino qui.
Euforico,

6
@Euforico: sembra una buona base per una risposta non supponente ;-)
Doc Brown,

5
@Gherman: perché C #, come molte lingue, non può fare il sovraccarico basandosi solo sul tipo di ritorno. Si finirebbe con metodi asincroni che hanno la stessa firma delle loro controparti di sincronizzazione (non tutti possono prendere un CancellationToken, e quelli che lo fanno potrebbero voler fornire un valore predefinito). Rimuovere i metodi di sincronizzazione esistenti (e rompere in modo proattivo tutto il codice) è un ovvio non avviatore.
Jeroen Mostert,

Risposte:


111

Di che colore è la tua funzione?

Potresti essere interessato a What Colour Is Your Function di Bob Nystrom 1 .

In questo articolo, descrive un linguaggio immaginario in cui:

  • Ogni funzione ha un colore: blu o rosso.
  • Una funzione rossa può chiamare funzioni blu o rosse, nessun problema.
  • Una funzione blu può chiamare solo funzioni blu.

Sebbene fittizio, ciò accade abbastanza regolarmente nei linguaggi di programmazione:

  • In C ++, un metodo "const" può solo richiamare altri metodi "const" this.
  • In Haskell, una funzione non-IO può chiamare solo funzioni non-IO.
  • In C #, una funzione di sincronizzazione può chiamare solo funzioni di sincronizzazione 2 .

Come hai capito, a causa di queste regole, le funzioni rosse tendono a diffondersi attorno alla base di codice. Ne inserisci uno e pian piano colonizza l'intera base di codice.

1 Bob Nystrom, oltre al blog, fa anche parte del team di Dart e ha scritto questa piccola serie di Crafting Interpreters; altamente raccomandato per qualsiasi linguaggio di programmazione / compilatore afficionado.

2 Non del tutto vero, poiché potresti chiamare una funzione asincrona e bloccare fino a quando non ritorna, ma ...

Limitazione linguistica

Questo è essenzialmente un limite di lingua / runtime.

Il linguaggio con threading M: N, ad esempio, come Erlang e Go, non ha asyncfunzioni: ogni funzione è potenzialmente asincrona e la sua "fibra" sarà semplicemente sospesa, scambiata e ricambiata quando sarà di nuovo pronta.

C # è andato con un modello di threading 1: 1, e quindi ha deciso di affinare la sincronicità nella lingua per evitare di bloccare accidentalmente i thread.

In presenza di limiti linguistici, le linee guida per la codifica devono adattarsi.


4
Le funzioni di I / O hanno la tendenza a diffondersi, ma con diligenza è possibile isolarle principalmente nelle funzioni vicino (nello stack durante la chiamata) ai punti di ingresso del codice. È possibile farlo facendo in modo che tali funzioni chiamino le funzioni IO e quindi altre funzioni elaborino l'output da esse e restituiscano tutti i risultati necessari per ulteriori IO. Trovo che questo stile renda le mie basi di codice molto più facili da gestire e lavorare. Mi chiedo se c'è un corollario con sincronicità.
jpmc26,

16
Cosa intendi con filettatura "M: N" e "1: 1"?
Captain Man,

14
@CaptainMan: threading 1: 1 significa mappare un thread dell'applicazione su un thread del sistema operativo, questo è il caso in linguaggi come C, C ++, Java o C #. Al contrario, il threading M: N significa mappare i thread delle applicazioni M sui thread N OS; nel caso di Go, un thread dell'applicazione si chiama "goroutine", nel caso di Erlang, si chiama "attore", e potresti anche averne sentito parlare come "fili verdi" o "fibre". Forniscono concorrenza senza richiedere parallelismo. Sfortunatamente l' articolo di Wikipedia sull'argomento è piuttosto scarso.
Matthieu M.,

2
Ciò è in qualche modo correlato, ma penso anche che questa idea di "colore della funzione" si applichi anche alle funzioni che si bloccano sull'input dell'utente, ad esempio finestre di dialogo modali, finestre di messaggio, alcune forme di I / O della console, ecc., Che sono strumenti che quadro ha avuto sin dall'inizio.
jrh

2
@MatthieuM. C # non ha un thread dell'applicazione per un thread del sistema operativo e non lo ha mai fatto. Questo è molto ovvio quando si interagisce con il codice nativo e particolarmente evidente quando si esegue in MS SQL. E, naturalmente, le routine di cooperazione erano sempre possibili (e sono ancora più semplici async); in effetti, era un modello abbastanza comune per la creazione di un'interfaccia utente reattiva. Era carino come Erlang? No. Ma è ancora molto
diverso

82

Hai ragione, c'è una contraddizione qui, ma non è che le "migliori pratiche" siano cattive. È perché la funzione asincrona fa essenzialmente cose diverse da una sincrona. Invece di attendere il risultato dalle sue dipendenze (di solito un po 'di I / O) crea un'attività che deve essere gestita dal ciclo di eventi principale. Questa non è una differenza che può essere ben nascosta sotto l'astrazione.


27
La risposta è semplice come questa IMO. La differenza tra un processo sincrono e asincrono non è un dettaglio dell'implementazione, è un contratto semanticamente diverso.
Formica P

11
@AntP: non sono d'accordo che sia così semplice; affiora nel linguaggio C #, ma non nel linguaggio Go per esempio. Quindi questa non è una proprietà intrinseca dei processi asincroni, è una questione di come i processi asincroni sono modellati nella lingua data.
Matthieu M.,

1
@MatthieuM. Sì, ma puoi usare i asyncmetodi in C # per fornire anche contratti sincroni, se lo desideri. L'unica differenza è che Go è asincrono di default mentre C # è sincrono di default. asyncti dà il secondo modello di programmazione - async è l'astrazione (ciò che effettivamente fa dipende da runtime, scheduler, contesto di sincronizzazione, implementazione del cameriere ...).
Luaan,

6

Un metodo asincrono si comporta in modo diverso rispetto a uno sincrono, come sono sicuro che tu ne sia consapevole. In fase di esecuzione, convertire una chiamata asincrona in una sincrona è banale, ma non si può dire il contrario. Quindi quindi la logica diventa, perché non facciamo metodi asincroni di ogni metodo che potrebbe richiederlo e lasciare che il chiamante "converta" come necessario in un metodo sincrono?

In un certo senso è come avere un metodo che genera eccezioni e un altro che è "sicuro" e non verrà lanciato anche in caso di errore. A che punto il programmatore è eccessivo per fornire questi metodi che altrimenti possono essere convertiti l'uno nell'altro?

In questo ci sono due scuole di pensiero: una è quella di creare più metodi, ognuno chiama un altro metodo possibilmente privato che consenta la possibilità di fornire parametri opzionali o lievi alterazioni del comportamento come essere asincrone. L'altro è minimizzare i metodi di interfaccia per scoprire gli elementi essenziali, lasciando al chiamante l'esecuzione delle modifiche necessarie.

Se sei della prima scuola, c'è una certa logica nel dedicare una classe a chiamate sincrone e asincrone per evitare di raddoppiare ogni chiamata. Microsoft tende a favorire questa scuola di pensiero e, per convenzione, per rimanere coerente con lo stile preferito da Microsoft, anche tu dovresti avere una versione Async, più o meno allo stesso modo in cui le interfacce iniziano quasi sempre con un "io". Vorrei sottolineare che non è di per sé sbagliato , perché è meglio mantenere uno stile coerente in un progetto piuttosto che farlo "nel modo giusto" e cambiare radicalmente lo stile per lo sviluppo che si aggiunge a un progetto.

Detto questo, tendo a favorire la seconda scuola, che è quella di minimizzare i metodi di interfaccia. Se penso che un metodo possa essere chiamato in modo asincrono, il metodo per me è asincrono. Il chiamante può decidere se attendere o meno il completamento di tale attività prima di procedere. Se questa interfaccia è un'interfaccia per una libreria, è più ragionevole farlo in questo modo per ridurre al minimo il numero di metodi che è necessario ammortizzare o modificare. Se l'interfaccia è per uso interno nel mio progetto, aggiungerò un metodo per ogni chiamata necessaria in tutto il mio progetto per i parametri forniti e nessun metodo "extra", e anche in questo caso, solo se il comportamento del metodo non è già coperto con un metodo esistente.

Tuttavia, come molte cose in questo campo, è in gran parte soggettivo. Entrambi gli approcci hanno i loro pro e contro. Microsoft ha anche avviato la convenzione di aggiungere lettere indicative di tipo all'inizio del nome della variabile e "m_" per indicare che è un membro, portando a nomi di variabili come m_pUser. Il mio punto di vista è che nemmeno Microsoft è infallibile e può anche commettere errori.

Detto questo, se il tuo progetto segue questa convenzione Async, ti consiglierei di rispettarlo e continuare lo stile. E solo una volta ricevuto un tuo progetto, puoi scriverlo nel modo che ritieni più adatto.


6
"In fase di esecuzione, convertire una chiamata asincrona in una sincrona è banale" Non sono sicuro che sia esattamente così. In .NET, usare .Wait()method e like può causare conseguenze negative, e in js, per quanto ne so, non è affatto possibile.
max630

2
@ max630 Non ho detto che non ci sono problemi simultanei da considerare, ma se inizialmente era un'attività sincrona , è probabile che non stia creando deadlock. Detto questo, banale non significa "doppio clic qui per convertire in sincrono". In js, si restituisce un'istanza Promise e si chiama risoluzione su di essa.
Neil,

2
Sì, è totalmente una seccatura nel riconvertire asincrono in sincronizzazione
Ewan,

4
@Neil In javascript, anche se si chiama Promise.resolve(x)e quindi si aggiungono richiamate, tali richiamate non verranno eseguite immediatamente.
NickL

1
@Neil se un'interfaccia espone un metodo asincrono, aspettarsi che l'attesa sull'attività non crei un deadlock non è una buona ipotesi. È molto meglio per l'interfaccia mostrare che è effettivamente sincrono nella firma del metodo, piuttosto che una promessa nella documentazione che potrebbe cambiare in una versione successiva.
Carl Walsh,

2

Immaginiamo che ci sia un modo per permetterti di chiamare le funzioni in modo asincrono senza cambiare la loro firma.

Sarebbe davvero bello e nessuno ti consiglierebbe di cambiare nome.

Tuttavia, le effettive funzioni asincrone, non solo quelle che attendono un'altra funzione asincrona, ma il livello più basso ha una struttura specifica per la loro natura asincrona. per esempio

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

Sono quei due bit di informazioni di cui il codice chiamante ha bisogno per eseguire il codice in modo asincrono che cose come Taske asyncincapsulano.

Esiste più di un modo per eseguire funzioni asincrone, async Taskè solo un modello incorporato nel compilatore tramite i tipi restituiti in modo da non dover collegare manualmente gli eventi


0

Tratterò il punto principale in modo meno C # ness e più generico:

Gli asincroni / attendono le migliori pratiche in contraddizione con i principi della "buona architettura"?

Direi che dipende solo dalla scelta che fai nella progettazione della tua API e da ciò che lasci all'utente.

Se vuoi che una funzione della tua API sia solo asincrona, c'è poco interesse a seguire la convenzione di denominazione. Restituisci sempre Task <> / Promise <> / Future <> / ... come tipo di ritorno, è auto-documentante. Se vuole una risposta sincronizzata, sarà comunque in grado di farlo aspettando, ma se lo fa sempre, farà un po 'di scaldabagno.

Tuttavia, se esegui solo la sincronizzazione dell'API, ciò significa che se un utente desidera che sia asincrono, dovrà gestire da sé la parte asincrona.

Ciò può comportare un sacco di lavoro extra, tuttavia può anche dare un maggiore controllo all'utente su quante chiamate simultanee consente, effettuare il timeout, ritirare e così via.

In un sistema di grandi dimensioni con un'API enorme, l'implementazione della maggior parte di essi per essere sincronizzata per impostazione predefinita potrebbe essere più semplice ed efficiente della gestione indipendente di ciascuna parte dell'API, specialmente se condividono risorse (filesystem, CPU, database, ...).

In effetti per le parti più complesse, potresti avere perfettamente due implementazioni della stessa parte della tua API, una sincrona che fa le cose utili, una asincrona che fa affidamento su quella sincrona gestisce le cose e gestisce solo la concorrenza, i carichi, i timeout e i tentativi .

Forse qualcun altro può condividere la sua esperienza con quello perché mi manca l'esperienza con tali sistemi.


2
@Miral Hai usato "chiama un metodo asincrono da un metodo di sincronizzazione" in entrambe le possibilità.
Adrian Wragg,

@AdrianWragg Così l'ho fatto; il mio cervello deve aver avuto una condizione di razza. Lo aggiusterò.
Miral,

È il contrario; è banale chiamare un metodo asincrono da un metodo di sincronizzazione, ma è impossibile chiamare un metodo di sincronizzazione da un metodo asincrono. (E dove le cose vanno completamente in pezzi è quando qualcuno cerca di fare comunque quest'ultimo, il che può portare a deadlock.) Quindi, se dovessi sceglierne uno, asincronizzare di default è la scelta migliore. Sfortunatamente è anche la scelta più difficile, perché un'implementazione asincrona può solo chiamare metodi asincroni.
Miral,

(E con questo intendo ovviamente un metodo di sincronizzazione bloccante . Puoi chiamare qualcosa che fa un calcolo associato alla CPU in modo sincrono da un metodo asincrono - anche se dovresti cercare di evitare di farlo a meno che tu non sappia di trovarti in un contesto di lavoro piuttosto che un contesto UI - ma bloccare le chiamate che attendono inattive su un blocco o per I / O o per un'altra operazione è una cattiva idea.)
Miral
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.