Go Programming Language: un tutorial introduttivo sul Golang

Pubblicato: 2022-03-11

Che cos'è il linguaggio di programmazione Go?

Il relativamente nuovo linguaggio di programmazione Go si colloca perfettamente nel mezzo del panorama, fornendo molte buone funzionalità e omettendo deliberatamente molte cattive. Si compila velocemente, funziona velocemente, include un runtime e una garbage collection, ha un semplice sistema di tipi statici e interfacce dinamiche e un'eccellente libreria standard. Questo è il motivo per cui così tanti sviluppatori sono desiderosi di imparare a programmare Go.

Tutorial Golang: illustrazione del logo

Vai e OOP

OOP è una di quelle funzionalità che Go omette deliberatamente. Non ha sottoclassi, quindi non ci sono diamanti ereditari o super chiamate o metodi virtuali per inciampare. Tuttavia, molte delle parti utili di OOP sono disponibili in altri modi.

*I mixin* sono disponibili incorporando struct in modo anonimo, consentendo ai loro metodi di essere chiamati direttamente sullo struct contenitore (vedi embedding). La promozione dei metodi in questo modo è chiamata *forwarding* e non è la stessa cosa della sottoclasse: il metodo verrà comunque invocato sulla struttura interna incorporata.

Anche l'incorporamento non implica polimorfismo. Sebbene `A` possa avere una `B`, ciò non significa che sia una `B` -- le funzioni che richiedono una `B` non prenderanno invece una `A`. Per questo, abbiamo bisogno di interfacce , che incontreremo brevemente più avanti.

Nel frattempo, Golang prende una posizione forte sulle funzionalità che possono portare a confusione e bug. Omette idiomi OOP come ereditarietà e polimorfismo, a favore della composizione e delle interfacce semplici. Riduce la gestione delle eccezioni a favore di errori espliciti nei valori restituiti. C'è esattamente un modo corretto per disporre il codice Go, imposto dallo strumento gofmt . E così via.

Perché imparare il Golang?

Go è anche un ottimo linguaggio per scrivere programmi simultanei : programmi con molte parti che funzionano in modo indipendente. Un esempio ovvio è un server web: ogni richiesta viene eseguita separatamente, ma spesso le richieste devono condividere risorse come sessioni, cache o code di notifica. Ciò significa che i programmatori Go esperti devono gestire l'accesso simultaneo a tali risorse.

Sebbene Golang abbia un eccellente set di funzionalità di basso livello per la gestione della concorrenza, il loro utilizzo diretto può diventare complicato. In molti casi, una manciata di astrazioni riutilizzabili su quei meccanismi di basso livello rende la vita molto più semplice.

Nel tutorial di programmazione Go di oggi, esamineremo una di queste atrazioni: un wrapper che può trasformare qualsiasi struttura di dati in un servizio transazionale . Useremo un tipo di Fund come esempio: un semplice negozio per i fondi rimanenti della nostra startup, dove possiamo controllare il saldo ed effettuare prelievi.

Per dimostrarlo in pratica, costruiremo il servizio a piccoli passi, facendo un pasticcio lungo il percorso e poi ripulendolo di nuovo. Man mano che procediamo nel nostro tutorial Go, incontreremo molte fantastiche funzionalità del linguaggio Go, tra cui:

  • Tipi e metodi di struct
  • Unit test e benchmark
  • Goroutine e canali
  • Interfacce e tipizzazione dinamica

Costruire un fondo semplice

Scriviamo del codice per tracciare i finanziamenti della nostra startup. Il fondo inizia con un determinato saldo e il denaro può essere prelevato solo (scopriremo le entrate più avanti).

Questo grafico illustra un semplice esempio di goroutine utilizzando il linguaggio di programmazione Go.

Go non è deliberatamente un linguaggio orientato agli oggetti: non ci sono classi, oggetti o eredità. Invece, dichiareremo un tipo di struttura chiamato Fund , con una semplice funzione per creare nuove strutture di fondi e due metodi pubblici.

