Informazioni sui canali memorizzati nel buffer

Completato

Come si è appreso, i canali non vengono memorizzati nel buffer per impostazione predefinita. Questo significa che accettano un'operazione di invio solo se è presente un'operazione di ricezione. In caso contrario, il programma verrà bloccato in attesa per sempre.

In alcuni casi è necessario questo tipo di sincronizzazione tra le goroutine. Tuttavia, in alcuni casi potrebbe essere necessario implementare semplicemente la concorrenza senza dover limitare il modo in cui le goroutine comunicano tra loro.

I canali memorizzati nel buffer inviano e ricevono dati senza bloccare il programma perché un canale memorizzato nel buffer si comporta come una coda. È possibile limitare le dimensioni della coda quando si crea il canale, come segue:

ch := make(chan string, 10)

Ogni volta che si invia qualcosa al canale, l'elemento viene aggiunto alla coda. Quindi, un'operazione di ricezione rimuove l'elemento dalla coda. Quando il canale è pieno, qualsiasi operazione di invio rimane semplicemente in attesa fino a quando non è disponibile spazio per i dati. Viceversa, se il canale è vuoto ed è presente un'operazione di lettura, viene bloccato fino a quando non c'è qualcosa da leggere.

Ecco un semplice esempio per comprendere i canali memorizzati nel buffer:

package main

import (
    "fmt"
)

func send(ch chan string, message string) {
    ch <- message
}

func main() {
    size := 4
    ch := make(chan string, size)
    send(ch, "one")
    send(ch, "two")
    send(ch, "three")
    send(ch, "four")
    fmt.Println("All data sent to the channel ...")

    for i := 0; i < size; i++ {
        fmt.Println(<-ch)
    }

    fmt.Println("Done!")
}

Quando si esegue il programma viene visualizzato l'output seguente:

All data sent to the channel ...
one
two
three
four
Done!

Si potrebbe dire che non c'è alcuna differenza ed è vero. Vediamo però cosa accade quando si modifica la variabile size impostando un numero inferiore (è anche possibile provare con un numero più alto), come illustrato di seguito:

size := 2

Quando si esegue di nuovo il programma, viene generato l'errore seguente:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.send(...)
        /Users/developer/go/src/concurrency/main.go:8
main.main()
        /Users/developer/go/src/concurrency/main.go:16 +0xf3
exit status 2

Il motivo è che le chiamate alla funzione send sono sequenziali. Non si sta creando una nuova goroutine, quindi non c'è nulla da accodare.

I canali sono strettamente connessi alle goroutine. Senza un'altra goroutine che riceve dati dal canale, è possibile che l'intero programma venga bloccato per sempre. Come si è visto, è un evento che si verifica.

Si vedrà ora un altro aspetto interessante. Verrà creata una goroutine per le ultime due chiamate (le prime due rientrano nel buffer in modo corretto) e verrà eseguito quattro volte un ciclo for. Ecco il codice:

func main() {
    size := 2
    ch := make(chan string, size)
    send(ch, "one")
    send(ch, "two")
    go send(ch, "three")
    go send(ch, "four")
    fmt.Println("All data sent to the channel ...")

    for i := 0; i < 4; i++ {
        fmt.Println(<-ch)
    }

    fmt.Println("Done!")
}

Quando si esegue il programma, funziona come previsto. È sempre consigliabile usare le goroutine quando si usano i canali.

Verrà ora testato il caso in cui si crea un canale memorizzato nel buffer con un numero di elementi maggiore del necessario. Si userà l'esempio visto in precedenza per controllare le API e creare un canale memorizzato nel buffer con dimensioni pari a 10:

package main

import (
    "fmt"
    "net/http"
    "time"
)

func main() {
    start := time.Now()

    apis := []string{
        "https://management.azure.com",
        "https://dev.azure.com",
        "https://api.github.com",
        "https://outlook.office.com/",
        "https://api.somewhereintheinternet.com/",
        "https://graph.microsoft.com",
    }

    ch := make(chan string, 10)

    for _, api := range apis {
        go checkAPI(api, ch)
    }

    for i := 0; i < len(apis); i++ {
        fmt.Print(<-ch)
    }

    elapsed := time.Since(start)
    fmt.Printf("Done! It took %v seconds!\n", elapsed.Seconds())
}

func checkAPI(api string, ch chan string) {
    _, err := http.Get(api)
    if err != nil {
        ch <- fmt.Sprintf("ERROR: %s is down!\n", api)
        return
    }

    ch <- fmt.Sprintf("SUCCESS: %s is up and running!\n", api)
}

Quando si esegue il programma, si ottiene lo stesso output di prima. È possibile provare a modificare le dimensioni del canale impostando valori minori o maggiori, ma il programma funzionerà comunque.

Differenze tra canali non memorizzati nel buffer e canali memorizzati nel buffer

