Todos los artículos
Aprendiendo Concurrencia en Golang

Aprendiendo Concurrencia en Golang

Quería aprender un nuevo lenguaje de programación, así que después de probar algunos, terminé con Golang como uno de mis favoritos por su simplicidad y capacidades. Tiene características que no había usado en años, como multithreading y concurrencia.

blog-cover

Quería aprender un nuevo lenguaje, así que después de probar algunos, terminé con Golang como uno de mis favoritos por su simplicidad y capacidades. Tiene características que no había usado en años, como multithreading y concurrencia.

Golang (o Go) soporta concurrencia mediante hilos ligeros llamados goroutines. Son diferentes del multithreading tradicional de Java, donde hay que manejar sincronización y coordinación para gestionar recursos compartidos de forma segura. Las goroutines son ligeras, las gestiona el runtime de Go, y son más baratas de crear y manejar.

El paralelismo es hacer varias cosas simultáneamente. La concurrencia es lidiar con varias cosas a la vez. En ambos casos, no conocemos el orden de ejecución: no sabemos qué pasará primero ni qué terminará antes.

Imagina que cocinas: preparas una sopa, una ensalada y una tortilla. Eres una sola persona, pero preparas varios platos. Podrías terminar primero la ensalada, la sopa o la tortilla… no hay garantía. Esto es concurrencia: lidias con varias cosas a la vez. Cuando tu pareja viene a ayudarte, eso ya es paralelismo.

concurrencia vs multithreading

Recuerdo haber construido un juego similar en Java cuando aprendía multithreading hace diez años. Aprovecho esta oportunidad para hacerlo de nuevo con Go moderno.

Construi un juego de terminal que simula una carrera de caballos. Cada caballo es una goroutine que corre en una matriz bidimensional compartida. Cuando un caballo llega al final, notifica al canal compartido con los demas caballos (que corren en diferentes procesos) y todos se detienen, mostrando al ganador.

Separé el código en cuatro áreas para ayudar a visualizarlo:

  • Punto de entrada
  • Generando el tablero
  • Renderizando el juego
  • Moviendo los caballos

demo de carrera de caballos en terminal

Punto de entrada

La estructura Horse representa cada Caballo en la carrera. El juego consiste en una lista de líneas, en las cuales cada Caballo está corriendo.

type Horse struct {
  Name string // El nombre del caballo
  Line int    // La línea de competición
}

func (h Horse) Letter() string {
  return fmt.Sprintf("%c", h.Name[0])
}

func (h Horse) Equals(other *Horse) bool {
  return other != nil &&
    h.Line == other.Line &&
    h.Name == other.Name
}

func (h Horse) String() string {
  return fmt.Sprintf("%s (line:%d)", h.Name, h.Line)
}

Puedes generar un nuevo proceso usando la palabra clave go al invocar cualquier función. En este juego, esto se usa 1) para renderizar el juego RenderGame() y 2) para el movimiento de cada caballo startRuningHorseInLine(). El objetivo es mantener la “renderización” y la “lógica de movimiento” trabajando en paralelo.

func main() {
  const lines, lineLength = 12, 30

  board := NewRaceBoard(lines, lineLength)
  go RenderGame(board)

  winnerChan := make(chan Horse)
  for line := range board {
    // cada caballo será movido en diferentes procesos
    go startRunningHorseInLine(board, line, winnerChan)
  }

  // esperar hasta que un caballo llegue al final
  winner := <-winnerChan
  // renderizar una última vez para asegurar el último estado del tablero
  RenderRaceBoard(board, &winner)

  fmt.Println("Race finished!")
  fmt.Printf("# Winner: %s\n", winner)
}

Generando el tablero

El tablero de carreras es una matriz bidimensional de punteros a Horses. Cada línea “contiene” un solo Caballo: solo un puntero apunta a un Caballo real, el resto son nil. Al generar el Tablero, creamos un Caballo en la primera posición de cada línea.

func NewRaceBoard(lines, lineLength int) [][]*Horse {
  board := make([][]*Horse, lines)
  for line := range board {
    board[line] = make([]*Horse, lineLength)
    board[line][0] = &Horse{
      Name: generateHorseName(),
      Line: line,
    }
  }
  return board
}

Los nombres se generan aleatoriamente usando HorseNames.

var HorseNames = [][2]string{
  {"Alloping", "Giggles"},
  {"A-lot", "Gallop"},
  {"BoJack", "Jack"},
  {"Baroness", "Belle"},
  // ...
}

func generateHorseName() string {
  name := HorseNames[rand.Intn(len(HorseNames))][0]
  surname := HorseNames[rand.Intn(len(HorseNames))][1]

  return name + " " + surname
}

Renderizando el juego

Los métodos RenderGame(), renderRaceBoard(), renderRaceLine() y renderRacePosition() están separados para que cada uno tenga una responsabilidad clara: renderizar su sujeto correspondiente.