fondo.vai

 package funding type Fund struct { // balance is unexported (private), because it's lowercase balance int } // A regular function returning a pointer to a fund func NewFund(initialBalance int) *Fund { // We can return a pointer to a new struct without worrying about // whether it's on the stack or heap: Go figures that out for us. return &Fund{ balance: initialBalance, } } // Methods start with a *receiver*, in this case a Fund pointer func (f *Fund) Balance() int { return f.balance } func (f *Fund) Withdraw(amount int) { f.balance -= amount }

Test con benchmark

Quindi abbiamo bisogno di un modo per testare Fund . Invece di scrivere un programma separato, utilizzeremo il pacchetto di test di Go, che fornisce un framework sia per i test unitari che per i benchmark. La semplice logica del nostro Fund non vale davvero la pena di scrivere unit test, ma poiché in seguito parleremo molto dell'accesso simultaneo al fondo, scrivere un benchmark ha senso.

I benchmark sono come unit test, ma includono un ciclo che esegue lo stesso codice molte volte (nel nostro caso, fund.Withdraw(1) ). Ciò consente al framework di calcolare il tempo impiegato da ciascuna iterazione, calcolando la media delle differenze transitorie da ricerche sul disco, errori nella cache, pianificazione dei processi e altri fattori imprevedibili.

Il framework di test vuole che ogni benchmark venga eseguito per almeno 1 secondo (per impostazione predefinita). Per garantire ciò, chiamerà il benchmark più volte, passando ogni volta un valore crescente di "numero di iterazioni" (il campo bN ), fino a quando l'esecuzione richiede almeno un secondo.

Per ora, il nostro benchmark depositerà del denaro e poi lo ritirerà un dollaro alla volta.

fund_test.go

 package funding import "testing" func BenchmarkFund(b *testing.B) { // Add as many dollars as we have iterations this run fund := NewFund(bN) // Burn through them one at a time until they are all gone for i := 0; i < bN; i++ { fund.Withdraw(1) } if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

Ora eseguiamolo:

 $ go test -bench . funding testing: warning: no tests to run PASS BenchmarkWithdrawals 2000000000 1.69 ns/op ok funding 3.576s

È andata bene. Abbiamo eseguito due miliardi (!) di iterazioni e il controllo finale sul saldo è stato corretto. Possiamo ignorare l'avviso "nessun test da eseguire", che si riferisce agli unit test che non abbiamo scritto (nei successivi esempi di programmazione Go in questo tutorial, l'avviso viene eliminato).

Accesso simultaneo in Go

Ora rendiamo il benchmark simultaneo, per modellare utenti diversi che effettuano prelievi contemporaneamente. Per fare ciò, genereremo dieci goroutine e ciascuna di esse ritirerà un decimo del denaro.

Come strutturaremmo più goroutine simultanee nella lingua Go?

Le goroutine sono gli elementi costitutivi di base per la concorrenza nel linguaggio Go. Sono thread verdi: thread leggeri gestiti dal runtime Go, non dal sistema operativo. Ciò significa che puoi eseguirne migliaia (o milioni) senza alcun sovraccarico significativo. Le goroutine vengono generate con la parola chiave go e iniziano sempre con una funzione (o una chiamata al metodo):

 // Returns immediately, without waiting for `DoSomething()` to complete go DoSomething()

Spesso, vogliamo generare una breve funzione una tantum con poche righe di codice. In questo caso possiamo usare una chiusura invece del nome di una funzione:

 go func() { // ... do stuff ... }() // Must be a function *call*, so remember the ()

Una volta che tutte le nostre goroutine sono state generate, abbiamo bisogno di un modo per aspettare che finiscano. Potremmo costruirne uno noi stessi usando i canali , ma non li abbiamo ancora incontrati, quindi sarebbe saltare avanti.

Per ora, possiamo semplicemente usare il tipo WaitGroup nella libreria standard di Go, che esiste proprio per questo scopo. Ne creeremo uno (chiamato " wg ") e chiameremo wg.Add(1) prima di generare ogni lavoratore, per tenere traccia di quanti ce ne sono. Quindi i lavoratori riporteranno indietro usando wg.Done() . Nel frattempo, nella routine principale, possiamo semplicemente dire wg.Wait() per bloccare fino a quando ogni lavoratore ha terminato.

All'interno delle goroutine di lavoro nel nostro prossimo esempio, useremo defer per chiamare defer wg.Done() .

defer prende una chiamata di funzione (o metodo) e la esegue immediatamente prima che la funzione corrente ritorni, dopo che tutto il resto è stato fatto. Questo è utile per la pulizia:

 func() { resource.Lock() defer resource.Unlock() // Do stuff with resource }()

In questo modo possiamo facilmente abbinare Unlock con il suo Lock , per la leggibilità. Ancora più importante, una funzione differita verrà eseguita anche se c'è un panico nella funzione principale (qualcosa che potremmo gestire tramite try-finally in altri linguaggi).

Infine, le funzioni differite verranno eseguite nell'ordine inverso a cui sono state chiamate, il che significa che possiamo eseguire bene la pulizia nidificata (simile all'idioma C di goto s nidificato e label s, ma molto più ordinato):

 func() { db.Connect() defer db.Disconnect() // If Begin panics, only db.Disconnect() will execute transaction.Begin() defer transaction.Close() // From here on, transaction.Close() will run first, // and then db.Disconnect() // ... }()

OK, quindi con tutto ciò che ha detto, ecco la nuova versione:

fund_test.go

 package funding import ( "sync" "testing" ) const WORKERS = 10 func BenchmarkWithdrawals(b *testing.B) { // Skip N = 1 if bN < WORKERS { return } // Add as many dollars as we have iterations this run fund := NewFund(bN) // Casually assume bN divides cleanly dollarsPerFounder := bN / WORKERS // WaitGroup structs don't need to be initialized // (their "zero value" is ready to use). // So, we just declare one and then use it. var wg sync.WaitGroup for i := 0; i < WORKERS; i++ { // Let the waitgroup know we're adding a goroutine wg.Add(1) // Spawn off a founder worker, as a closure go func() { // Mark this worker done when the function finishes defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { fund.Withdraw(1) } }() // Remember to call the closure! } // Wait for all the workers to finish wg.Wait() if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

Possiamo prevedere cosa accadrà qui. I lavoratori eseguiranno tutti Withdraw uno sopra l'altro. Al suo interno, f.balance -= amount leggerà il saldo, ne sottrarrà uno e quindi lo riscriverà. Ma a volte due o più lavoratori leggeranno entrambi lo stesso saldo ed eseguiranno la stessa sottrazione, e finiremo con il totale sbagliato. Destra?

 $ go test -bench . funding BenchmarkWithdrawals 2000000000 2.01 ns/op ok funding 4.220s

No, passa ancora. Cos'è successo qua?

Ricorda che le goroutine sono thread verdi : sono gestite dal runtime Go, non dal sistema operativo. Il runtime pianifica le goroutine su tutti i thread del sistema operativo disponibili. Al momento della stesura di questo tutorial sul linguaggio Go, Go non cerca di indovinare quanti thread del sistema operativo dovrebbe utilizzare e, se ne vogliamo più di uno, dobbiamo dirlo. Infine, il runtime corrente non previene le goroutine: una goroutine continuerà a funzionare finché non fa qualcosa che suggerisce che è pronta per una pausa (come interagire con un canale).

Tutto ciò significa che, sebbene il nostro benchmark ora sia simultaneo, non è parallelo . Solo uno dei nostri lavoratori alla volta funzionerà e funzionerà fino al termine. Possiamo cambiarlo dicendo a Go di usare più thread, tramite la variabile di ambiente GOMAXPROCS .

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 account_test.go:39: Balance wasn't zero: 4238 ok funding 0.007s

Va meglio. Ora stiamo ovviamente perdendo alcuni dei nostri prelievi, come ci aspettavamo.

In questo esempio di programmazione Go, il risultato di più goroutine parallele non è favorevole.

Rendilo un server

A questo punto abbiamo diverse opzioni. Potremmo aggiungere un mutex esplicito o un blocco di lettura-scrittura attorno al fondo. Potremmo usare un confronto e uno scambio con un numero di versione. Potremmo fare di tutto e utilizzare uno schema CRDT (magari sostituendo il campo del balance con elenchi di transazioni per ciascun cliente e calcolando il saldo da quelli).

Ma non faremo nessuna di queste cose ora, perché sono disordinate o spaventose o entrambe le cose. Invece, decideremo che un fondo dovrebbe essere un server . Cos'è un server? È qualcosa con cui parli. In Go, le cose parlano attraverso i canali.

I canali sono il meccanismo di comunicazione di base tra le goroutine. I valori vengono inviati al canale (con channel <- value ) e possono essere ricevuti dall'altro lato (con value = <- channel ). I canali sono "sicuri per le goroutine", il che significa che un numero qualsiasi di goroutine può inviare e ricevere da loro contemporaneamente.

Buffering

Il buffering dei canali di comunicazione può essere un'ottimizzazione delle prestazioni in determinate circostanze, ma dovrebbe essere utilizzato con grande attenzione (e benchmarking!).

Tuttavia, ci sono usi per canali bufferizzati che non riguardano direttamente la comunicazione.

Ad esempio, un linguaggio di limitazione comune crea un canale con (ad esempio) dimensione del buffer `10` e quindi invia immediatamente dieci token al suo interno. Viene quindi generato un numero qualsiasi di goroutine di lavoro e ciascuna riceve un token dal canale prima di iniziare il lavoro e lo rimanda in seguito. Quindi, per quanti lavoratori ci siano, solo dieci lavoreranno mai contemporaneamente.

Per impostazione predefinita, i canali Go non sono bufferizzati . Ciò significa che l'invio di un valore a un canale si bloccherà finché un'altra goroutine non sarà pronta a riceverlo immediatamente. Go supporta anche dimensioni del buffer fisse per i canali (usando make(chan someType, bufferSize) ). Tuttavia, per un uso normale, di solito è una cattiva idea .

Immagina un server web per il nostro fondo, in cui ogni richiesta effettua un prelievo. Quando le cose sono molto occupate, il FundServer non sarà in grado di tenere il passo e le richieste che tentano di inviare al suo canale di comando inizieranno a bloccarsi e ad aspettare. A quel punto possiamo imporre un numero massimo di richieste nel server e restituire un codice di errore ragionevole (come un 503 Service Unavailable ) ai client oltre quel limite. Questo è il miglior comportamento possibile quando il server è sovraccarico.

L'aggiunta del buffering ai nostri canali renderebbe questo comportamento meno deterministico. Potremmo facilmente finire con lunghe code di comandi non elaborati basati su informazioni che il client ha visto molto prima (e forse per richieste che da allora erano scadute a monte). Lo stesso vale in molte altre situazioni, come l'applicazione di una contropressione su TCP quando il destinatario non riesce a tenere il passo con il mittente.

In ogni caso, per il nostro esempio Go, manterremo il comportamento predefinito senza buffer.

Useremo un canale per inviare comandi al nostro FundServer . Ogni lavoratore benchmark invierà comandi al canale, ma solo il server li riceverà.

Potremmo trasformare direttamente il nostro tipo di fondo in un'implementazione del server, ma sarebbe un pasticcio: mescoleremmo la gestione della concorrenza e la logica aziendale. Invece, lasceremo il tipo di fondo esattamente com'è e renderemo FundServer un wrapper separato attorno ad esso.

Come qualsiasi server, il wrapper avrà un ciclo principale in cui attende i comandi e risponde a ciascuno a turno. C'è un altro dettaglio che dobbiamo affrontare qui: il tipo di comandi.

Un diagramma del fondo utilizzato come server in questo tutorial di programmazione Go.

Puntatori

Avremmo potuto fare in modo che il nostro canale dei comandi prendesse *puntatori* ai comandi (`chan *TransactionCommand`). Perché non l'abbiamo fatto?

Passare i puntatori tra le goroutine è rischioso, perché entrambe le goroutine potrebbero modificarlo. Spesso è anche meno efficiente, perché l'altra goroutine potrebbe essere in esecuzione su un core CPU diverso (il che significa più invalidazione della cache).

Quando possibile, preferisci passare valori semplici.

Nella prossima sezione di seguito, invieremo diversi comandi diversi, ognuno con il proprio tipo di struttura. Vogliamo che il canale dei comandi del server ne accetti qualcuno. In un linguaggio OOP potremmo farlo tramite il polimorfismo: fare in modo che il canale prenda una superclasse, di cui i singoli tipi di comando erano sottoclassi. In Go, invece, utilizziamo le interfacce .

Un'interfaccia è un insieme di firme di metodo. Qualsiasi tipo che implementa tutti questi metodi può essere trattato come quell'interfaccia (senza essere dichiarato per farlo). Per la nostra prima esecuzione, le nostre strutture di comando non esporranno effettivamente alcun metodo, quindi utilizzeremo l'interfaccia vuota, interface{} . Poiché non ha requisiti, qualsiasi valore (inclusi valori primitivi come interi) soddisfa l'interfaccia vuota. Questo non è l'ideale – vogliamo solo accettare strutture di comando – ma ci torneremo più tardi.

Per ora, iniziamo con l'impalcatura per il nostro server Go:

server.go

 package funding type FundServer struct { Commands chan interface{} fund Fund } func NewFundServer(initialBalance int) *FundServer { server := &FundServer{ // make() creates builtins like channels, maps, and slices Commands: make(chan interface{}), fund: NewFund(initialBalance), } // Spawn off the server's main loop immediately go server.loop() return server } func (s *FundServer) loop() { // The built-in "range" clause can iterate over channels, // amongst other things for command := range s.Commands { // Handle the command } }

Ora aggiungiamo un paio di tipi di struct Golang per i comandi:

 type WithdrawCommand struct { Amount int } type BalanceCommand struct { Response chan int }

Il WithdrawCommand contiene solo l'importo da prelevare. Non c'è risposta. BalanceCommand ha una risposta, quindi include un canale su cui inviarlo. Ciò garantisce che le risposte andranno sempre nel posto giusto, anche se il nostro fondo deciderà in seguito di rispondere fuori servizio.

Ora possiamo scrivere il ciclo principale del server:

 func (s *FundServer) loop() { for command := range s.Commands { // command is just an interface{}, but we can check its real type switch command.(type) { case WithdrawCommand: // And then use a "type assertion" to convert it withdrawal := command.(WithdrawCommand) s.fund.Withdraw(withdrawal.Amount) case BalanceCommand: getBalance := command.(BalanceCommand) balance := s.fund.Balance() getBalance.Response <- balance default: panic(fmt.Sprintf("Unrecognized command: %v", command)) } } }

Hmm. È un po' brutto. Stiamo attivando il tipo di comando, utilizzando asserzioni di tipo e possibilmente andando in crash. Andiamo avanti comunque e aggiorniamo il benchmark per utilizzare il server.

 func BenchmarkWithdrawals(b *testing.B) { // ... server := NewFundServer(bN) // ... // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { server.Commands <- WithdrawCommand{ Amount: 1 } } }() } // ... balanceResponseChan := make(chan int) server.Commands <- BalanceCommand{ Response: balanceResponseChan } balance := <- balanceResponseChan if balance != 0 { b.Error("Balance wasn't zero:", balance) } }

Anche quello era un po' brutto, specialmente quando abbiamo controllato il bilanciamento. Non importa. Proviamolo:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 465 ns/op ok funding 2.822s

Molto meglio, non stiamo più perdendo i prelievi. Ma il codice sta diventando difficile da leggere e ci sono problemi più seri. Se mai emettiamo un BalanceCommand e poi dimentichiamo di leggere la risposta, il nostro server di fondi si bloccherà per sempre nel tentativo di inviarlo. Puliamo un po' le cose.

Fallo diventare un servizio

Un server è qualcosa con cui parli. Cos'è un servizio? Un servizio è qualcosa con cui parli con un'API . Invece di fare in modo che il codice client funzioni direttamente con il canale di comando, renderemo il canale non esportato (privato) e racchiuderemo i comandi disponibili in funzioni.

 type FundServer struct { commands chan interface{} // Lowercase name, unexported // ... } func (s *FundServer) Balance() int { responseChan := make(chan int) s.commands <- BalanceCommand{ Response: responseChan } return <- responseChan } func (s *FundServer) Withdraw(amount int) { s.commands <- WithdrawCommand{ Amount: amount } }

Ora il nostro benchmark può semplicemente dire server.Withdraw(1) and balance := server.Balance() , e ci sono meno possibilità di inviare accidentalmente comandi non validi o dimenticare di leggere le risposte.

Ecco come potrebbe essere l'utilizzo del fondo come servizio in questo esempio di programma in lingua Go.

C'è ancora molto standard in più per i comandi, ma su questo torneremo più tardi.

Transazioni

Alla fine, i soldi finiscono sempre. Siamo d'accordo sul fatto che smetteremo di prelevare quando il nostro fondo sarà sceso agli ultimi dieci dollari e spenderemo quei soldi per una pizza comune per festeggiare o commiserare. Il nostro benchmark rifletterà questo:

 // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { // Stop when we're down to pizza money if server.Balance() <= 10 { break } server.Withdraw(1) } }() } // ... balance := server.Balance() if balance != 10 { b.Error("Balance wasn't ten dollars:", balance) }

Questa volta possiamo davvero prevedere il risultato.

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 fund_test.go:43: Balance wasn't ten dollars: 6 ok funding 0.009s

Siamo tornati al punto di partenza: diversi lavoratori possono leggere il bilancio contemporaneamente e quindi aggiornarlo. Per far fronte a questo potremmo aggiungere della logica nel fondo stesso, come una proprietà minimumBalance , o aggiungere un altro comando chiamato WithdrawIfOverXDollars . Queste sono entrambe idee terribili. Il nostro accordo è tra di noi, non è una proprietà del fondo. Dovremmo tenerlo nella logica dell'applicazione.

Ciò di cui abbiamo veramente bisogno sono le transazioni , nello stesso senso delle transazioni del database. Poiché il nostro servizio esegue solo un comando alla volta, è semplicissimo. Aggiungeremo un comando Transact che contiene un callback (una chiusura). Il server eseguirà quel callback all'interno della propria goroutine, passando il Raw Fund . Il callback può quindi tranquillamente fare tutto ciò che vuole con il Fund .

Semafori ed errori

In questo prossimo esempio stiamo sbagliando due piccole cose.

Per prima cosa, stiamo usando un canale `Fatto` come semaforo per far sapere al codice chiamante quando la sua transazione è terminata. Va bene, ma perché il tipo di canale è `bool`? Invieremo sempre e solo `true` per significare "fatto" (cosa significherebbe anche inviare `false`?). Quello che vogliamo veramente è un valore a stato singolo (un valore che non ha valore?). In Go, possiamo farlo usando il tipo di struttura vuota: `struct{}`. Questo ha anche il vantaggio di utilizzare meno memoria. Nell'esempio continueremo con `bool` per non sembrare troppo spaventoso.

In secondo luogo, la nostra richiamata della transazione non restituisce nulla. Come vedremo tra poco, possiamo ottenere valori dal callback nel codice chiamante usando i trucchi dell'ambito. Tuttavia, le transazioni in un sistema reale presumibilmente fallirebbero a volte, quindi la convenzione Go prevede che la transazione restituisca un "errore" (e quindi controlli se è "zero" nel codice chiamante).

Per ora non lo stiamo facendo, poiché non abbiamo errori da generare.
 // Typedef the callback for readability type Transactor func(fund *Fund) // Add a new command type with a callback and a semaphore channel type TransactionCommand struct { Transactor Transactor Done chan bool } // ... // Wrap it up neatly in an API method, like the other commands func (s *FundServer) Transact(transactor Transactor) { command := TransactionCommand{ Transactor: transactor, Done: make(chan bool), } s.commands <- command <- command.Done } // ... func (s *FundServer) loop() { for command := range s.commands { switch command.(type) { // ... case TransactionCommand: transaction := command.(TransactionCommand) transaction.Transactor(s.fund) transaction.Done <- true // ... } } }

Le nostre richiamate di transazione non restituiscono direttamente nulla, ma il linguaggio Go rende facile ottenere direttamente valori da una chiusura, quindi lo faremo nel benchmark per impostare il flag pizzaTime quando il denaro sta per esaurirsi:

 pizzaTime := false for i := 0; i < dollarsPerFounder; i++ { server.Transact(func(fund *Fund) { if fund.Balance() <= 10 { // Set it in the outside scope pizzaTime = true return } fund.Withdraw(1) }) if pizzaTime { break } }

E controlla che funzioni:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 775 ns/op ok funding 4.637s

Nient'altro che transazioni

Potresti aver individuato un'opportunità per ripulire un po' le cose ora. Poiché abbiamo un comando Transact generico, non abbiamo più bisogno di WithdrawCommand o BalanceCommand . Li riscriviamo in termini di transazioni:

 func (s *FundServer) Balance() int { var balance int s.Transact(func(f *Fund) { balance = f.Balance() }) return balance } func (s *FundServer) Withdraw(amount int) { s.Transact(func (f *Fund) { f.Withdraw(amount) }) }

Ora l'unico comando che il server prende è TransactionCommand , quindi possiamo rimuovere l'intera interface{} pasticcio nella sua implementazione e fargli accettare solo i comandi di transazione:

 type FundServer struct { commands chan TransactionCommand fund *Fund } func (s *FundServer) loop() { for transaction := range s.commands { // Now we don't need any type-switch mess transaction.Transactor(s.fund) transaction.Done <- true } }

Molto meglio.

C'è un ultimo passo che potremmo fare qui. A parte le sue funzioni di convenienza per Balance e Withdraw , l'implementazione del servizio non è più vincolata al Fund . Invece di gestire un Fund , potrebbe gestire interface{} ed essere utilizzato per avvolgere qualsiasi cosa . Tuttavia, ogni callback di transazione dovrebbe quindi riconvertire l' interface{} in un valore reale:

 type Transactor func(interface{}) server.Transact(func(managedValue interface{}) { fund := managedValue.(*Fund) // Do stuff with fund ... })

Questo è brutto e soggetto a errori. Quello che vogliamo veramente sono i generici in fase di compilazione, quindi possiamo "modellare" un server per un tipo particolare (come *Fund ).

Sfortunatamente, Go non supporta ancora i generici. Ci si aspetta che arrivi alla fine, una volta che qualcuno capirà una sintassi e una semantica sensate per esso. Nel frattempo, un'attenta progettazione dell'interfaccia rimuove spesso la necessità di generici e, quando non lo fanno, possiamo cavarcela con le asserzioni di tipo (che vengono controllate in fase di esecuzione).

Abbiamo finito?

Sì.

Bene, ok, no.

Per esempio:

  • Un panico in una transazione ucciderà l'intero servizio.

  • Non ci sono timeout. Una transazione che non restituisce mai bloccherà il servizio per sempre.

  • Se il nostro Fondo fa crescere alcuni nuovi campi e una transazione va in crash a metà dell'aggiornamento, avremo uno stato incoerente.

  • Le transazioni possono far trapelare l'oggetto Fund gestito, il che non va bene.

  • Non esiste un modo ragionevole per effettuare transazioni su più fondi (come prelevare da uno e depositare in un altro). Non possiamo semplicemente annidare le nostre transazioni perché consentirebbe deadlock.

  • L'esecuzione di una transazione in modo asincrono ora richiede una nuova routine e un sacco di pasticci. Allo stesso modo, probabilmente vorremmo essere in grado di leggere lo stato del Fund più recente da altrove mentre è in corso una transazione di lunga durata.

Nel prossimo tutorial sul linguaggio di programmazione Go, esamineremo alcuni modi per affrontare questi problemi.

Correlati: Logica ben strutturata: un tutorial OOP di Golang