Giocando a ping pong in Go

Il sommario

Approfondiremo il funzionamento delle goroutine del linguaggio Go studiando l’esempio del ping pong, ovvero due goroutine che si rimbalzano dati sullo stesso canale.
L’argomento è particolarmente interessante perché il supporto diretto alla programmazione concorrente offerto dal Go tramite goroutine e canali, promette di rendere semplice accedere alla potenza di elaborazione dei moderni dispositivi multicore.
L’esempio del ping pong — presentato recentemente anche al Google I/O 2013 — è da una parte semplice in modo che si possono capire i concetti della dinamica di esecuzione concorrente e, dall’altra, offre la possibilità di studiare in dettaglio il comportamento del programma evidenziandone le sottigliezze.

Il codice del ping pong

Riporto subito il codice in Go (tra l’altro già presentato anche da Juhan in una variante): nella funzione main() vengono lanciate due goroutine che eseguono indipendentemente la funzione player() non appena creato il canale di comunicazione tra le due. A questo punto sia la prima goroutine chiamata Ann, sia la seconda chiamata Bob sono bloccate perché nessun dato è ancora disponibile nel canale. Ad iniziare il gioco ci pensa l’istruzione successiva che spedisce il numero 1 nel canale.
Da questo momento cosa accade?

package main

import (
    "fmt"
    "time"
)

func main() {
    ball := make(chan int)
    go player("Ann", ball)
    go player("Bob", ball)

    ball <- 1 // start the match
    time.Sleep(10 * time.Second)
    <-ball // stop the match
}

func player(name string, ball chan int) {
    for {
        touch := <- ball
        fmt.Printf("Player %s: %d\n", name, touch)
        touch++
        ball <- touch
        time.Sleep(100 * time.Millisecond)
    }
}

Quello che accade è riportato nello schema seguente: le due linee verticali rappresentano lo stato delle due goroutine Ann e Bob, con lo scorrerere del tempo, ipotizzando che le istruzioni vengano eseguite in un tempo zero. Una croce sulla linea significa che la goroutine è bloccata, un tratto spesso significa che la goroutine è in attesa.
Le frecce indicano dati inviati sul canale ed infine a destra è riportata la stampa in console prodotta dal programma.

Schema dinamico per goroutine

Schema dinamico per le goroutine che giocano a ping pong… i tratti spessi sono i periodi in cui la goroutine è in idle, i tratti con la crocetta sono periodi in cui la goroutine è bloccata.

Lo schema risponde alle domande spontanee: perché al tempo zero entrambi i giocatori danno un tocco alla palla per poi darne uno alternativamente ogni 100 millisecondi?
Come fare per ottenere invece un gioco regolare fin da subito?
Ed ancora, perché se nella funzione principale prelevo un dato dal canale il gioco si ferma?

Risposte spontanee

Quando Ann riceve il primo numero (1) seguendo il codice si ricava che essa stampa 1 e spedisce 2 sul canale e poi si mette in attesa per 100 miliisecondi. Bob è in attesa sul canale, riceve immediatamente (o quasi) il 2, lo stampa ma si blocca all’istruzione che invia 3 sul canale perché Ann sta dormendo. Finalmente Ann si svegli per t=100ms, Bob può quindi spedire il dato ed entrare in letargo per i prossimi 100ms.
Nel frattempo Ann ha già stampato 3 ma non può preseguire perché il canale è bloccato almeno fino a t=200ms. E così via.
All’inizio, come potete notare eseguendo il programma, ci sono due rimbalzi ma poi il gioco si fa regolare per effetto del blocco del canale quando l’altra goroutine dorme…

Per ottenere un gioco regolare fin dall’inizio basta anticipare la messa in attesa della funzione rispetto all’invio sul canale, così (fate per esercizio lo schema dinamico corrispondente e confermatene la correttezza eseguendo il programma):

func player(name string, ball chan int) {
    for {
        touch := <- ball
        fmt.Printf("Player %s: %d\n", name, touch)
        time.Sleep(100 * time.Millisecond)
        touch++
        ball <- touch
    }
}

Infine, se è la funzione main() a prelevare il dato dal canale sia Ann che Bob si metteranno in attesa di nuovo sulla prima istruzione del ciclo for infinito. La funzione principale viene eseguita essa stessa in una goroutine, ed esegue una vera e propria intercettazione della palla.
Per dimostrare con un programma questo meccanismo consideriamo il seguente codice:

// ping pong test
package main

import (
    "fmt"
    "time"
)

func main() {
    ball := make(chan int)
    go player("Ann", ball)
    go player("Bob", ball)
    
    fmt.Println("Start the match")
    ball <- 1
    time.Sleep(10 * time.Second)
    fmt.Println("Pause the match for five seconds")
    tmp := <-ball
    time.Sleep(5 * time.Second)
    fmt.Println("Ok. Go again now")
    ball <- tmp
    time.Sleep(10 * time.Second)
    fmt.Println("Stop!")
    <-ball
}