A questo punto, è lecito chiedersi quando è appropriato usare un tipo o l'altro. Tutto dipende da come si vuole gestire il flusso delle comunicazioni tra le goroutine. I canali non memorizzati nel buffer comunicano in modo sincrono. Garantiscono che ogni volta che si inviano i dati, il programma venga bloccato fino a quando un utente non esegue la lettura dal canale.

Viceversa, i canali memorizzati nel buffer separano le operazioni di invio e ricezione. Non bloccano un programma, ma è necessario prestare attenzione perché potrebbe verificarsi un deadlock, come illustrato in precedenza. Quando si usano canali non memorizzati nel buffer, è possibile controllare il numero di goroutine che possono essere eseguite contemporaneamente. Ad esempio, nel caso di chiamate a un'API potrebbe essere necessario controllare il numero di chiamate eseguite ogni secondo per evitare eventuali blocchi.

Direzioni del canale

I canali in Go includono un'altra funzionalità interessante. Quando si usano i canali come parametri di una funzione, è possibile specificare se un canale deve inviare o ricevere dati. Man mano che il programma cresce, ci si potrebbe ritrovare con moltissime funzioni ed è buona norma documentare lo scopo di ogni canale per un uso appropriato. Oppure, quando si scrive una libreria potrebbe essere necessario esporre un canale come di sola lettura per mantenere la coerenza dei dati.

Per definire la direzione del canale, è possibile procedere in modo analogo a quando si leggono o si ricevono i dati. Questa operazione viene però eseguita in fase di dichiarazione del canale in un parametro di funzione. La sintassi per definire il tipo di canale come parametro in una funzione è:

chan<- int // it's a channel to only send data
<-chan int // it's a channel to only receive data

Quando si inviano dati attraverso un canale di sola ricezione, viene visualizzato un errore durante la compilazione del programma.

Il programma seguente verrà usato come esempio di due funzioni, una che legge i dati e un'altra che invia i dati:

package main

import "fmt"

func send(ch chan<- string, message string) {
    fmt.Printf("Sending: %#v\n", message)
    ch <- message
}

func read(ch <-chan string) {
    fmt.Printf("Receiving: %#v\n", <-ch)
}

func main() {
    ch := make(chan string, 1)
    send(ch, "Hello World!")
    read(ch)
}

Quando si esegue il programma viene visualizzato l'output seguente:

Sending: "Hello World!"
Receiving: "Hello World!"

Il programma chiarisce lo scopo di ogni canale in ogni funzione. Se si prova a usare un canale per inviare dati in un canale il cui scopo è solo la ricezione di dati, verrà generato un errore di compilazione. Ad esempio, provare a eseguire un'operazione simile alla seguente:

func read(ch <-chan string) {
    fmt.Printf("Receiving: %#v\n", <-ch)
    ch <- "Bye!"
}

Quando si esegue il comando viene visualizzato l'errore seguente:

# command-line-arguments
./main.go:12:5: invalid operation: ch <- "Bye!" (send to receive-only type <-chan string)

È preferibile ottenere un errore di compilazione piuttosto che usare un canale in modo improprio.

Multiplexing

Infine, si vedrà come interagire simultaneamente con più di un canale usando la parola chiave select. In alcuni casi, sarà necessario attendere che si verifichi un evento quando si usano più canali. Ad esempio, il programma potrebbe includere la logica per annullare un'operazione quando si verifica un'anomalia nei dati elaborati.

Un'istruzione select funziona come un'istruzione switch, ma per i canali. Blocca l'esecuzione del programma fino a quando non riceve un evento da elaborare. Se riceve più di un evento, ne sceglie uno in modo casuale.

Un aspetto essenziale dell'istruzione select è che termina l'esecuzione dopo l'elaborazione di un evento. Se si vuole attendere che si verifichino più eventi, potrebbe essere necessario usare un ciclo.

Si userà il programma seguente per vedere select in azione:

package main

import (
    "fmt"
    "time"
)

func process(ch chan string) {
    time.Sleep(3 * time.Second)
    ch <- "Done processing!"
}

func replicate(ch chan string) {
    time.Sleep(1 * time.Second)
    ch <- "Done replicating!"
}

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
    go process(ch1)
    go replicate(ch2)

    for i := 0; i < 2; i++ {
        select {
        case process := <-ch1:
            fmt.Println(process)
        case replicate := <-ch2:
            fmt.Println(replicate)
        }
    }
}

Quando si esegue il programma viene visualizzato l'output seguente:

Done replicating!
Done processing!

Si noti che la funzione replicate è terminata per prima, motivo per cui il relativo output viene visualizzato per primo nel terminale. La funzione main include un ciclo perché l'istruzione select termina non appena riceve un evento, ma si è ancora in attesa del completamento della funzione process.