RenderGame() se está ejecutando en otro proceso usando go.

func RenderGame(board [][]*Horse) {
  for {
    time.Sleep(renderDelay * time.Millisecond)
    RenderRaceBoard(board, nil)
  }
}

func RenderRaceBoard(board [][]*Horse, winner *Horse) {
  // usar un "buffer de string" para guardar todo el estado del tablero
  // para que luego podamos usar una sola llamada IO para renderizarlo
  var buffer bytes.Buffer
  buffer.WriteString("\n")
  for line := range board {
    renderRaceLine(board, line, &buffer, winner)
  }
  clearScreen()
  fmt.Println(buffer.String())
}

func clearScreen() {
  cmd := exec.Command("clear")
  cmd.Stdout = os.Stdout
  cmd.Run()
}

func renderRaceLine(
  board [][]*Horse,
  line int,
  buffer *bytes.Buffer,
  winner *Horse,
) {
  buffer.WriteString(fmt.Sprintf(" %.2d | ", line))
  var current Horse
  for col := range board[line] {
    h := renderRacePosition(board, line, col, buffer, winner)
    if h != nil {
      current = *h
    }
  }
  buffer.WriteString(fmt.Sprintf("| %s", current.Name))

  if current.Equals(winner) {
    buffer.WriteString(" [Won!]")
  }
  buffer.WriteString("\n")
}

func renderRacePosition(
  board [][]*Horse,
  line, col int,
  buffer *bytes.Buffer,
  winner *Horse,
) *Horse {
  if board[line][col] == nil {
    buffer.WriteString(" ")
    return nil
  }

  current := board[line][col]

  if current.Equals(winner) {
    removeChars(buffer, col+1)
    for range board[line] {
      buffer.WriteString("-")
    }
  }

  buffer.WriteString(current.Letter())

  return current
}

Moviendo los caballos

En main(...), el winnerChan es un canal compartido que usará el primer Caballo que llegue a la última posición de su línea.

Cada Caballo ejecuta un bucle hasta llegar al final de la línea o recibir (vía winnerChan) el mensaje de que otro Caballo ya ganó. Hasta entonces, cada caballo se mueve de forma independiente, durmiendo milisegundos aleatorios antes de avanzar a la siguiente posición.

startRuningHorseInLine() se ejecuta en otro proceso usando go.

func main() {
  //...
  winnerChan := make(chan Horse)
  for line := range board {
    // cada caballo será movido en diferentes procesos
    go startHorseRunning(board, line, winnerChan)
  }
  // esperar hasta que un caballo llegue al final
  winner := <-winnerChan
  //...
}

func startRunningHorseInLine(board [][]*Horse, line int, winnerChan chan Horse) {
  for {
    select {
    case <-winnerChan: // verificar si otro caballo terminó
      return // en tal caso, entonces detener el bucle for
    default:
      time.Sleep(time.Millisecond * time.Duration(rand.Intn(maxSleepDelay)))
      moveHorseOnePos(board, line, winnerChan)
    }
  }
}

func moveHorseOnePos(board [][]*Horse, line int, winnerChan chan Horse) {
  cols := len(board[line])
  for col := cols - 1; col > 0; col-- {
    if board[line][col-1] == nil {
      continue
    }
    // aquí identificamos que hay un caballo en
    // la siguiente columna, así que lo movemos a la
    // columna actual, y ponemos `nil` en la otra
    board[line][col] = board[line][col-1]
    board[line][col-1] = nil

    if col+1 == cols {
      winnerChan <- *board[line][col]
    }
    break
  }
}

Código fuente

El código de este post es una versión simplificada. Si quieres ver el código completo funcionando, está aquí: Chemaclass/go-horse-racing.

Gracias a mi antiguo Team Lead, Andrei Boar, que me ayudó a revisar mi solución original y proporcionó una solución alternativa (más simple y mejor) que apliqué a mi código. Lo principal que aprendí fue usar un chan Horse para pasar el Caballo ganador desde main(), en vez de usar un chan bool y un sync.WaitGroup entre todos los hilos.

Atajos de Teclado

Movimiento vim hjkl

hArtículo anterior← left
jBajar↓ down
kSubir↑ up
lArtículo siguiente→ right
ggIr arriba
GIr al final
nSiguiente secciónnext heading
NSección anteriorprevious heading

Ir a g = go

ghIniciogo home
gbBloggo blog
grLecturasgo readings
gcCVgo cv

Acciones

/Buscarvim search
dCambiar temadark mode
tMostrar/ocultar índicetable of contents
iCambiar idiomai18n
mAlternar resaltadomark text

General

?Mostrar ayuda
EscCerrar
:Terminalvim command mode
↑↑↓↓←→←→BA???