func player( name string, ball chan int) {
    for {
        touch := <- ball
        fmt.Printf("Player %s: %d\n", name, touch)
        time.Sleep(1*time.Second)
        touch++
        ball <- touch
    }
}

Ok, un esercizio

Cosa succede se faccio giocare tre giocatori invece che due?
Anche in questo caso uno schema temporale di esecuzione come quello proposto chiarisce il comportamento del programma: ad ogni intervallo (per esempio i soliti 100ms iniziali) ci sono due giocatori che fanno un rimbalzo quindi il conteggio è doppio rispetto al caso precedente.
Fate la prova!

Ping pong multigiocatori

Se volessimo far giocare 10 giocatori l’idea potrebbe essere quella di collegarli tramite canali a formare un cerchio. Ciascun giocatore riceve la palla da quello alla sua destra e la rimanda a quello alla sua sinistra.
Ecco il curioso programma (ovvio che vi lascio verificare il risultato):

// ping pong test
package main

import (
    "fmt"
    "time"
)

const p = 10

func main() {
    // creo p canali
    var chs [p]chan int
    for i := 0; i < p; i++ {
        chs[i] = make(chan int)
    }
    // players in action
    for i := 1; i < p; i++ {
        go player(i, chs[i-1], chs[i])
    }
    go player(p, chs[p-1], chs[0])
    
    fmt.Println("Start the match")
    chs[0] <- 1
    time.Sleep(10 * time.Second)
    
    <-chs[0]
    fmt.Println("Stop!")
}

func player( name int, ball, pass chan int) {
    for {
        touch := <- ball
        fmt.Printf("Player %d: %d\n", name, touch)
        time.Sleep(500*time.Millisecond)
        touch++
        pass <- touch
    }
}

Attenzione però, perchè stranamente il programma non termina subito quando la funzione principale chiede di ricevere il numero da un canale. Quello che accade è che comunque la palla compie un giro fino ad arrivare al canale in cui attende la main().
Il motivo di questo comportamento a mio parere è che nel momento in cui desidereremo interrompere il gioco prelevando un numero da uno dei canali, due goroutine sono in competizione: quella della funzione principale e quella a cui il giocatore precedente vorrebbe inviare il numero, ed in questa competizione vince la prima volta la goroutine del giocatore, e la seconda quella della funzione principale.
In altre parole, la palla va al giocatore successivo e non all’arbitro che comunque avrà successo al passaggio successivo.
Su questo punto sarebbe interessante conoscere la vostra interpretazione. La soluzione che propongo io è quella di creare un canale a parte in cui da main() si spedisce un segnale di interruzione. La funzione player() va modificata con un istruzione select che per prima cosa tenta di ricevere dal canale di interruzione partita, altrimenti spedisce il numero di tocco al giocatore vicino:

// ping pong test
package main

import (
    "fmt"
    "time"
)

const p = 3

func main() {
    // creo p canali
    var chs [p]chan int
    stopsignal := make(chan int)
    for i := 0; i < p; i++ {
        chs[i] = make(chan int)
    }
    // players in action
    for i := 1; i < p; i++ {
        go player(i, chs[i-1], chs[i], stopsignal)
    }
    go player(p, chs[p-1], chs[0], stopsignal)

    fmt.Println("Start the match")
    chs[0] <- 1
    time.Sleep(10 * time.Second)
    fmt.Println("Stop!")
    stopsignal <- 1
    time.Sleep(3 * time.Second)
}

func player(name int, ball, pass, stop chan int) {
    for {
        touch := <-ball
        fmt.Printf("Player %d: %d\n", name, touch)
        time.Sleep(500 * time.Millisecond)
        touch++
        select {
            case <- stop:
                stop <- touch
            default:
                pass <- touch
        }
    }
}

Conclusioni

Ma ci devono sempre essere le conclusioni? Io vorrei continuare a giocare per esempio ma vi lancio volentieri la palla…
Alla prossima.
R.

Posta un commento o usa questo indirizzo per il trackback.

Rispondi

Inserisci i tuoi dati qui sotto o clicca su un'icona per effettuare l'accesso:

Logo di WordPress.com

Stai commentando usando il tuo account WordPress.com. Chiudi sessione /  Modifica )

Google photo

Stai commentando usando il tuo account Google. Chiudi sessione /  Modifica )

Foto Twitter

Stai commentando usando il tuo account Twitter. Chiudi sessione /  Modifica )

Foto di Facebook

Stai commentando usando il tuo account Facebook. Chiudi sessione /  Modifica )

Connessione a %s...

Questo sito utilizza Akismet per ridurre lo spam. Scopri come vengono elaborati i dati derivati dai commenti.

%d blogger hanno fatto clic su Mi Piace per questo: