Capítulo 4. Patrones nativos de la nube
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
El progreso sólo es posible si nos entrenamos para pensar en los programas sin pensar en ellos como piezas de código ejecutable.1
Edsger W. Dijkstra, agosto de 1979
En 1991, cuando aún trabajaba en Sun Microsystems, L Peter Deutsch2 formuló las Falacias de la Informática Distribuida, que enumera algunas de las falsas suposiciones que suelen hacer los programadores nuevos (y no tan nuevos) en las aplicaciones distribuidas:
-
La red es fiable: los conmutadores fallan, los routers se desconfiguran
-
La latencia es cero: se tarda tiempo en mover los datos a través de una red
-
El ancho de banda es infinito: una red sólo puede manejar una cantidad determinada de datos a la vez
-
La red es segura: no compartas secretos en texto plano; encripta todo
-
La topología no cambia: los servidores y servicios van y vienen
-
Hay un administrador: múltiples administradores conducen a soluciones heterogéneas
-
El coste de transporte es cero: desplazar los datos cuesta tiempo y dinero
-
La red es homogénea: cada red es (a veces muy) diferente
Si se me permite la osadía, me gustaría añadir también una novena:
-
Los serviciosson fiables: los servicios de los que dependes pueden fallar en cualquier momento
En este capítulo, presentaré una selección de patrones idiomáticos -paradigmas de desarrollo probados y contrastados- diseñados para abordar una o más de las condiciones descritas en las Falacias de Deutsch, y demostraré cómo implementarlos en Go. Ninguno de los patrones tratados en este libro es original -algunos existen desde que existen las aplicaciones distribuidas-, pero la mayoría no se han publicado antes juntos en una sola obra. Muchos de ellos son exclusivos de Go o tienen implementaciones novedosas en Go en relación con otros lenguajes.
Lamentablemente, este libro no cubrirá patrones a nivel de infraestructura como los patrones Bulkhead o Gatekeeper. En gran medida, esto se debe a que nos centramos en el desarrollo de la capa de aplicación en Go, y esos patrones, aunque indispensables, funcionan en un nivel de abstracción totalmente distinto. Si te interesa saber más, te recomiendo Cloud Native Infrastructure de Justin Garrison y Kris Nova (O'Reilly) y Designing Distributed Systems de Brendan Burns (O'Reilly).
El Paquete Contexto
La mayoría de los ejemplos de código de este capítulo utilizan el paquete context
, que se introdujo en Go 1.7 para proporcionar un medio idiomático de transportar plazos, señales de cancelación y valores de solicitud entre procesos. Contiene una única interfaz, context.Context
, cuyos métodos se enumeran a continuación:
type
Context
interface
{
// Done returns a channel that's closed when this Context is cancelled.
Done
()
<-
chan
struct
{}
// Err indicates why this context was cancelled after the Done channel is
// closed. If Done is not yet closed, Err returns nil.
Err
()
error
// Deadline returns the time when this Context should be cancelled; it
// returns ok==false if no deadline is set.
Deadline
()
(
deadline
time
.
Time
,
ok
bool
)
// Value returns the value associated with this context for key, or nil
// if no value is associated with key. Use with care.
Value
(
key
interface
{})
interface
{}
}
Tres de estos métodos pueden utilizarse para saber algo sobre el estado de cancelación o el comportamiento de un valor Context
. El cuarto, Value
, puede utilizarse para recuperar un valor asociado a una clave arbitraria. Context
El método Value
de Go es objeto de cierta controversia en el mundo Go, y se tratará con más detalle en "Definición de valores con ámbito de solicitud".
Lo que el contexto puede hacer por ti
Un valor context.Context
se utiliza pasándolo directamente a una petición de servicio, que a su vez puede pasarlo a una o más subpeticiones. Lo que hace que esto sea útil es que cuando se cancela el Context
, todas las funciones que lo tengan (o un Context
derivado; más sobre esto en las Figuras4-1,4-2 y4-3) recibirán la señal, lo que les permitirá coordinar su cancelación y reducir la cantidad de esfuerzo desperdiciado.
Tomemos, por ejemplo, una petición de un usuario a un servicio, que a su vez hace una petición a una base de datos. En un escenario ideal, las peticiones del usuario, la aplicación y la base de datos pueden diagramarse como en la Figura 4-1.
Pero, ¿qué ocurre si el usuario finaliza su petición antes de que se complete por completo? En la mayoría de los casos, ajenos al contexto general de la solicitud, los procesos seguirán viviendo de todos modos(Figura 4-2), consumiendo recursos para proporcionar un resultado que nunca se utilizará.
Sin embargo, al compartir un Context
para cada solicitud posterior, se puede enviar a todos los procesos de larga duración una señal simultánea de "hecho", lo que permite coordinar la señal de cancelación entre cada uno de los procesos(Figura 4-3).
Y lo que es más importante, los valores de Context
también son seguros para los hilos, es decir, pueden ser utilizados con seguridad por múltiples goroutines que se ejecuten simultáneamente sin temor a comportamientos inesperados.
Crear contexto
Puedes obtener un nuevo context.Context
utilizando una de estas dos funciones:
func Background() Context
-
Devuelve un
Context
vacío que nunca se cancela, no tiene valores ni fecha límite. Suele utilizarse en la función principal, en la inicialización y en las pruebas, y comoContext
de nivel superior para las solicitudes entrantes. func TODO() Context
-
También proporciona un
Context
vacío, pero está pensado para ser utilizado como marcador de posición cuando no está claro quéContext
utilizar o cuando unContext
padre aún no está disponible.
Definir plazos y tiempos de espera del contexto
El paquete context
también incluye una serie de métodos para crear valores derivados de Context
que te permiten dirigir el comportamiento de cancelación, ya sea aplicando un tiempo de espera o mediante un gancho de función que pueda desencadenar explícitamente una cancelación.
func WithDeadline(Context, time.Time) (Context, CancelFunc)
-
Acepta una hora concreta a la que se cancelará el
Context
y se cerrará el canalDone
. func WithTimeout(Context, time.Duration) (Context, CancelFunc)
-
Acepta una duración tras la cual se cancelará el
Context
y se cerrará el canalDone
. func WithCancel(Context) (Context, CancelFunc)
-
A diferencia de las funciones anteriores,
WithCancel
no acepta nada, y sólo devuelve una función a la que se puede llamar para cancelar explícitamente laContext
.
Estas tres funciones devuelven un Context
derivado que incluye cualquier decoración solicitada, y un context.CancelFunc
, una función de parámetro cero a la que se puede llamar para cancelar explícitamente el Context
y todos sus valores derivados.
Consejo
Cuando se anula un Context
, también se anulan todos los Context
s que se deriven de él. Los Context
s de los que se derivó no se anulan.
Definir valores de solicitud
Por último, el paquete context
incluye una función que se puede utilizar para definir un par clave-valor arbitrario con ámbito de solicitud al que se pueda acceder desde el Context
devuelto -y todos los valores Context
derivados de él- mediante el método Value
.
func WithValue(parent Context, key, val interface{}) Context
-
WithValue
devuelve una derivación deparent
en la que akey
se le asocia el valorval
.
Utilizar un contexto
Cuando se inicia una solicitud de servicio, ya sea por una solicitud entrante o desencadenada por la función main
, el proceso de nivel superior utilizará la función Background
para crear un nuevo valor Context
, posiblemente decorándolo con una o más de las funciones context.With*
, antes de pasarlo a cualquier subpetición. Entonces, esas subpeticiones sólo tendrán que vigilar el canal Done
en busca de señales de cancelación.
Por ejemplo, echa un vistazo a la siguiente función Stream
:
func
Stream
(
ctx
context
.
Context
,
out
chan
<-
Value
)
error
{
// Create a derived Context with a 10s timeout; dctx
// will be cancelled upon timeout, but ctx will not.
// cancel is a function that will explicitly cancel dctx.
dctx
,
cancel
:=
context
.
WithTimeout
(
ctx
,
time
.
Second
*
10
)
// Release resources if SlowOperation completes before timeout
defer
cancel
()
res
,
err
:=
SlowOperation
(
dctx
)
if
err
!=
nil
{
// True if dctx times out
return
err
}
for
{
select
{
case
out
<-
res
:
// Read from res; send to out
case
<-
ctx
.
Done
():
// Triggered if ctx is cancelled
return
ctx
.
Err
()
}
}
}
Stream
recibe un ctx Context
como parámetro de entrada, que envía a WithTimeout
para crear dctx
, un Context
derivado con un tiempo de espera de 10 segundos. Debido a esta decoración, la llamada a SlowOperation(dctx)
podría agotarse tras diez segundos y devolver un error. Sin embargo, las funciones que utilicen el ctx
original no tendrán esta decoración de tiempo de espera y no se agotarán.
Más abajo, el valor original ctx
se utiliza en un bucle for
alrededor de una sentencia select
para recuperar valores del canal res
proporcionado por la función SlowOperation
. Observa la sentencia case <-ctx.Done()
, que se ejecuta cuando el canal ctx.Done
se cierra para devolver un valor de error apropiado.
Estructura de este capítulo
La presentación general de cada patrón en este capítulo se basa libremente en la utilizada en el famoso libro de Patrones de Diseño "Gang of Four",3 pero más sencilla y menos formal. Cada patrón se abre con una descripción muy breve de su finalidad y de las razones para utilizarlo, y va seguido de las secciones siguientes:
- Aplicabilidad
-
Contexto y descripciones de dónde puede aplicarse este patrón.
- Participantes
-
Un listado de los componentes del patrón y sus funciones.
- Aplicación
-
Un debate sobre la solución y su aplicación.
- Código de muestra
-
Una demostración de cómo se puede implementar el código en Go.
Patrones de estabilidad
Los patrones de estabilidad que aquí se presentan abordan uno o varios de los supuestos señalados por las Falacias de la Informática Distribuida. En general, están pensados para que los apliquen las aplicaciones distribuidas con el fin de mejorar su propia estabilidad y la del sistema mayor del que forman parte.
Disyuntor
El Interruptor Automático degrada automáticamente las funciones de servicio en respuesta a un fallo probable, evitando fallos mayores o en cascada al eliminar los errores recurrentes y proporcionar respuestas de error razonables.
Aplicabilidad
Si hubiera que resumir las falacias de la informática distribuida en un punto, sería que los errores y los fallos son un hecho innegable en los sistemas distribuidos nativos de la nube. Los servicios se desconfiguran, las bases de datos se bloquean, las redes se particionan. No podemos evitarlo; sólo podemos aceptarlo y dar cuenta de ello.
No hacerlo puede tener consecuencias bastante desagradables. Todos las hemos visto, y no son bonitas. Algunos servicios pueden seguir intentando inútilmente hacer su trabajo y devolviendo tonterías a su cliente; otros pueden fracasar catastróficamente e incluso caer en una espiral de muerte por colapso/reinicio. No importa, porque al final todos están malgastando recursos, ocultando el origen del fallo original y haciendo que los fallos en cascada sean aún más probables.
Por otra parte, un servicio diseñado suponiendo que sus dependencias pueden fallar en cualquier momento puede responder razonablemente cuando lo hacen. El Circuit Breaker permite a un servicio detectar tales fallos y "abrir el circuito" dejando temporalmente de ejecutar peticiones, proporcionando en su lugar a los clientes un mensaje de error coherente con el contrato de comunicación del servicio.
Por ejemplo, imagina un servicio que (idealmente) recibe una petición de un cliente, ejecuta una consulta a la base de datos y devuelve una respuesta. ¿Qué ocurre si falla la base de datos? El servicio podría seguir intentando inútilmente consultarla de todos modos, inundando los registros con mensajes de error y, finalmente, desconectándose o devolviendo errores inútiles. Un servicio de este tipo puede utilizar un Interruptor Automático para "abrir el circuito" cuando falle la base de datos, impidiendo que el servicio realice más peticiones condenadas a la ruina a la base de datos (al menos durante un tiempo), y permitiéndole responder al cliente inmediatamente con una notificación significativa.
Participantes
Este patrón incluye a los siguientes participantes:
- Circuito
-
La función que interactúa con el servicio.
- Interruptor
-
Un cierre con la misma firma de función que Circuito.
Aplicación
Esencialmente, el Circuit Breaker no es más que un patrón Adapter especializado, con Breaker
envolviendo Circuit
para añadir alguna lógica adicional de gestión de errores.
Al igual que el interruptor eléctrico del que este patrón deriva su nombre, Breaker
tiene dos estados posibles: cerrado y abierto. En el estado cerrado todo funciona normalmente. Todas las solicitudes recibidas del cliente por Breaker
se reenvían sin cambios a Circuit
, y todas las respuestas de Circuit
se reenvían de nuevo al cliente. En el estado abierto, Breaker
no reenvía las solicitudes a Circuit
. En su lugar, "falla rápido" respondiendo con un mensaje de error informativo.
Breaker
realiza un seguimiento interno de los errores devueltos por Circuit
; si el número de errores consecutivos devueltos por Circuit
supera un umbral definido, Breaker
se dispara y su estado pasa a abierto.
La mayoría de las implementaciones del Interruptor Automático incluyen alguna lógica para cerrar automáticamente el circuito tras un cierto periodo de tiempo. Ten en cuenta, sin embargo, que machacar un servicio que ya funciona mal con muchos reintentos puede causar sus propios problemas, por lo que lo normal es incluir algún tipo de backoff, lógica que reduce la tasa de reintentos a lo largo del tiempo. En realidad, el tema de los reintentos es bastante matizado, pero se tratará en detalle en "Reintentar: Reintentar peticiones".
En un servicio multinodo, esta implementación puede ampliarse para incluir algún mecanismo de almacenamiento compartido, como una caché de red Memcached o Redis, para rastrear el estado del circuito.
Código de muestra
Comenzamos creando un tipo Circuit
que especifica la firma de la función que interactúa con tu base de datos u otro servicio ascendente. En la práctica, esto puede tomar cualquier forma que sea apropiada para tu funcionalidad. Sin embargo, debe incluir un error
en su lista de retorno:
type
Circuit
func
(
context
.
Context
)
(
string
,
error
)
En este ejemplo, Circuit
es una función que acepta un valor Context
, que se describió en profundidad en "El paquete Contexto". Tu implementación puede variar.
La función Breaker
acepta cualquier función que se ajuste a la definición de tipo Circuit
, y un entero sin signo que representa el número de fallos consecutivos permitidos antes de que el circuito se abra automáticamente. A cambio, proporciona otra función que también se ajusta a la definición de tipo Circuit
:
func
Breaker
(
circuit
Circuit
,
failureThreshold
uint
)
Circuit
{
var
consecutiveFailures
int
=
0
var
lastAttempt
=
time
.
Now
()
var
m
sync
.
RWMutex
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
m
.
RLock
()
// Establish a "read lock"
d
:=
consecutiveFailures
-
int
(
failureThreshold
)
if
d
>=
0
{
shouldRetryAt
:=
lastAttempt
.
Add
(
time
.
Second
*
2
<<
d
)
if
!
time
.
Now
().
After
(
shouldRetryAt
)
{
m
.
RUnlock
()
return
""
,
errors
.
New
(
"service unreachable"
)
}
}
m
.
RUnlock
()
// Release read lock
response
,
err
:=
circuit
(
ctx
)
// Issue request proper
m
.
Lock
()
// Lock around shared resources
defer
m
.
Unlock
()
lastAttempt
=
time
.
Now
()
// Record time of attempt
if
err
!=
nil
{
// Circuit returned an error,
consecutiveFailures
++
// so we count the failure
return
response
,
err
// and return
}
consecutiveFailures
=
0
// Reset failures counter
return
response
,
nil
}
}
La función Breaker
construye otra función, también del tipo Circuit
, que envuelve a circuit
para proporcionar la funcionalidad deseada. Puede que reconozcas esto de "Funciones anónimas y cierres " como un cierre: una función anidada con acceso a las variables de su función padre. Como verás, todas las funciones de "estabilidad" implementadas en este capítulo funcionan de este modo.
El cierre funciona contando el número de errores consecutivos devueltos porcircuit
. Si ese valor alcanza el umbral de fallo, devuelve el error "servicio inalcanzable" sin llamar realmente a circuit
. Cualquier llamada con éxito a circuit
hace que consecutiveFailures
se ponga a 0, y el ciclo vuelve a empezar.
El cierre incluye incluso un mecanismo de reintento automático que permite que las peticiones vuelvan a llamar a circuit
al cabo de varios segundos, con un backoff exponencial en el que las duraciones de los retardos entre reintentos se duplican aproximadamente con cada intento. Aunque sencillo y bastante común, en realidad éste no es el algoritmo de reintento ideal. Repasaremos exactamente por qué en "Algoritmos de reintento".
Rebote
Debounce limita la frecuencia de invocación de una función para que sólo se realice realmente la primera o la última de un grupo de llamadas.
Aplicabilidad
El rebote es el segundo de nuestros patrones etiquetado con un tema de circuito eléctrico. Concretamente, debe su nombre a un fenómeno en el que los contactos de un interruptor "rebotan" cuando se abren o cierran, haciendo que el circuito fluctúe un poco antes de estabilizarse. Normalmente no es gran cosa, pero este "rebote de los contactos" puede ser un verdadero problema en los circuitos lógicos en los que una serie de pulsos de encendido/apagado puede interpretarse como un flujo de datos. La práctica de eliminar el rebote de los contactos para que sólo se transmita una señal por un contacto de apertura o cierre se llama "desbordamiento."
En el mundo de los servicios, a veces nos encontramos realizando un conjunto de operaciones potencialmente lentas o costosas cuando sólo bastaría con una. Utilizando el patrón Debounce, una serie de llamadas similares que están estrechamente agrupadas en el tiempo se restringen a una sola llamada, normalmente la primera o la última de un lote.
Esta técnica se ha utilizado en el mundo de JavaScript durante años para limitar el número de operaciones que podrían ralentizar el navegador tomando sólo la primera de una serie de eventos de usuario o para retrasar una llamada hasta que el usuario esté preparado. Probablemente ya hayas visto alguna aplicación de esta técnica en la práctica. Todos estamos familiarizados con la experiencia de utilizar una barra de búsqueda cuya ventana emergente de autocompletar no se muestra hasta que dejas de escribir, o de hacer spam al pulsar un botón sólo para ver los clics posteriores al primero ignorados.
Los que nos especializamos en servicios de backend podemos aprender mucho de nuestros hermanos de frontend, que llevan años trabajando para tener en cuenta los problemas de fiabilidad, latencia y ancho de banda inherentes a los sistemas distribuidos. Por ejemplo, este enfoque podría utilizarse para recuperar algún recurso remoto que se actualice lentamente sin atascarse, haciendo perder tiempo tanto al cliente como al servidor con peticiones inútiles.
Este patrón es similar a "Acelerar", en el sentido de que limita la frecuencia con la que se puede llamar a una función. Pero donde Debounce restringe grupos de invocaciones, Throttle simplemente limita según el periodo de tiempo. Para saber más sobre la diferencia entre los patrones Debounce y Throttle, consulta "¿Cuál es la diferencia entre Throttle y Debounce?".
Participantes
Este patrón incluye a los siguientes participantes:
- Circuito
-
La función de regular.
- Rebote
-
Un cierre con la misma firma de función que Circuito.
Aplicación
La implementación de Debounce es, en realidad, muy similar a la de Circuit Breaker, en el sentido de que envuelve a Circuit para proporcionar la lógica de limitación de velocidad. En realidad, esa lógica es bastante sencilla: en cada llamada de la función externa -independientemente de su resultado- se establece un intervalo de tiempo. Cualquier llamada posterior realizada antes de que expire ese intervalo de tiempo se ignora; cualquier llamada realizada después se pasa a la función interna. Esta implementación, en la que se llama a la función interna una vez y se ignoran las llamadas posteriores, se denomina función-primera, y es útil porque permite almacenar en caché y devolver la respuesta inicial de la función interna.
Una implementación de función-última esperará una pausa tras una serie de llamadas antes de llamar a la función interna. Esta variante es común en el mundo de JavaScript cuando un programador desea una cierta cantidad de entrada antes de realizar una llamada a una función, como cuando una barra de búsqueda espera una pausa en la escritura antes de autocompletarse. La función-última tiende a ser menos común en los servicios backend porque no proporciona una respuesta inmediata, pero puede ser útil si tu función no necesita resultados de inmediato.
Código de muestra
Al igual que en la implementación del Circuit Breaker, empezamos definiendo un tipo de función con la firma de la función que queremos limitar. También como en el Circuit Breaker, la llamamos Circuit
; es idéntica a la declarada en ese ejemplo. De nuevo, Circuit
puede adoptar cualquier forma que sea apropiada para tu funcionalidad, pero debe incluir un error
en sus retornos:
type
Circuit
func
(
context
.
Context
)
(
string
,
error
)
La similitud con la implementación del Circuit Breaker es bastante intencionada: su compatibilidad los hace "encadenables", como se demuestra a continuación:
func
myFunction
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
/* ... */
}
wrapped
:=
Breaker
(
Debounce
(
myFunction
))
response
,
err
:=
wrapped
(
ctx
)
La implementación de la función-primero de Debounce-DebounceFirst
-es muy sencilla en comparación con la función-último, porque sólo necesita realizar un seguimiento de la última vez que se llamó y devolver un resultado almacenado en caché si se vuelve a llamar menos de d
duración después:
func
DebounceFirst
(
circuit
Circuit
,
d
time
.
Duration
)
Circuit
{
var
threshold
time
.
Time
var
result
string
var
err
error
var
m
sync
.
Mutex
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
m
.
Lock
()
defer
func
()
{
threshold
=
time
.
Now
().
Add
(
d
)
m
.
Unlock
()
}()
if
time
.
Now
().
Before
(
threshold
)
{
return
result
,
err
}
result
,
err
=
circuit
(
ctx
)
return
result
,
err
}
}
Esta implementación de DebounceFirst
se esfuerza por garantizar la seguridad de los hilos envolviendo toda la función en un mutex. Aunque esto obligará a que las llamadas solapadas al principio de un cluster tengan que esperar hasta que el resultado se almacene en caché, también garantiza que circuit
se llame exactamente una vez, al principio de un cluster. Un defer
garantiza que el valor de threshold
, que representa el momento en que finaliza un cluster (si no hay más llamadas), se reinicia con cada llamada.
Nuestra implementación de la función-última es un poco más complicada porque implica el uso de un time.Ticker
para determinar si ha pasado suficiente tiempo desde que se llamó a la función por última vez, y para llamar a circuit
cuando así sea. Alternativamente, podríamos crear un nuevo time.Ticker
con cada llamada, pero eso puede resultar bastante caro si se llama con frecuencia:
type
Circuit
func
(
context
.
Context
)
(
string
,
error
)
func
DebounceLast
(
circuit
Circuit
,
d
time
.
Duration
)
Circuit
{
var
threshold
time
.
Time
=
time
.
Now
()
var
ticker
*
time
.
Ticker
var
result
string
var
err
error
var
once
sync
.
Once
var
m
sync
.
Mutex
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
m
.
Lock
()
defer
m
.
Unlock
()
threshold
=
time
.
Now
().
Add
(
d
)
once
.
Do
(
func
()
{
ticker
=
time
.
NewTicker
(
time
.
Millisecond
*
100
)
go
func
()
{
defer
func
()
{
m
.
Lock
()
ticker
.
Stop
()
once
=
sync
.
Once
{}
m
.
Unlock
()
}()
for
{
select
{
case
<-
ticker
.
C
:
m
.
Lock
()
if
time
.
Now
().
After
(
threshold
)
{
result
,
err
=
circuit
(
ctx
)
m
.
Unlock
()
return
}
m
.
Unlock
()
case
<-
ctx
.
Done
():
m
.
Lock
()
result
,
err
=
""
,
ctx
.
Err
()
m
.
Unlock
()
return
}
}
}()
})
return
result
,
err
}
}
Al igual que DebounceFirst
, DebounceLast
utiliza un valor llamado threshold
para indicar el final de un grupo de llamadas (suponiendo que no haya llamadas adicionales). Sin embargo, la similitud termina en gran medida ahí.
Observarás que casi toda la función se ejecuta dentro del método Do
de un valor sync.Once
, lo que garantiza que (como su nombre indica) la función contenida se ejecute exactamente una vez. Dentro de este bloque, se utiliza un time.Ticker
para comprobar sithreshold
se ha pasado y para llamar a circuit
en caso afirmativo. Por último, se detiene el time.Ticker
, se reinicia el sync.Once
y se prepara el ciclo para repetirse.
Reintentar
El reintento tiene en cuenta un posible fallo transitorio en un sistema distribuido, reintentando de forma transparente una operación fallida.
Aplicabilidad
Los errores transitorios son un hecho de la vida cuando se trabaja con sistemas distribuidos complejos. Pueden estar causados por cualquier número de condiciones (esperemos que) temporales, especialmente si el servicio o recurso de red descendente dispone de estrategias de protección, como el estrangulamiento que rechaza temporalmente las peticiones con una carga de trabajo elevada, o estrategias adaptativas como el autoescalado que puede añadir capacidad cuando sea necesario.
Estos fallos suelen resolverse solos al cabo de un rato, por lo que repetir la petición tras un retraso razonable tiene probabilidades (aunque no garantías) de éxito. No tener en cuenta los fallos transitorios puede dar lugar a un sistema innecesariamente frágil. Por otra parte, aplicar una estrategia de reintento automático puede mejorar considerablemente la estabilidad del servicio, lo que puede beneficiarlo tanto a él como a sus consumidores ascendentes.
Participantes
Este patrón incluye a los siguientes participantes:
- Efector
-
La función que interactúa con el servicio.
- Reintentar
-
Una función que acepta el Efecto y devuelve un cierre con la misma firma de función que el Efecto.
Aplicación
Este patrón funciona de forma similar al Circuit Breaker o Debounce, en el sentido de que hay un tipo, Effector, que define una firma de función. Esta firma puede adoptar cualquier forma que sea apropiada para tu implementación, pero cuando se implemente la función que ejecuta la operación potencialmente fallida, debe coincidir con la firma definida por el Efector.
La función Reintentar acepta la función Efecto definida por el usuario y devuelve una función Efecto que envuelve la función definida por el usuario para proporcionar la lógica de reintento. Junto con la función definida por el usuario, Reintentar también acepta un número entero que describe el número máximo de intentos de reintento que hará, y un time.Duration
que describe cuánto tiempo esperará entre cada intento de reintento. Si el parámetro retries
es 0, la lógica de reintento se convertirá en un no-op.
Nota
Aunque no se incluye aquí, la lógica de reintento incluirá normalmente algún tipo de algoritmo de retroceso.
Código de muestra
La firma del argumento de la función Retry
es Effector
. Es exactamente igual que los tipos de función de los patrones anteriores:
type
Effector
func
(
context
.
Context
)
(
string
,
error
)
La propia función Retry
es relativamente sencilla, al menos si la comparamos con las funciones que hemos visto hasta ahora:
func
Retry
(
effector
Effector
,
retries
int
,
delay
time
.
Duration
)
Effector
{
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
for
r
:=
0
;
;
r
++
{
response
,
err
:=
effector
(
ctx
)
if
err
==
nil
||
r
>=
retries
{
return
response
,
err
}
log
.
Printf
(
"Attempt %d failed; retrying in %v"
,
r
+
1
,
delay
)
select
{
case
<-
time
.
After
(
delay
):
case
<-
ctx
.
Done
():
return
""
,
ctx
.
Err
()
}
}
}
}
Puede que ya te hayas dado cuenta de qué es lo que hace que la función Retry
sea tan delgada: aunque devuelve una función, esa función no tiene ningún estado externo. Esto significa que no necesitamos ningún mecanismo elaborado para soportar la concurrencia.
Para utilizar Retry
, podemos implementar la función que ejecuta la operación potencialmente fallida y cuya firma coincide con el tipo Effector
; este papel lo desempeñaEmulateTransientError
en el siguiente ejemplo:
var
count
int
func
EmulateTransientError
(
ctx
context
.
Context
)
(
string
,
error
)
{
count
++
if
count
<=
3
{
return
"intentional fail"
,
errors
.
New
(
"error"
)
}
else
{
return
"success"
,
nil
}
}
func
main
()
{
r
:=
Retry
(
EmulateTransientError
,
5
,
2
*
time
.
Second
)
res
,
err
:=
r
(
context
.
Background
())
fmt
.
Println
(
res
,
err
)
}
En la función main
, la función EmulateTransientError
se pasa a Retry
, proporcionando la variable de función r
. Cuando se llama a r
, se llama a EmulateTransientError
, y se vuelve a llamar tras un retardo si devuelve un error, según la lógica de reintentos mostrada anteriormente. Finalmente, tras el cuarto intento, EmulateTransientError
devuelve un error nil
y sale.
Acelerador
El acelerador limita la frecuencia de una llamada a una función a un número máximo de invocaciones por unidad de tiempo.
Aplicabilidad
El patrón Acelerador recibe su nombre de un dispositivo utilizado para controlar el flujo de un fluido, como la cantidad de combustible que entra en el motor de un coche. Al igual que su mecanismo homónimo, el Acelerador restringe el número de veces que se puede llamar a una función durante un periodo de tiempo. Por ejemplo:
-
A un usuario sólo se le pueden permitir 10 peticiones de servicio por segundo.
-
Un cliente puede limitarse a llamar a una función concreta una vez cada 500 milisegundos.
-
A una cuenta sólo se le pueden permitir tres intentos fallidos de inicio de sesión en un periodo de 24 horas.
Tal vez la razón más común para aplicar un Acelerador sea tener en cuenta los picos bruscos de actividad que podrían saturar el sistema con un número posiblemente irrazonable de peticiones que podrían ser caras de satisfacer, o conducir a la degradación del servicio y, finalmente, al fallo. Aunque puede ser posible que un sistema escale para añadir capacidad suficiente para satisfacer la demanda de los usuarios, esto lleva tiempo, y puede que el sistema no sea capaz de reaccionar con suficiente rapidez.
Participantes
Este patrón incluye a los siguientes participantes:
- Efector
-
La función de regular.
- Acelerador
-
Una función que acepta el Efecto y devuelve un cierre con la misma firma de función que el Efecto.
Aplicación
El patrón Acelerador es similar a muchos de los otros patrones descritos en este capítulo: se implementa como una función que acepta una función efector y devuelve un cierre Throttle
con la misma firma que proporciona la lógica de limitación de velocidad.
El algoritmo más común para implementar el comportamiento de limitación de velocidad es el cubo de fichas, que utiliza la analogía de un cubo que puede contener un número máximo de fichas. Cuando se llama a una función, se coge una ficha del cubo, que se rellena a un ritmo fijo.
La forma en que un Throttle
trata las solicitudes cuando no hay suficientes tokens en el cubo para pagarlas puede variar en función de las necesidades del desarrollador. Algunas estrategias comunes son:
- Devuelve un error
-
Ésta es la estrategia más básica y es habitual cuando sólo intentas restringir un número irrazonable o potencialmente abusivo de solicitudes de clientes. Un servicio RESTful que adopte esta estrategia podría responder con un estado
429 (Too Many Requests)
. - Reproducir la respuesta de la última llamada de función realizada con éxito
-
Esta estrategia puede ser útil cuando es probable que un servicio o una llamada a una función costosaproporcione un resultado idéntico si se llama demasiado pronto. Se utiliza habitualmente en el mundoJavaScript.
- Pon en cola la solicitud para su ejecución cuando haya suficientes fichas disponibles
-
Este enfoque puede ser útil cuando quieras gestionar finalmente todas las solicitudes, pero también es más complejo y puede requerir que se tenga cuidado para garantizar que no se agote la memoria.
Código de muestra
El siguiente ejemplo implementa un algoritmo muy básico de "cubo de fichas" que utiliza la estrategia "error":
type
Effector
func
(
context
.
Context
)
(
string
,
error
)
func
Throttle
(
e
Effector
,
max
uint
,
refill
uint
,
d
time
.
Duration
)
Effector
{
var
tokens
=
max
var
once
sync
.
Once
return
func
(
ctx
context
.
Context
)
(
string
,
error
)
{
if
ctx
.
Err
()
!=
nil
{
return
""
,
ctx
.
Err
()
}
once
.
Do
(
func
()
{
ticker
:=
time
.
NewTicker
(
d
)
go
func
()
{
defer
ticker
.
Stop
()
for
{
select
{
case
<-
ctx
.
Done
():
return
case
<-
ticker
.
C
:
t
:=
tokens
+
refill
if
t
>
max
{
t
=
max
}
tokens
=
t
}
}
}()
})
if
tokens
<=
0
{
return
""
,
fmt
.
Errorf
(
"too many calls"
)
}
tokens
--
return
e
(
ctx
)
}
}
Esta implementación de Throttle
es similar a la de nuestros otros ejemplos en que envuelve una función efectora e
con un cierre que contiene la lógica de limitación de velocidad. Al cubo se le asignan inicialmente max
fichas; cada vez que se activa el cierre, comprueba si le quedan fichas. Si hay tokens disponibles, disminuye en uno el recuento de tokens y activa la función efector. En caso contrario, se devuelve un error. Las fichas se añaden a un ritmo de refill
fichas cada duración d
.
Tiempo de espera
El tiempo de espera permite a un proceso dejar de esperar una respuesta una vez que está claro que ésta puede no llegar.
Aplicabilidad
La primera de las falacias de la informática distribuida es que "la red es fiable", y es la primera por algo. Los conmutadores fallan, los routers y cortafuegos se desconfiguran; los paquetes se quedan en negro. Aunque tu red funcione a la perfección, no todos los servicios son lo bastante considerados como para garantizar una respuesta significativa y oportuna -o ninguna respuesta en absoluto- en caso de avería.
El tiempo de espera representa una solución común a este dilema, y es tan maravillosamente simple que apenas se puede considerar un patrón: dada una solicitud de servicio o una llamada a una función que se está ejecutando durante más tiempo del esperado, la persona que llama simplemente... deja de esperar.
Sin embargo, no confundas "simple" o "común" con "inútil". Al contrario, la ubicuidad de la estrategia del tiempo de espera es un testimonio de su utilidad. El uso juiciosode los tiempos de espera puede proporcionar un cierto grado de aislamiento de fallos, evitando fallos en cascaday reduciendo la posibilidad de que un problema en un recurso aguas abajo se convierta en tuproblema.
Participantes
Este patrón incluye a los siguientes participantes:
- Cliente
-
El cliente que quiere ejecutar SlowFunction.
- FunciónLenta
-
La función de larga duración que implementa la funcionalidad deseada por el Cliente.
- Tiempo de espera
-
Una función que envuelve a SlowFunction y que implementa la lógica del tiempo de espera.
Aplicación
Hay varias formas de implementar un tiempo de espera en Go, pero la forma idiomática es utilizar la funcionalidad que proporciona el paquete context
. Para más información, consulta "El paquete Context".
En un mundo ideal, cualquier función posiblemente larga aceptará directamente un parámetro context.Context
. Si es así, tu trabajo es bastante sencillo: sólo tienes que pasarle un valor Context
decorado con la función context.WithTimeout
:
ctx
:=
context
.
Background
()
ctxt
,
cancel
:=
context
.
WithTimeout
(
ctx
,
10
*
time
.
Second
)
defer
cancel
()
result
,
err
:=
SomeFunction
(
ctxt
)
Sin embargo, no siempre es así, y con las bibliotecas de terceros no siempre tienes la opción de refactorizar para aceptar un valor Context
. En estos casos,lo mejor puede ser envolver la llamada a la función de forma que respete tuContext
.
Por ejemplo, imagina que tienes una función potencialmente de larga duración que no sólo no acepta un valor Context
, sino que procede de un paquete que no controlas. Si el Cliente llamara directamente a SlowFunction, se vería obligado a esperar hasta que la función finalizara, si es que alguna vez lo hace. ¿Y ahora qué?
En lugar de llamar directamente a SlowFunction, puedes llamarla dentro de una goroutine. De esta forma, puedes capturar los resultados que devuelve, si los devuelve en un periodo de tiempo aceptable. Pero esto también te permite seguir adelante si no lo hace.
Para ello, podemos aprovechar algunas herramientas que ya hemos visto antes: context.Context
para los tiempos de espera, canales para comunicar los resultados y select
para atrapar al que actúe primero.
Código de muestra
El siguiente ejemplo imagina la existencia de una función ficticia, Slow
, cuya ejecución puede o no completarse en un tiempo razonable, y cuya firma se ajusta a la siguiente definición de tipo:
type
SlowFunction
func
(
string
)
(
string
,
error
)
En lugar de llamar directamente a Slow
, proporcionamos una función Timeout
, que envuelve un SlowFunction
proporcionado en un cierre y devuelve una función WithContext
, que añade un context.Context
a la lista de parámetros de SlowFunction
:
type
WithContext
func
(
context
.
Context
,
string
)
(
string
,
error
)
func
Timeout
(
f
SlowFunction
)
WithContext
{
return
func
(
ctx
context
.
Context
,
arg
string
)
(
string
,
error
)
{
chres
:=
make
(
chan
string
)
cherr
:=
make
(
chan
error
)
go
func
()
{
res
,
err
:=
f
(
arg
)
chres
<-
res
cherr
<-
err
}()
select
{
case
res
:=
<-
chres
:
return
res
,
<-
cherr
case
<-
ctx
.
Done
():
return
""
,
ctx
.
Err
()
}
}
}
Dentro de la función que construye Timeout
, Slow
se ejecuta en una goroutine, y sus valores de retorno se envían a canales construidos a tal efecto, siempre y cuando se complete.
La siguiente declaración de goroutine es un bloque select
en dos canales: el primero de los canales de respuesta de la función Slow
, y el canal Done
del valor Context
. Si el primero se completa primero, el cierre devolverá los valores de retorno de la función Slow
; en caso contrario, devuelve el error proporcionado por el Context
.
Utilizar la función Timeout
no es mucho más complicado que consumir directamente Slow
, salvo que en lugar de una llamada a la función, tenemos dos: la llamada a Timeout
para recuperar el cierre, y la llamada al propio cierre:
func
main
()
{
ctx
:=
context
.
Background
()
ctxt
,
cancel
:=
context
.
WithTimeout
(
ctx
,
1
*
time
.
Second
)
defer
cancel
()
timeout
:=
Timeout
(
Slow
)
res
,
err
:=
timeout
(
ctxt
,
"some input"
)
fmt
.
Println
(
res
,
err
)
}
Por último, aunque normalmente se prefiere implementar los tiempos de espera de servicio utilizandocontext.Context
los tiempos de espera del canal también pueden implementarse utilizando el canal proporcionado por la función time.After
. Consulta "Implementación de los tiempos de espera del canal" para ver un ejemplo de cómo hacerlo.
Patrones de concurrencia
A menudo, un servicio nativo en la nube tendrá que hacer malabarismos eficientes con múltiples procesos y gestionar niveles de carga elevados (y muy variables), idealmente sin tener que sufrir los problemas y gastos de la ampliación. Como tal, necesita ser altamente concurrente y capaz de gestionar múltiples peticiones simultáneas de múltiples clientes. Aunque Go es conocido por su compatibilidad con la concurrencia, pueden producirse cuellos de botella, y de hecho se producen. Aquí se presentan algunos de los patrones que se han desarrollado para evitarlos.
Fan-In
La entrada en abanico multiplexa varios canales de entrada en un canal de salida.
Aplicabilidad
Los servicios que tienen un cierto número de trabajadores que generan todos salida pueden encontrar útil combinar todas las salidas de los trabajadores para procesarlas como un único flujo unificado. Para estos casos utilizamos el patrón de entrada en abanico, que puede leer desde varios canales de entrada multiplexándolos en un único canal de destino.
Participantes
Este patrón incluye a los siguientes participantes:
- Fuentes
-
Conjunto de uno o más canales de entrada con el mismo tipo. Aceptado por Embudo.
- Destino
-
Un canal de salida del mismo tipo que Fuentes. Creado y proporcionado por Funnel.
- Embudo
-
Acepta las Fuentes y devuelve inmediatamente el Destino. Cualquier entrada de cualquier Fuente será emitida por el Destino.
Aplicación
Embudo se implementa como una función que recibe de cero a N canales de entrada(Fuentes). Para cada canal de entrada en Fuentes, la función Embudo inicia una goroutina independiente para leer valores de su canal asignado y reenviarlos a un único canal de salida compartido por todas las goroutinas(Destino).
Código de muestra
La función Funnel
es una función variádica que recibe sources
: de cero a N canales de algún tipo (int
en el ejemplo siguiente):
func
Funnel
(
sources
...<-
chan
int
)
<-
chan
int
{
dest
:=
make
(
chan
int
)
// The shared output channel
var
wg
sync
.
WaitGroup
// Used to automatically close dest
// when all sources are closed
wg
.
Add
(
len
(
sources
))
// Set size of the WaitGroup
for
_
,
ch
:=
range
sources
{
// Start a goroutine for each source
go
func
(
c
<-
chan
int
)
{
defer
wg
.
Done
()
// Notify WaitGroup when c closes
for
n
:=
range
c
{
dest
<-
n
}
}(
ch
)
}
go
func
()
{
// Start a goroutine to close dest
wg
.
Wait
()
// after all sources close
close
(
dest
)
}()
return
dest
}
Para cada canal de la lista de sources
, Funnel
inicia una goroutina dedicada que lee valores de su canal asignado y los reenvía a dest
, un canal de salida único compartido por todas las goroutinas.
Observa el uso de un sync.WaitGroup
para garantizar que el canal de destino se cierra adecuadamente. Inicialmente, se crea un WaitGroup
y se establece el número total de canales de origen. Si se cierra un canal, su goroutine asociada sale, llamando a wg.Done
. Cuando se cierran todos los canales, el contador del Grupo de Espera llega a cero, se libera el bloqueo impuesto por wg.Wait
y se cierra el canal dest
.
Utilizar Funnel
es razonablemente sencillo: dados N canales de origen (o una porción de N canales), pasa los canales a Funnel
. El canal de destino devuelto puede leerse de la forma habitual, y se cerrará cuando se cierren todos los canales de origen:
func
main
()
{
sources
:=
make
([]
<-
chan
int
,
0
)
// Create an empty channel slice
for
i
:=
0
;
i
<
3
;
i
++
{
ch
:=
make
(
chan
int
)
sources
=
append
(
sources
,
ch
)
// Create a channel; add to sources
go
func
()
{
// Run a toy goroutine for each
defer
close
(
ch
)
// Close ch when the routine ends
for
i
:=
1
;
i
<=
5
;
i
++
{
ch
<-
i
time
.
Sleep
(
time
.
Second
)
}
}()
}
dest
:=
Funnel
(
sources
...
)
for
d
:=
range
dest
{
fmt
.
Println
(
d
)
}
}
Este ejemplo crea una porción de tres canales int
, a los que se envían los valores de 1 a 5 antes de cerrarse. En una goroutine separada, se imprimen las salidas del canal único dest
. Al ejecutarlo, se imprimirán las 15 líneas correspondientes antes de que se cierre dest
y finalice la función.
Fan-Out
La distribución en abanico distribuye uniformemente los mensajes de un canal de entrada a varioscanales de salida.
Aplicabilidad
La salida en abanico recibe mensajes de un canal de entrada, distribuyéndolos uniformemente entre los canales de salida, y es un patrón útil para paralelizar la utilización de la CPU y la E/S.
Por ejemplo, imagina que tienes una fuente de entrada, como un Reader
en un flujo de entrada, o un oyente en un corredor de mensajes, que proporciona las entradas para alguna unidad de trabajo que consume muchos recursos. En lugar de acoplar los procesos de entrada y de cálculo, lo que limitaría el esfuerzo a un único proceso en serie, quizá prefieras paralelizar la carga de trabajo distribuyéndola entre un cierto número deprocesos de trabajo concurrentes.
Participantes
Este patrón incluye a los siguientes participantes:
- Fuente
-
Un canal de entrada. Aceptado por Split.
- Destinos
-
Un canal de salida del mismo tipo que Fuente. Creado y proporcionado por Dividir.
- Dividir
-
Una función que acepta el Origen y devuelve inmediatamente el Destino. Cualquier entrada de la Fuente se enviará a un Destino.
Aplicación
La salida en abanico puede ser relativamente sencilla desde el punto de vista conceptual, pero el diablo está en los detalles.
Habitualmente, el despliegue en abanico se implementa como una función Dividir, que acepta un único canal Origen y un número entero que representa el número deseado de canales Destino. La función Dividir crea los canales de Destino y ejecuta algúnproceso en segundo plano que recupera valores del canal Fuente y los reenvía a uno de losDestinos.
La implementación de la lógica de reenvío puede hacerse de dos maneras:
-
Utilizar una única goroutina que lea los valores de la Fuente y los reenvíe a los Destinos de forma rotatoria. Esto tiene la virtud de requerir sólo una goroutina maestra, pero si el siguiente canal aún no está listo para leer, ralentizará todo el proceso.
-
Utilizar goroutines independientes para cada Destino que compitan por leer el siguiente valor del Origen y reenviarlo a su respectivo Destino. Esto requiere algo más de recursos, pero es menos probable que se atasque por culpa de un único trabajador de ejecución lenta.
El siguiente ejemplo utiliza este último enfoque.
Código de muestra
En este ejemplo, la función Split
acepta un único canal de sólo recepción, source
, y un número entero que describe el número de canales en los que dividir la entrada, n
. Devuelve una porción de n
canales de sólo envío con el mismo tipo que source
.
Internamente, Split
crea los canales de destino. Para cada canal creado, ejecuta una goroutina que recupera valores de source
en un bucle for
y los reenvía a su canal de salida asignado. Efectivamente, cada goroutine compite por las lecturas de source
; si varias intentan leer, el "ganador" se determinará aleatoriamente. Si se cierra source
, todas las goroutines terminan y se cierran todos los canales de destino:
func
Split
(
source
<-
chan
int
,
n
int
)
[]
<-
chan
int
{
dests
:=
make
([]
<-
chan
int
,
0
)
// Create the dests slice
for
i
:=
0
;
i
<
n
;
i
++
{
// Create n destination channels
ch
:=
make
(
chan
int
)
dests
=
append
(
dests
,
ch
)
go
func
()
{
// Each channel gets a dedicated
defer
close
(
ch
)
// goroutine that competes for reads
for
val
:=
range
source
{
ch
<-
val
}
}()
}
return
dests
}
Dado un canal de algún tipo específico, la función Split
devolverá un número de canales de destino. Normalmente, cada uno se pasará a una goroutine distinta, como se muestra en el siguiente ejemplo:
func
main
()
{
source
:=
make
(
chan
int
)
// The input channel
dests
:=
Split
(
source
,
5
)
// Retrieve 5 output channels
go
func
()
{
// Send the number 1..10 to source
for
i
:=
1
;
i
<=
10
;
i
++
{
// and close it when we're done
source
<-
i
}
close
(
source
)
}()
var
wg
sync
.
WaitGroup
// Use WaitGroup to wait until
wg
.
Add
(
len
(
dests
))
// the output channels all close
for
i
,
ch
:=
range
dests
{
go
func
(
i
int
,
d
<-
chan
int
)
{
defer
wg
.
Done
()
for
val
:=
range
d
{
fmt
.
Printf
(
"#%d got %d\n"
,
i
,
val
)
}
}(
i
,
ch
)
}
wg
.
Wait
()
}
Este ejemplo crea un canal de entrada, source
, que pasa a Split
para recibir sus canales de salida. Al mismo tiempo, pasa los valores del 1 al 10 a source
en una goroutine, mientras recibe valores de dests
en otras cinco. Cuando se completan las entradas, se cierra el canal source
, lo que desencadena cierres en los canales de salida, lo que finaliza los bucles de lectura, lo que provoca que wg.Done
sea llamada por cada una de las goroutinas de lectura, lo que libera el bloqueo en wg.Wait
, y permite que la función finalice.
Futuro
Futuro proporciona un marcador de posición para un valor que aún no se conoce.
Aplicabilidad
Los Futuros (también conocidos como Promesas o Retrasos4) son una construcción de sincronización que proporciona un marcador de posición para un valor que todavía está siendo generado por unproceso asíncrono.
Este patrón no se utiliza con tanta frecuencia en Go como en otros lenguajes, porque los canales pueden usarse a menudo de forma similar. Por ejemplo, la función de bloqueo de larga duración BlockingInverse
(no mostrada) puede ejecutarse en una goroutine que devuelva el resultado (cuando llegue) a través de un canal. La función ConcurrentInverse
hace exactamente eso, devolviendo un canal que puede leerse cuando el resultado esté disponible:
func
ConcurrentInverse
(
m
Matrix
)
<-
chan
Matrix
{
out
:=
make
(
chan
Matrix
)
go
func
()
{
out
<-
BlockingInverse
(
m
)
close
(
out
)
}()
return
out
}
Utilizando ConcurrentInverse
, se podría construir una función para calcular el producto inverso de dos matrices:
func
InverseProduct
(
a
,
b
Matrix
)
Matrix
{
inva
:=
ConcurrentInverse
(
a
)
invb
:=
ConcurrentInverse
(
b
)
return
Product
(
<-
inva
,
<-
invb
)
}
Esto no parece tan malo, pero conlleva un cierto bagaje que lo hace indeseable para algo como una API pública. En primer lugar, la persona que llama tiene que tener cuidado de llamar aConcurrentInverse
en el momento adecuado. Para ver a qué me refiero, fíjate bien en lo siguiente:
return
Product
(
<-
ConcurrentInverse
(
a
),
<-
ConcurrentInverse
(
b
))
¿Ves el problema? Como el cálculo no se inicia hasta que se llama a ConcurrentInverse
, esta construcción se ejecutaría en serie, lo que requeriría el doble de tiempo de ejecución.
Además, al utilizar canales de este modo, las funciones con más de un valor de retorno suelen asignar un canal dedicado a cada miembro de la lista de retorno, lo que puede resultar incómodo cuando la lista de retorno crece o cuando los valores deben ser leídos por más de una goroutina.
El patrón Futuro contiene esta complejidad encapsulándola en una API que proporciona al consumidor una interfaz sencilla cuyo método puede llamarse normalmente, bloqueando todas las rutinas de llamada hasta que se resuelvan todos sus resultados. La interfaz que satisface el valor ni siquiera tiene que construirse especialmente para ese fin; puede utilizarse cualquier interfaz que sea conveniente para el consumidor.
Participantes
Este patrón incluye a los siguientes participantes:
- Futuro
-
La interfaz que recibe el consumidor para recuperar el resultado final.
- FunciónLenta
-
Una función que envuelve alguna función para ser ejecutada asíncronamente; proporciona Future.
- InnerFuture
-
Satisface la interfaz Futuro; incluye un método adjunto que contiene la lógica de acceso al resultado.
Aplicación
La API que se presenta al consumidor es bastante sencilla: el programador llama a FunciónLenta, que devuelve un valor que satisface la interfaz Futuro. Futuro puede ser una interfaz a medida, como en el ejemplo siguiente, o puede ser algo más parecido a un io.Reader
que se puede pasar a sus propias funciones.
En realidad, cuando se llama a SlowFunction, ejecuta la función central de interés como una goroutine. Al hacerlo, define canales para capturar la salida de la función central, que envuelve en InnerFuture.
InnerFuture tiene uno o varios métodos que satisfacen la interfaz Futuro, que recuperan los valores devueltos por la función principal de los canales, los almacenan en caché y los devuelven. Si los valores no están disponibles en el canal, la petición se bloquea. Si ya se han recuperado, se devuelven los valores almacenados en caché.
Código de muestra
En este ejemplo, utilizamos una interfaz Future
que satisfará el InnerFuture
:
type
Future
interface
{
Result
()
(
string
,
error
)
}
La estructura InnerFuture
se utiliza internamente para proporcionar la funcionalidad concurrente. En este ejemplo, satisface la interfaz Future
, pero podría optar fácilmente por satisfacer algo como io.Reader
adjuntando un método Read
, por ejemplo:
type
InnerFuture
struct
{
once
sync
.
Once
wg
sync
.
WaitGroup
res
string
err
error
resCh
<-
chan
string
errCh
<-
chan
error
}
func
(
f
*
InnerFuture
)
Result
()
(
string
,
error
)
{
f
.
once
.
Do
(
func
()
{
f
.
wg
.
Add
(
1
)
defer
f
.
wg
.
Done
()
f
.
res
=
<-
f
.
resCh
f
.
err
=
<-
f
.
errCh
})
f
.
wg
.
Wait
()
return
f
.
res
,
f
.
err
}
En esta implementación, la propia estructura contiene un canal y una variable para cada valor devuelto por el método Result
. Cuando se llama por primera vez a Result
, intenta leer los resultados de los canales y enviarlos de vuelta a la estructura InnerFuture
, de modo que las llamadas posteriores a Result
puedan devolver inmediatamente los valores almacenados en caché.
Fíjate en el uso de sync.Once
y sync.WaitGroup
. El primero hace lo que dice en la lata: se asegura de que la función que se le pasa se llama exactamente una vez. El WaitGroup
se utiliza para hacer que esta llamada a la función sea segura para los hilos: cualquier llamada después de la primera se bloqueará en wg.Wait
hasta que se completen las lecturas del canal.
SlowFunction
es una envoltura de la función central que quieres ejecutar simultáneamente. Se encarga de crear los canales de resultados, ejecutar la función principal en una goroutine, y crear y devolver la implementación de Future
(InnerFuture
, en este ejemplo):
func
SlowFunction
(
ctx
context
.
Context
)
Future
{
resCh
:=
make
(
chan
string
)
errCh
:=
make
(
chan
error
)
go
func
()
{
select
{
case
<-
time
.
After
(
time
.
Second
*
2
):
resCh
<-
"I slept for 2 seconds"
errCh
<-
nil
case
<-
ctx
.
Done
():
resCh
<-
""
errCh
<-
ctx
.
Err
()
}
}()
return
&
InnerFuture
{
resCh
:
resCh
,
errCh
:
errCh
}
}
Para utilizar este patrón, sólo tienes que llamar a SlowFunction
y utilizar el Future
devuelto como harías con cualquier otro valor:
func
main
()
{
ctx
:=
context
.
Background
()
future
:=
SlowFunction
(
ctx
)
res
,
err
:=
future
.
Result
()
if
err
!=
nil
{
fmt
.
Println
(
"error:"
,
err
)
return
}
fmt
.
Println
(
res
)
}
Este enfoque proporciona una experiencia de usuario razonablemente buena. El programador puede crear un Future
y acceder a él como desee, e incluso puede aplicar tiempos de espera o plazos con un Context
.
Fragmentación
La fragmentación divide una gran estructura de datos en varias particiones para localizar los efectos de los bloqueos de lectura/escritura.
Aplicabilidad
El término fragmentación se utiliza normalmente en el contexto del estado distribuido para describir los datos que se dividen entre instancias del servidor. Las bases de datos y otros almacenes de datossuelen utilizar este tipo de fragmentación horizontal para distribuir la carga y proporcionarredundancia.
Un problema ligeramente distinto puede afectar a veces a los servicios altamente concurrentes que tienen una estructura de datos compartida con un mecanismo de bloqueo para protegerla de escrituras conflictivas. En este escenario, los bloqueos que sirven para garantizar la fidelidad de los datos también pueden crear un cuello de botella cuando los procesos empiezan a pasar más tiempo esperando bloqueos que haciendo su trabajo. Este desafortunado fenómeno se denomina contención de bloqueos.
Aunque esto podría resolverse en algunos casos escalando el número de instancias, también aumenta la complejidad y la latencia, porque hay que establecer bloqueos distribuidos, y las escrituras tienen que establecer la coherencia. Una estrategia alternativa para reducir la contención de bloqueos en torno a estructuras de datos compartidas dentro de una instancia de un servicio es la fragmentación vertical, en la que una estructura de datos grande se divide en dos o más estructuras, cada una de las cuales representa una parte del todo. Con esta estrategia, sólo es necesario bloquear una parte de la estructura total a la vez, lo que reduce la contención general de bloqueos.
Participantes
Este patrón incluye a los siguientes participantes:
- ShardedMap
-
Una abstracción en torno a uno o varios Shards que proporciona acceso de lectura y escritura como si los Shards fueran un único mapa.
- Fragmento
-
Una colección bloqueable individualmente que representa una única partición de datos.
Aplicación
Aunque idiomáticamente Go prefiere encarecidamente el uso compartido de memoria mediante canales al uso de bloqueos para proteger los recursos compartidos,5 esto no siempre es posible. Los mapas son especialmente inseguros para el uso concurrente, por lo que el uso de bloqueos como mecanismo de sincronización es un mal necesario. Afortunadamente, Go proporciona sync.RWMutex
precisamente para este propósito.
RWMutex
proporciona métodos para establecer bloqueos tanto de lectura como de escritura, como se demuestra a continuación. Utilizando este método, cualquier número de procesos puede establecer bloqueos de lectura simultáneos siempre que no haya bloqueos de escritura abiertos; un proceso puede establecer un bloqueo de escritura sólo cuando no haya bloqueos de lectura o escritura existentes. Los intentos de establecer bloqueos adicionales se bloquearán hasta que se liberen todos los bloqueos anteriores:
var
items
=
struct
{
// Struct with a map and a
sync
.
RWMutex
// composed sync.RWMutex
m
map
[
string
]
int
}{
m
:
make
(
map
[
string
]
int
)}
func
ThreadSafeRead
(
key
string
)
int
{
items
.
RLock
()
// Establish read lock
value
:=
items
.
m
[
key
]
items
.
RUnlock
()
// Release read lock
return
value
}
func
ThreadSafeWrite
(
key
string
,
value
int
)
{
items
.
Lock
()
// Establish write lock
items
.
m
[
key
]
=
value
items
.
Unlock
()
// Release write lock
}
Esta estrategia suele funcionar perfectamente. Sin embargo, como los bloqueos sólo permiten el acceso a un proceso a la vez, la cantidad media de tiempo que se pasa esperando a que se despejen los bloqueos en una aplicación intensiva de lectura/escritura puede aumentar drásticamente con el número de procesos concurrentes que actúan sobre el recurso. La contención de bloqueos resultante puede suponer un cuello de botella para la funcionalidad clave.
La fragmentación vertical reduce la contención de bloqueos dividiendo la estructura de datos subyacente -normalmente un mapa- en varios mapas bloqueables individualmente. Una capa de abstracción proporciona acceso a los fragmentos subyacentes como si fueran una única estructura (ver Figura 4-5).
Internamente, esto se consigue creando una capa de abstracción alrededor de lo que es esencialmente un mapa de mapas. Cada vez que se lee o escribe un valor en la abstracción del mapa, se calcula un valor hash para la clave, que luego se modifica por el número de fragmentos para generar un índice de fragmentos. Esto permite a la abstracción del mapa aislar el bloqueo necesario sólo al fragmento de ese índice.
Código de muestra
En el siguiente ejemplo, utilizamos los paquetes estándar sync
y crypto/sha1
para implementar un mapa fragmentado básico: ShardedMap
.
Internamente, ShardedMap
es sólo un fragmento de punteros a un cierto número de valores Shard
, pero lo definimos como un tipo para poder adjuntarle métodos. Cada Shard
incluyeun map[string]interface{}
que contiene los datos de ese fragmento, y un compuestosync.RWMutex
para que pueda bloquearse individualmente:
type
Shard
struct
{
sync
.
RWMutex
// Compose from sync.RWMutex
m
map
[
string
]
interface
{}
// m contains the shard's data
}
type
ShardedMap
[]
*
Shard
// ShardedMap is a *Shards slice
Go no tiene ningún concepto de constructores, así que proporcionamos una función NewShardedMap
para recuperar un nuevo ShardedMap
:
func
NewShardedMap
(
nshards
int
)
ShardedMap
{
shards
:=
make
([]
*
Shard
,
nshards
)
// Initialize a *Shards slice
for
i
:=
0
;
i
<
nshards
;
i
++
{
shard
:=
make
(
map
[
string
]
interface
{})
shards
[
i
]
=
&
Shard
{
m
:
shard
}
}
return
shards
// A ShardedMap IS a *Shards slice!
}
ShardedMap
tiene dos métodos sin exportar, getShardIndex
y getShard
, que se utilizan para calcular el índice de fragmentos de una clave y recuperar el fragmento correcto de una clave, respectivamente. Podrían combinarse fácilmente en un único método, pero dividirlos de esta forma facilita su comprobación:
func
(
m
ShardedMap
)
getShardIndex
(
key
string
)
int
{
checksum
:=
sha1
.
Sum
([]
byte
(
key
))
// Use Sum from "crypto/sha1"
hash
:=
int
(
checksum
[
17
])
// Pick an arbitrary byte as the hash
return
hash
%
len
(
m
)
// Mod by len(m) to get index
}
func
(
m
ShardedMap
)
getShard
(
key
string
)
*
Shard
{
index
:=
m
.
getShardIndex
(
key
)
return
m
[
index
]
}
Ten en cuenta que el ejemplo anterior tiene un punto débil obvio: como utiliza un valor del tamaño de byte
como valor hash, sólo puede manejar hasta 255 fragmentos. Si por alguna razón quieres más, puedes añadirle algo de aritmética binaria: hash := int(sum[13]) << 8 | int(sum[17])
.
Por último, añadimos métodos a ShardedMap
para que un usuario pueda leer y escribir valores. Obviamente, esto no demuestra toda la funcionalidad que puede necesitar un mapa. Sin embargo, el código fuente de este ejemplo está en el repositorio de GitHub asociado a este libro, así que siéntete libre de implementarlos como ejercicio. Estaría bien un método Delete
y otro Contains
:
func
(
m
ShardedMap
)
Get
(
key
string
)
interface
{}
{
shard
:=
m
.
getShard
(
key
)
shard
.
RLock
()
defer
shard
.
RUnlock
()
return
shard
.
m
[
key
]
}
func
(
m
ShardedMap
)
Set
(
key
string
,
value
interface
{})
{
shard
:=
m
.
getShard
(
key
)
shard
.
Lock
()
defer
shard
.
Unlock
()
shard
.
m
[
key
]
=
value
}
Cuando necesites establecer bloqueos en todas las tablas, generalmente es mejor hacerlo de forma concurrente. A continuación, implementamos una función Keys
utilizando goroutines y nuestro viejo amigo sync.WaitGroup
:
func
(
m
ShardedMap
)
Keys
()
[]
string
{
keys
:=
make
([]
string
,
0
)
// Create an empty keys slice
mutex
:=
sync
.
Mutex
{}
// Mutex for write safety to keys
wg
:=
sync
.
WaitGroup
{}
// Create a wait group and add a
wg
.
Add
(
len
(
m
))
// wait value for each slice
for
_
,
shard
:=
range
m
{
// Run a goroutine for each slice
go
func
(
s
*
Shard
)
{
s
.
RLock
()
// Establish a read lock on s
for
key
:=
range
s
.
m
{
// Get the slice's keys
mutex
.
Lock
()
keys
=
append
(
keys
,
key
)
mutex
.
Unlock
()
}
s
.
RUnlock
()
// Release the read lock
wg
.
Done
()
// Tell the WaitGroup it's done
}(
shard
)
}
wg
.
Wait
()
// Block until all reads are done
return
keys
// Return combined keys slice
}
Desgraciadamente, utilizar ShardedMap
no es como utilizar un mapa estándar, pero aunque es diferente, no es más complicado:
func
main
()
{
shardedMap
:=
NewShardedMap
(
5
)
shardedMap
.
Set
(
"alpha"
,
1
)
shardedMap
.
Set
(
"beta"
,
2
)
shardedMap
.
Set
(
"gamma"
,
3
)
fmt
.
Println
(
shardedMap
.
Get
(
"alpha"
))
fmt
.
Println
(
shardedMap
.
Get
(
"beta"
))
fmt
.
Println
(
shardedMap
.
Get
(
"gamma"
))
keys
:=
shardedMap
.
Keys
()
for
_
,
k
:=
range
keys
{
fmt
.
Println
(
k
)
}
}
Quizá el mayor inconveniente de ShardedMap
(además de su complejidad, claro) sea la pérdida de seguridad de tipos asociada al uso de interface{}
, y el consiguiente requisito de aserciones de tipos. Esperemos que, con el inminente lanzamiento de los genéricos para Go, esto sea pronto (o quizá ya lo sea, dependiendo de cuándo leas esto) ¡un problema del pasado!
Resumen
En este capítulo se han tratado unos cuantos vocablos muy interesantes y útiles. Probablemente haya muchos más,6 pero estos son los que me parecieron más importantes, bien porque son prácticos de alguna manera directamente aplicable, bien porque muestran alguna característica interesante del lenguaje Go. A menudo ambas cosas.
En el Capítulo 5 pasaremos al siguiente nivel, tomando algunas de las cosas que hemos tratado en los Capítulos 3 y 4, y poniéndolas en práctica ¡construyendo un sencillo almacén de valores clave desde cero!
1 Pronunciada en agosto de 1979. Atestiguado por Vicki Almstrum, Tony Hoare, Niklaus Wirth, Wim Feijen y Rajeev Joshi. En busca de la simplicidad: Simposio en honor del profesor Edsger Wybe Dijkstra, 12-13 de mayo de 2000.
2 L (sí, su nombre legal es L) es un ser humano brillante y fascinante. Búscale alguna vez.
3 Erich Gamma et al. Patrones de diseño: Elements of Reusable Object-Oriented Software, 1ª edición. Addison-Wesley Professional, 1994).
4 Aunque estos términos suelen utilizarse indistintamente, también pueden tener matices según el contexto. Ya lo sé. Por favor, no me escribas enfadada por esto.
5 Consulta el artículo "Compartir la memoria comunicando" en El Blog de Go.
6 ¿Me he dejado tu favorito? ¡Házmelo saber e intentaré incluirlo en la próxima edición!
Get Nube Nativa Go now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.