Capítulo 1. Introducción a la concurrencia
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
La concurrencia es una palabra interesante porque significa cosas distintas para distintas personas de nuestro campo. Además de "concurrencia", es posible que hayas oído las palabras "asíncrona", "paralela" o "enhebrada". Algunas personas consideran que estas palabras significan lo mismo, y otras delimitan muy específicamente cada una de esas palabras. Si vamos a dedicar todo el tiempo de un libro a discutir la concurrencia, sería beneficioso dedicar primero algún tiempo a discutir qué queremos decir cuando decimos "concurrencia".
Dedicaremos algún tiempo a la filosofía de la concurrencia en el Capítulo 2, pero de momento adoptemos una definición práctica que nos sirva de base para comprenderla.
Cuando la mayoría de la gente utiliza la palabra "concurrente", suele referirse a un proceso que ocurre simultáneamente con uno o más procesos. También suele implicar que todos estos procesos avanzan más o menos al mismo tiempo. Según esta definición, una forma fácil de pensar en esto son las personas. En este momento estás leyendo esta frase mientras otras personas en el mundo están viviendo simultáneamente sus vidas. Están existiendo concurrentemente a ti.
La concurrencia es un tema amplio en informática, y de esta definición surgen todo tipo de temas: teoría, enfoques para modelar la concurrencia, corrección de la lógica, cuestiones prácticas, ¡incluso física teórica! A lo largo del libro tocaremos algunos de los temas secundarios, pero nos ceñiremos sobre todo a las cuestiones prácticas que implican entender la concurrencia en el contexto de Go, en concreto: cómo Go elige modelar la concurrencia, qué problemas surgen de este modelo y cómo podemos componer primitivas dentro de este modelo para resolver problemas.
En este capítulo, echaremos un amplio vistazo a algunas de las razones por las que la concurrencia se convirtió en un tema tan importante en la informática, por qué la concurrencia es difícil y justifica un estudio cuidadoso y, lo más importante, la idea de que, a pesar de estos retos, Go puede hacer que los programas sean más claros y rápidos utilizando sus primitivas de concurrencia.
Como en la mayoría de los caminos hacia la comprensión, empezaremos con un poco de historia. Veamos primero cómo la concurrencia se convirtió en un tema tan importante.
La Ley de Moore, la escala web y el lío en el que estamos metidos
En 1965, Gordon Moore escribió un documento de tres páginas que describía tanto la consolidación del mercado de la electrónica hacia los circuitos integrados, como la duplicación del número de componentes de un circuito integrado cada año durante al menos una década. En 1975, revisó esta predicción para afirmar que el número de componentes de un circuito integrado se duplicaría cada dos años. Esta predicción se ha mantenido más o menos hasta hace poco, alrededor de 2012.
Varias empresas previeron esta ralentización del ritmo que predecía la ley de Moore y empezaron a investigar formas alternativas de aumentar la potencia de cálculo. Como dice el refrán, la necesidad es la madre de la innovación, y así fue como nacieron los procesadores multinúcleo.
Parecía una forma inteligente de resolver los problemas de límites de la ley de Moore, pero los informáticos pronto se encontraron con los límites de otra ley: La ley de Amdahl, llamada así por el arquitecto informático Gene Amdahl.
La ley de Amdahl describe una forma de modelar las posibles ganancias de rendimiento al implementar la solución a un problema de forma paralela. En pocas palabras, afirma que las ganancias están limitadas por la parte del programa que debe escribirse de forma secuencial.
Por ejemplo, imagina que escribieras un programa basado en gran medida en la interfaz gráfica de usuario: se presenta al usuario una interfaz, pulsa algunos botones y ocurren cosas. Este tipo de programa está limitado por una parte secuencial muy grande del pipeline: la interacción humana. No importa cuántos núcleos pongas a disposición de este programa, siempre estará limitado por la rapidez con la que el usuario pueda interactuar con la interfaz.
Consideremos ahora un ejemplo diferente, el cálculo de los dígitos de pi. Gracias a una clase de algoritmos llamados algoritmos de espiga, este problema se denomina vergonzosamente paralelo, que -a pesar de sonar inventado- es un término técnico que significa que puede dividirse fácilmente en tareas paralelas. En este caso, se pueden obtener ganancias significativas poniendo más núcleos a disposición de tu programa, y tu nuevo problema pasa a ser cómo combinar y almacenar los resultados.
La ley de Amdahl nos ayuda a comprender la diferencia entre estos dos problemas, y puede ayudarnos a decidir si la paralelización es la forma correcta de abordar los problemas de rendimiento de nuestro sistema.
Para los problemas que son vergonzosamente paralelos, se recomienda que escribas tu aplicación de modo que pueda escalar horizontalmente. Esto significa que puedes tomar instancias de tu programa, ejecutarlo en más CPUs, o máquinas, y esto hará que mejore el tiempo de ejecución del sistema. Los problemas embarazosamente paralelos encajan muy bien en este modelo porque es muy fácil estructurar tu programa de tal manera que puedas enviar trozos de un problema a diferentes instancias de tu aplicación.
Escalar horizontalmente se hizo mucho más fácil a principios de la década de 2000, cuando empezó a arraigar un nuevo paradigma: la computación en nube. Aunque hay indicios de que la frase ya se utilizaba en la década de 1970, fue a principios de la década de 2000 cuando la idea arraigó realmente en el zeitgeist. La computación en nube implicaba un nuevo tipo de escala y enfoque de las Implementaciones de aplicaciones y el escalado horizontal. En lugar de máquinas cuidadosamente seleccionadas, en las que se instalaba software y se mantenían, la computación en nube implicaba el acceso a vastos conjuntos de recursos que se aprovisionaban en máquinas para cargas de trabajo bajo demanda. Las máquinas se convirtieron en algo casi efímero, y se aprovisionaron con características específicamente adaptadas a los programas que ejecutarían. Normalmente (pero no siempre) estos conjuntos de recursos se alojaban en centros de datos propiedad de otras empresas.
Este cambio fomentó un nuevo tipo de pensamiento. De repente, los desarrolladores tenían un acceso relativamente barato a grandes cantidades de potencia informática que podían utilizar para resolver grandes problemas. Ahora las soluciones podían abarcar trivialmente muchas máquinas e incluso regiones globales. La computación en nube hizo posible todo un nuevo conjunto de soluciones a problemas que antes sólo podían resolver los gigantes tecnológicos.
Pero la computación en nube también presentaba muchos retos nuevos. El aprovisionamiento de estos recursos, la comunicación entre las instancias de la máquina y la agregación y almacenamiento de los resultados se convirtieron en problemas a resolver. Pero uno de los más difíciles fue averiguar cómo modelar el código de forma concurrente. El hecho de que partes de tu solución pudieran ejecutarse en máquinas dispares exacerbó algunos de los problemas que suelen surgir al modelar un problema de forma concurrente. Resolver con éxito estos problemas pronto condujo a un nuevo tipo de marca para el software, la escala web.
Si el software fuera a escala web, entre otras cosas, cabría esperar que fuera vergonzosamente paralelo; es decir, normalmente se espera que el software a escala web sea capaz de gestionar cientos de miles (o más) de cargas de trabajo simultáneas añadiendo más instancias de la aplicación. Esto permitió todo tipo de propiedades, como las actualizaciones continuas, la arquitectura elástica escalable horizontalmente y la distribución geográfica. También introdujo nuevos niveles de complejidad tanto en la comprensión como en la tolerancia a fallos.
Y es así, en este mundo de múltiples núcleos, computación en la nube, escala web y problemas que pueden o no ser paralelizables, donde encontramos al desarrollador moderno, quizá un poco abrumado. Se nos ha pasado la proverbial pelota, y se espera que estemos a la altura del reto de resolver problemas dentro de los confines del hardware que se nos ha entregado. En 2005, Herb Sutter escribió un artículo para Dr. Dobb's titulado "Se acabó la comida gratis: Un giro fundamental hacia la concurrencia en el software". El título es acertado, y el artículo clarividente. Hacia el final, Sutter afirma: "Necesitamos desesperadamente un modelo de programación de mayor nivel para la concurrencia que el que ofrecen hoy los lenguajes".
Para saber por qué Sutter utilizó un lenguaje tan fuerte, tenemos que ver por qué es tan difícil acertar con la concurrencia.
¿Por qué es difícil la concurrencia?
El código concurrente es notoriamente difícil de hacer bien. Normalmente se necesitan varias iteraciones para que funcione como se espera, e incluso entonces no es raro que existan errores en el código durante años antes de que algún cambio en la sincronización (mayor utilización del disco, más usuarios conectados al sistema, etc.) haga que aparezca un error no descubierto previamente. De hecho, para este mismo libro, he puesto tantos ojos como me ha sido posible en el código para intentar mitigarlo.
Afortunadamente, todo el mundo se encuentra con los mismos problemas cuando trabaja con código concurrente. Por ello, los informáticos han podido etiquetar los problemas comunes, lo que nos permite debatir cómo surgen, por qué y cómo resolverlos.
Así que empecemos. A continuación se exponen algunos de los problemas más comunes que hacen que trabajar con código concurrente sea tan frustrante como interesante.
Condiciones de la carrera
Una condición de carrera se produce cuando dos o más operaciones deben ejecutarse en el orden correcto, pero el programa no se ha escrito de forma que se garantice el mantenimiento de este orden.
La mayoría de las veces, esto aparece en lo que se denomina una carrera de datos, en la que una operación concurrente intenta leer una variable mientras, en un momento indeterminado, otra operación concurrente intenta escribir en la misma variable.
He aquí un ejemplo básico:
1var
data
int
2
go
func
(
)
{
3
data
++
4
}
(
)
5
if
data
==
0
{
6
fmt
.
Printf
(
"the value is %v.\n"
,
data
)
7
}
En Go, puedes utilizar la palabra clave
go
para ejecutar una función simultáneamente. Al hacerlo, se crea lo que se denomina una gorutina. Hablaremos de ello en detalle en la sección "Goroutines".
Aquí, las líneas 3 y 5 intentan ambas acceder a los datos de la variable, pero no hay ninguna garantía de en qué orden puede ocurrir esto. Hay tres posibles resultados al ejecutar este código:
-
No se imprime nada. En este caso, la línea 3 se ejecutó antes que la línea 5.
-
se imprime "el valor es 0". En este caso, las líneas 5 y 6 se ejecutaron antes que la línea 3.
-
se imprime "el valor es 1". En este caso, la línea 5 se ejecutó antes que la línea 3, pero la línea 3 se ejecutó antes que la línea 6.
Como puedes ver, sólo unas pocas líneas de código incorrecto pueden introducir una tremenda variabilidad en tu programa.
La mayoría de las veces, las carreras de datos se introducen porque los desarrolladores piensan en el problema de forma secuencial. Suponen que, como una línea de código va antes que otra, se ejecutará primero. Suponen que la goroutine anterior se programará y ejecutará antes de que se lea la variable data
en la sentencia if
.
Al escribir código concurrente, tienes que iterar meticulosamente a través de los posibles escenarios. A menos que utilices algunas de las técnicas que veremos más adelante en el libro, no tienes garantías de que tu código se ejecute en el orden en que aparece en el código fuente. A veces me resulta útil imaginar que pasa un largo período de tiempo entre una operación y otra. Imagina que pasa una hora entre el momento en que se invoca la goroutine y el momento en que se ejecuta. ¿Cómo se comportaría el resto del programa? ¿Qué pasaría si transcurriera una hora entre que la gorutina se ejecuta correctamente y el programa llega a la sentencia if
? Pensar de esta manera me ayuda porque para un ordenador, la escala puede ser diferente, pero las diferencias de tiempo relativas son más o menos las mismas.
De hecho, algunos desarrolladores caen en la trampa de espolvorear duermevelas por todo su código precisamente porque parece resolver sus problemas de concurrencia. Intentémoslo en el programa anterior:
1var
data
int
2go
func
()
{
data
++
}()
3time
.
Sleep
(
1
*
time
.
Second
)
// This is bad!
4if
data
==
0
{
5fmt
.
Printf
(
"the value is %v.\n"
data
)
6}
¿Hemos resuelto nuestra carrera de datos? No. De hecho, sigue siendo posible que surjan los tres resultados de este programa, sólo que cada vez es menos probable. Cuanto más tiempo pasemos entre la invocación de nuestra gorutina y la comprobación del valor de los datos, más cerca estará nuestro programa de alcanzar la corrección, pero esta probabilidad se aproxima asintóticamente a la corrección lógica; nunca será lógicamente correcto.
Además, ahora hemos introducido una ineficiencia en nuestro algoritmo. Ahora tenemos que esperar un segundo para que sea más probable que no veamos nuestra carrera de datos. Si utilizáramos las herramientas adecuadas, podríamos no tener que esperar en absoluto, o la espera podría ser sólo de un microsegundo.
La conclusión es que siempre debes centrarte en la corrección lógica. Introducir sleeps en tu código puede ser una forma práctica de depurar programas concurrentes, pero no son una solución.
Las condiciones de carrera son uno de los tipos más insidiosos de errores de concurrencia, porque pueden no aparecer hasta años después de que el código se haya puesto en producción. Suelen precipitarse por un cambio en el entorno en el que se ejecuta el código, o por un suceso sin precedentes. En estos casos, parece que el código se comporta correctamente, pero en realidad, sólo hay una probabilidad muy alta de que las operaciones se ejecuten en orden. Tarde o temprano, el programa tendrá una consecuencia no deseada.
Atomicidad
Cuando se considera que algo es atómico, o que tiene la propiedad de la atomicidad, significa que, dentro del contexto en el que funciona, es indivisible o ininterrumpible.
¿Qué significa eso realmente y por qué es importante saberlo cuando se trabaja con código concurrente?
Lo primero que es muy importante es la palabra "contexto". Algo puede ser atómico en un contexto, pero no en otro. Las operaciones que son atómicas en el contexto de tu proceso pueden no serlo en el contexto del sistema operativo; las operaciones que son atómicas en el contexto del sistema operativo pueden no serlo en el contexto de tu máquina; y las operaciones que son atómicas en el contexto de tu máquina pueden no serlo en el contexto de tu aplicación. En otras palabras, la atomicidad de una operación puede cambiar en función del ámbito definido en ese momento. ¡Este hecho puede jugar tanto a tu favor como en tu contra!
Cuando se piensa en la atomicidad, a menudo lo primero que hay que hacer es definir el contexto, o ámbito, en el que la operación se considerará atómica. Todo se deduce de esto.
Ahora veamos los términos "indivisible" e "ininterrumpible". Estos términos significan que, dentro del contexto que has definido, algo que es atómico ocurrirá en su totalidad sin que ocurra nada en ese contexto simultáneamente. Sigue siendo un trabalenguas, así que veamos un ejemplo:
i
++
Es el ejemplo más sencillo que se puede inventar y, sin embargo, demuestra fácilmente el concepto de atomicidad. Puede parecer atómico, pero un breve análisis revela varias operaciones:
-
Recupera el valor de
i
. -
Aumenta el valor de
i
. -
Guarda el valor de
i
.
Aunque cada una de estas operaciones por sí sola es atómica, la combinación de las tres puede no serlo, dependiendo de su contexto. Esto revela una propiedad interesante de las operaciones atómicas: combinarlas no produce necesariamente una operación atómica mayor. Hacer que la operación sea atómica depende del contexto en el que quieras que sea atómica. Si tu contexto es un programa sin procesos concurrentes, entonces este código es atómico dentro de ese contexto. Si tu contexto es una goroutina que no expone i
a otras goroutinas, entonces este código es atómico.
Entonces, ¿por qué nos importa? La atomicidad es importante porque si algo es atómico, implícitamente es seguro dentro de contextos concurrentes. Esto nos permite componer programas lógicamente correctos y -como veremos más adelante- puede servir incluso para optimizar los programas concurrentes.
La mayoría de las afirmaciones no son atómicas, por no hablar de las funciones, métodos y programas. Si la atomicidad es la clave para componer programas lógicamente correctos, y la mayoría de las sentencias no son atómicas, ¿cómo conciliamos estas dos afirmaciones? Profundizaremos más adelante, pero en resumen podemos forzar la atomicidad empleando diversas técnicas. El arte consiste entonces en determinar qué áreas de tu código necesitan ser atómicas, y a qué nivel de granularidad. Discutiremos algunos de estos retos en la siguiente sección.
Sincronización del acceso a la memoria
Supongamos que tenemos una carrera de datos: dos procesos concurrentes intentan acceder a la misma zona de memoria, y la forma en que acceden a la memoria no es atómica. Nuestro ejemplo anterior de una simple carrera de datos funcionará bien con algunas modificaciones:
var
data
int
go
func
()
{
data
++
}()
if
data
==
0
{
fmt
.
Println
(
"the value is 0."
)
}
else
{
fmt
.
Printf
(
"the value is %v.\n"
,
data
)
}
Hemos añadido aquí una cláusula else
para que, independientemente del valor de data
, siempre obtengamos alguna salida. Recuerda que tal y como está escrito, hay una carrera de datos y la salida del programa será completamente no determinista.
De hecho, hay un nombre para una sección de tu programa que necesita acceso exclusivo a un recurso compartido. Se llama sección crítica. En este ejemplo, tenemos tres secciones críticas:
-
Nuestra goroutine, que está incrementando las variables
data
. -
Nuestra sentencia
if
, que comprueba si el valor dedata
es 0. -
Nuestra sentencia
fmt.Printf
, que recupera el valor dedata
para la salida.
Hay varias formas de proteger las secciones críticas de tu programa, y Go tiene algunas ideas mejores sobre cómo tratar esto, pero una forma de resolver este problema es sincronizar el acceso a la memoria entre tus secciones críticas. Veamos qué aspecto tiene.
El código siguiente no es Go idiomático (y no te sugiero que intentes resolver así tus problemas de carreras de datos), pero demuestra de forma muy sencilla la sincronización del acceso a la memoria. Si alguno de los tipos, funciones o métodos de este ejemplo te resulta extraño, no pasa nada. Céntrate en el concepto de sincronizar el acceso a la memoria siguiendo las llamadas.
var
memoryAccess
sync
.
Mutex
var
value
int
go
func
(
)
{
memoryAccess
.
Lock
(
)
value
++
memoryAccess
.
Unlock
(
)
}
(
)
memoryAccess
.
Lock
(
)
if
value
==
0
{
fmt
.
Printf
(
"the value is %v.\n"
,
value
)
}
else
{
fmt
.
Printf
(
"the value is %v.\n"
,
value
)
}
memoryAccess
.
Unlock
(
)
Aquí añadimos una variable que permitirá a nuestro código sincronizar el acceso a la memoria de la variable
data
. Repasaremos el tiposync.Mutex
en detalle en "El paquete sync".Aquí declaramos que, hasta que declaremos lo contrario, nuestra goroutine debe tener acceso exclusivo a esta memoria.
Aquí declaramos que la goroutina ha terminado con esta memoria.
Aquí volvemos a declarar que las siguientes sentencias condicionales deben tener acceso exclusivo a la memoria de la variable
data
.Aquí declaramos que hemos terminado una vez más con este recuerdo.
En este ejemplo hemos creado una convención para que la sigan los desarrolladores. Cada vez que los desarrolladores quieran acceder a la memoria de la variable data
, primero deben llamar a Lock
, y cuando hayan terminado deben llamar a Unlock
. El código entre esas dos sentencias puede entonces asumir que tiene acceso exclusivo a data
; hemos sincronizado con éxito el acceso a la memoria. Ten en cuenta también que si los desarrolladores no siguen esta convención, ¡no tenemos ninguna garantía de acceso exclusivo! Volveremos sobre esta idea en la sección "Confinamiento".
Te habrás dado cuenta de que, aunque hemos resuelto nuestra carrera de datos, ¡en realidad no hemos resuelto nuestra condición de carrera! El orden de las operaciones en este programa sigue siendo no determinista; sólo hemos reducido un poco el alcance del no determinismo. En este ejemplo, o bien se ejecuta primero la gorutina, o bien lo hacen nuestros bloques if
y else
. Seguimos sin saber cuál se producirá primero en una ejecución determinada de este programa. Más adelante, exploraremos las herramientas para resolver adecuadamente este tipo de cuestiones.
A primera vista parece bastante sencillo: si descubres que tienes secciones críticas, ¡añade puntos para sincronizar el acceso a la memoria! Fácil, ¿verdad? Bueno... más o menos.
Es cierto que puedes resolver algunos problemas sincronizando el acceso a la memoria, pero como acabamos de ver, no resuelve automáticamente las carreras de datos ni la corrección lógica. Además, también puede crear problemas de mantenimiento y rendimiento.
Fíjate en que antes mencionamos que habíamos creado una convención para declarar que necesitábamos acceso exclusivo a cierta memoria. Las convenciones son estupendas, pero también es fácil ignorarlas, sobre todo en ingeniería de software, donde las exigencias del negocio a veces pesan más que la prudencia. Al sincronizar el acceso a la memoria de esta manera, estás contando con que todos los demás desarrolladores sigan la misma convención ahora y en el futuro. Eso es mucho pedir. Por suerte, más adelante en este libro también veremos algunas formas de ayudar a nuestros colegas a tener más éxito.
Sincronizar el acceso a la memoria de esta forma también tiene ramificaciones de rendimiento. Dejaremos los detalles para más adelante, cuando examinemos el paquete sync
en la sección "El paquete sync", pero las llamadas a Lock
que ves pueden hacer que nuestro programa sea lento. Cada vez que realizamos una de estas operaciones, nuestro programa se detiene durante un tiempo. Esto plantea dos cuestiones:
-
¿Mis secciones críticas entran y salen repetidamente?
-
¿Qué tamaño deben tener mis secciones críticas?
Responder a estas dos preguntas en el contexto de tu programa es un arte, y esto se añade a la dificultad de sincronizar el acceso a la memoria.
Sincronizar el acceso a la memoria también comparte algunos problemas con otras técnicas de modelado de problemas concurrentes, y hablaremos de ellos en la siguiente sección.
Bloqueos muertos, bloqueos vivos e inanición
En las secciones anteriores se ha hablado de la corrección del programa, en el sentido de que si estas cuestiones se gestionan correctamente, tu programa nunca dará una respuesta incorrecta. Por desgracia, incluso si manejas con éxito estas clases de problemas, hay otra clase de problemas a los que enfrentarse: los bloqueos muertos, los bloqueos vivos y la inanición. Todas estas cuestiones tienen que ver con garantizar que tu programa tenga algo útil que hacer en todo momento. Si no se manejan adecuadamente, tu programa podría entrar en un estado en el que dejaría de funcionar por completo.
Bloqueo
Un programa bloqueado es aquel en el que todos los procesos concurrentes están esperando unos a otros. En este estado, el programa nunca se recuperará sin intervención externa.
Si eso suena lúgubre, ¡es porque lo es! El tiempo de ejecución de Go intenta hacer su parte y detectará algunos deadlocks (todas las goroutines deben estar bloqueadas, o "dormidas"1), pero esto no ayuda mucho a evitar los bloqueos.
Para ayudar a solidificar lo que es un bloqueo, veamos primero un ejemplo. De nuevo, puedes ignorar los tipos, funciones, métodos o paquetes que no conozcas y limitarte a seguir las llamadas del código.
type
value
struct
{
mu
sync
.
Mutex
value
int
}
var
wg
sync
.
WaitGroup
printSum
:=
func
(
v1
,
v2
*
value
)
{
defer
wg
.
Done
(
)
v1
.
mu
.
Lock
(
)
defer
v1
.
mu
.
Unlock
(
)
time
.
Sleep
(
2
*
time
.
Second
)
v2
.
mu
.
Lock
(
)
defer
v2
.
mu
.
Unlock
(
)
fmt
.
Printf
(
"sum=%v\n"
,
v1
.
value
+
v2
.
value
)
}
var
a
,
b
value
wg
.
Add
(
2
)
go
printSum
(
&
a
,
&
b
)
go
printSum
(
&
b
,
&
a
)
wg
.
Wait
(
)
Aquí intentamos entrar en la sección crítica para el valor entrante.
Aquí utilizamos la sentencia
defer
para salir de la sección crítica antes de que vuelvaprintSum
.Aquí dormimos durante un periodo de tiempo para simular el trabajo (y provocar un punto muerto).
Si intentaras ejecutar este código, probablemente verías
fatal error: all goroutines are asleep - deadlock!
¿Por qué? Si te fijas bien, verás un problema de sincronización en este código. A continuación se muestra una representación gráfica de lo que ocurre. Los recuadros representan funciones, las líneas horizontales llamadas a esas funciones, y las barras verticales tiempos de vida de la función que encabeza el gráfico(Figura 1-1).
Esencialmente, hemos creado dos engranajes que no pueden girar juntos: nuestra primera llamada a printSum
bloquea a
y luego intenta bloquear b
, pero mientras tanto nuestra segunda llamada a printSum
ha bloqueado b
y ha intentado bloquear a
. Ambas goroutines esperan infinitamente la una a la otra.
Parece bastante obvio por qué se produce este bloqueo cuando lo exponemos así gráficamente, pero nos beneficiaríamos de una definición más rigurosa. Resulta que hay algunas condiciones que deben darse para que se produzcan bloqueos, y en 1971, Edgar Coffman enumeró estas condiciones en un artículo. Las condiciones se conocen ahora como Condiciones de Coffman y son la base de las técnicas que ayudan a detectar, prevenir y corregir los bloqueos.
Las Condiciones Coffman son las siguientes:
- Exclusión mutua
-
Un proceso concurrente tiene derechos exclusivos sobre un recurso en un momento dado.
- Condición de espera
-
Un proceso concurrente debe mantener simultáneamente un recurso y estar esperando un recurso adicional.
- Sin preferencia
-
Un recurso retenido por un proceso concurrente sólo puede ser liberado por ese proceso, por lo que cumple esta condición.
- Espera circular
-
Un proceso concurrente (P1) debe estar esperando en una cadena de otros procesos concurrentes (P2), que a su vez están esperando en él (P1), por lo que también cumple esta condición final.
Examinemos nuestro programa inventado y determinemos si cumple las cuatro condiciones:
-
La función
printSum
sí requiere derechos exclusivos tanto paraa
como parab
, por lo que cumple esta condición. -
Dado que
printSum
mantienea
ob
y está esperando al otro, cumple esta condición. -
No hemos dado ninguna forma de que nuestras goroutines se adelanten.
-
Nuestra primera invocación a
printSum
está esperando a nuestra segunda invocación, y viceversa.
Sí, definitivamente tenemos un punto muerto entre manos.
Estas leyes también nos permiten evitar los bloqueos. Si nos aseguramos de que al menos una de estas condiciones no es cierta, podemos evitar que se produzcan bloqueos. Por desgracia, en la práctica estas condiciones pueden ser difíciles de razonar y, por tanto, de evitar. La Web está plagada de preguntas de desarrolladores como tú y como yo que se preguntan por qué un fragmento de código se bloquea. Normalmente es bastante obvio una vez que alguien lo señala, pero a menudo requiere otro par de ojos. Hablaremos de ello en la sección "Determinar la seguridad de la concurrencia".
Livelock
Los Livelocks son programas que están realizando activamente operaciones concurrentes, pero estas operaciones no hacen avanzar el estado del programa.
¿Has estado alguna vez en un pasillo caminando hacia otra persona? Ella se mueve a un lado para dejarte pasar, pero tú acabas de hacer lo mismo. Entonces te mueves al otro lado, pero ella también ha hecho lo mismo. Imagínate que esto dura eternamente, y entenderás lo que son los cerrojos.
Escribamos algo de código que nos ayude a demostrar este escenario. En primer lugar, configuraremos algunas funciones de ayuda que simplificarán el ejemplo. Para que el ejemplo funcione, el código utiliza varios temas que aún no hemos tratado. No te aconsejo que intentes comprenderlo en detalle hasta que no tengas un buen conocimiento del paquete sync
. En lugar de eso, te recomiendo que sigas las llamadas al código para entender lo más destacado, y que luego dirijas tu atención al segundo bloque de código, que contiene el corazón del ejemplo.
cadence
:=
sync
.
NewCond
(
&
sync
.
Mutex
{
}
)
go
func
(
)
{
for
range
time
.
Tick
(
1
*
time
.
Millisecond
)
{
cadence
.
Broadcast
(
)
}
}
(
)
takeStep
:=
func
(
)
{
cadence
.
L
.
Lock
(
)
cadence
.
Wait
(
)
cadence
.
L
.
Unlock
(
)
}
tryDir
:=
func
(
dirName
string
,
dir
*
int32
,
out
*
bytes
.
Buffer
)
bool
{
fmt
.
Fprintf
(
out
,
" %v"
,
dirName
)
atomic
.
AddInt32
(
dir
,
1
)
takeStep
(
)
if
atomic
.
LoadInt32
(
dir
)
==
1
{
fmt
.
Fprint
(
out
,
". Success!"
)
return
true
}
takeStep
(
)
atomic
.
AddInt32
(
dir
,
-
1
)
return
false
}
var
left
,
right
int32
tryLeft
:=
func
(
out
*
bytes
.
Buffer
)
bool
{
return
tryDir
(
"left"
,
&
left
,
out
)
}
tryRight
:=
func
(
out
*
bytes
.
Buffer
)
bool
{
return
tryDir
(
"right"
,
&
right
,
out
)
}
tryDir
permite que una persona intente moverse en una dirección y devuelve si ha tenido éxito o no. Cada dirección se representa como un recuento del número de personas que intentan moverse en esa dirección,dir
.En primer lugar, declaramos nuestra intención de movernos en una dirección incrementando esa dirección en uno. Hablaremos en detalle del paquete
atomic
en el capítulo 3. Por ahora, todo lo que necesitas saber es que las operaciones de este paquete son atómicas.Para que el ejemplo demuestre un bloqueo en vivo, cada persona debe moverse a la misma velocidad, o cadencia.
takeStep
simula una cadencia constante entre todas las partes.Aquí la persona se da cuenta de que no puede ir en esa dirección y abandona. Lo indicamos disminuyendo esa dirección en uno.
walk
:=
func
(
walking
*
sync
.
WaitGroup
,
name
string
)
{
var
out
bytes
.
Buffer
defer
func
(
)
{
fmt
.
Println
(
out
.
String
(
)
)
}
(
)
defer
walking
.
Done
(
)
fmt
.
Fprintf
(
&
out
,
"%v is trying to scoot:"
,
name
)
for
i
:=
0
;
i
<
5
;
i
++
{
if
tryLeft
(
&
out
)
||
tryRight
(
&
out
)
{
return
}
}
fmt
.
Fprintf
(
&
out
,
"\n%v tosses her hands up in exasperation!"
,
name
)
}
var
peopleInHallway
sync
.
WaitGroup
peopleInHallway
.
Add
(
2
)
go
walk
(
&
peopleInHallway
,
"Alice"
)
go
walk
(
&
peopleInHallway
,
"Barbara"
)
peopleInHallway
.
Wait
(
)
Puse un límite artificial al número de intentos para que este programa terminara. En un programa que tiene un livelock, puede que no exista tal límite, ¡por eso es un problema!
Primero, la persona intentará dar un paso a la izquierda y, si eso falla, intentará dar un paso a la derecha.
Esta variable proporciona una forma de que el programa espere hasta que ambas personas sean capaces de pasarse la una a la otra, o se rindan.
Esto produce el siguiente resultado:
Alice is trying to scoot: left right left right left right left right left right Alice tosses her hands up in exasperation! Barbara is trying to scoot: left right left right left right left right left right Barbara tosses her hands up in exasperation!
Puedes ver que Alicia y Bárbara siguen estorbándose mutuamente antes de darse por vencidas.
Este ejemplo demuestra una razón muy común por la que se escriben los bloqueos de conexión: dos o más procesos concurrentes que intentan evitar un bloqueo sin coordinarse. Si las personas del pasillo hubieran acordado entre sí que sólo se movería una persona, no habría bloqueo: una persona se quedaría quieta, la otra se movería al otro lado y seguirían caminando.
En mi opinión, los bloqueos activos son más difíciles de detectar que los bloqueos muertos, simplemente porque puede parecer que el programa está trabajando. Si un programa bloqueado se estuviera ejecutando en tu máquina y echaras un vistazo a la utilización de la CPU para determinar si está haciendo algo, podrías pensar que sí. Dependiendo del bloqueo, incluso podría estar emitiendo otras señales que te harían pensar que está trabajando. Y sin embargo, todo el tiempo, tu programa estaría jugando una eterna partida de barajar pasillos.
Los Livelocks son un subconjunto de un conjunto más amplio de problemas llamado inanición. Lo veremos a continuación.
Hambre
La inanición es cualquier situación en la que un proceso concurrente no puede obtener todos los recursos que necesita para realizar su trabajo.
Cuando hablamos de los bloqueos permanentes, el recurso del que se privaba a cada gorutina era un bloqueo compartido. Los bloqueos de nivel justifican una discusión separada de la inanición, porque en un bloqueo de nivel, todos los procesos concurrentes sufren la misma inanición, y no se realiza ningún trabajo. En términos más generales, la inanición suele implicar que hay uno o más procesos concurrentes codiciosos que impiden injustamente que uno o más procesos concurrentes realicen el trabajo de la forma más eficiente posible, o tal vez en absoluto.
Aquí tienes un ejemplo de programa con una gorutina codiciosa y una gorutina educada:
var
wg
sync
.
WaitGroup
var
sharedLock
sync
.
Mutex
const
runtime
=
1
*
time
.
Second
greedyWorker
:=
func
()
{
defer
wg
.
Done
()
var
count
int
for
begin
:=
time
.
Now
();
time
.
Since
(
begin
)
<=
runtime
;
{
sharedLock
.
Lock
()
time
.
Sleep
(
3
*
time
.
Nanosecond
)
sharedLock
.
Unlock
()
count
++
}
fmt
.
Printf
(
"Greedy worker was able to execute %v work loops\n"
,
count
)
}
politeWorker
:=
func
()
{
defer
wg
.
Done
()
var
count
int
for
begin
:=
time
.
Now
();
time
.
Since
(
begin
)
<=
runtime
;
{
sharedLock
.
Lock
()
time
.
Sleep
(
1
*
time
.
Nanosecond
)
sharedLock
.
Unlock
()
sharedLock
.
Lock
()
time
.
Sleep
(
1
*
time
.
Nanosecond
)
sharedLock
.
Unlock
()
sharedLock
.
Lock
()
time
.
Sleep
(
1
*
time
.
Nanosecond
)
sharedLock
.
Unlock
()
count
++
}
fmt
.
Printf
(
"Polite worker was able to execute %v work loops.\n"
,
count
)
}
wg
.
Add
(
2
)
go
greedyWorker
()
go
politeWorker
()
wg
.
Wait
()
Esto produce:
Polite worker was able to execute 289777 work loops. Greedy worker was able to execute 471287 work loops
El trabajador codicioso se aferra ávidamente al bloqueo compartido durante todo su bucle de trabajo, mientras que el trabajador educado sólo intenta bloquear cuando lo necesita. Ambos trabajadores realizan la misma cantidad de trabajo simulado (durmiendo durante tres nanosegundos), pero como puedes ver, en la misma cantidad de tiempo, ¡el trabajador codicioso realizó casi el doble de trabajo!
Si suponemos que ambos trabajadores tienen la sección crítica del mismo tamaño, en lugar de concluir que el algoritmo del trabajador codicioso es más eficiente (o que las llamadas a Lock
y Unlock
son lentas, que no lo son), concluiremos que el trabajador codicioso ha ampliado innecesariamente su control sobre el bloqueo compartido más allá de su sección crítica y está impidiendo (por inanición) que la gorutina del trabajador cortés realice su trabajo de forma eficiente.
Observa aquí nuestra técnica para identificar la inanición: una métrica. La inanición es un buen argumento para registrar y muestrear métricas. Una de las formas de detectar y resolver la inanición es registrar cuándo se realiza el trabajo, y luego determinar si tu ritmo de trabajo es tan alto como esperabas.
Así que la inanición puede hacer que tu programa se comporte de forma ineficaz o incorrecta. El ejemplo anterior demuestra una ineficiencia, pero si tienes un proceso concurrente que es tan codicioso que impide completamente que otro proceso concurrente realice su trabajo, tienes un problema mayor entre manos.
También debemos considerar el caso en que la inanición proceda de fuera del proceso Go. Ten en cuenta que la inanición también puede aplicarse a la CPU, la memoria, los gestores de archivos, las conexiones a bases de datos: cualquier recurso que deba compartirse es candidato a la inanición.
Determinar la seguridad de concurrencia
Por último, llegamos al aspecto más difícil del desarrollo de código concurrente, lo que subyace a todos los demás problemas: las personas. Detrás de cada línea de código hay al menos una persona.
Como hemos descubierto, el código concurrente es difícil por innumerables razones. Si eres desarrollador e intentas lidiar con todos estos problemas a medida que introduces nuevas funcionalidades o solucionas errores en tu programa, puede ser realmente difícil determinar qué es lo correcto.
Si empiezas con una pizarra en blanco y necesitas construir una forma sensata de modelar tu espacio problemático y hay concurrencia implicada, puede ser difícil encontrar el nivel adecuado de abstracción. ¿Cómo expones la concurrencia a las personas que llaman? ¿Qué técnicas utilizas para crear una solución que sea fácil de usar y modificar? ¿Cuál es el nivel adecuado de concurrencia para este problema? Aunque hay formas de pensar en estos problemas de manera estructurada, sigue siendo un arte.
Como desarrollador que interactúa con código existente, no siempre es obvio qué código está utilizando concurrencia, y cómo utilizar el código de forma segura. Tomemos como ejemplo esta firma de función
// CalculatePi calculates digits of Pi between the begin and end
// place.
func
CalculatePi
(
begin
,
end
int64
,
pi
*
Pi
)
Calcular pi con una gran precisión es algo que se hace mejor simultáneamente, pero este ejemplo plantea muchas preguntas:
-
¿Cómo lo hago con esta función?
-
¿Soy responsable de instanciar múltiples invocaciones concurrentes de esta función?
-
Parece que todas las instancias de la función van a operar directamente sobre la instancia de
Pi
cuya dirección paso; ¿soy responsable de sincronizar el acceso a esa memoria, o el tipoPi
se encarga de ello por mí?
Una función plantea todas estas cuestiones. Imagina un programa de cualquier tamaño moderado, y podrás empezar a comprender las complejidades que puede plantear la concurrencia.
Los comentarios pueden hacer maravillas aquí. ¿Y si la función CalculatePi
se escribiera así?
// CalculatePi calculates digits of Pi between the begin and end
// place.
//
// Internally, CalculatePi will create FLOOR((end-begin)/2) concurrent
// processes which recursively call CalculatePi. Synchronization of
// writes to pi are handled internally by the Pi struct.
func
CalculatePi
(
begin
,
end
int64
,
pi
*
Pi
)
Ahora entendemos que podemos llamar a la función sin más y no preocuparnos por la concurrencia o la sincronización. Es importante que el comentario cubra estos aspectos:
-
¿Quién es responsable de la concurrencia?
-
¿Cómo se asigna el espacio del problema a las primitivas de concurrencia?
-
¿Quién es responsable de la sincronización?
Cuando expongas funciones, métodos y variables en espacios de problemas que impliquen concurrencia, haz un favor a tus colegas y a ti mismo en el futuro: peca de verborrea en los comentarios e intenta cubrir estos tres aspectos.
Considera también que quizá la ambigüedad de esta función sugiere que la hemos modelado mal. Quizá deberíamos adoptar un enfoque funcional y asegurarnos de que nuestra función no tiene efectos secundarios:
func
CalculatePi
(
begin
,
end
int64
)
[]
uint
La firma de esta función por sí sola elimina cualquier duda sobre la sincronización, pero sigue dejando la cuestión de si se utiliza la concurrencia. Podemos modificar de nuevo la firma para lanzar otra señal sobre lo que está ocurriendo:
func
CalculatePi
(
begin
,
end
int64
)
<-
chan
uint
Aquí vemos el primer uso de lo que se denomina un canal. Por razones que exploraremos más adelante en la sección "Canales", esto sugiere que CalculatePi
tendrá al menos una goroutine y que no deberíamos molestarnos en crear la nuestra.
Estas modificaciones tienen entonces ramificaciones de rendimiento que hay que tener en cuenta, y volvemos al problema de equilibrar la claridad con el rendimiento. La claridad es importante porque queremos que sea lo más probable posible que las personas que trabajen con este código en el futuro hagan lo correcto, y el rendimiento es importante por razones obvias. Ambas cosas no se excluyen mutuamente, pero son difíciles de mezclar.
Ahora considera estas dificultades en la comunicación e intenta ampliarlas a proyectos del tamaño de un equipo.
Vaya, esto es un problema.
La buena noticia es que Go ha hecho progresos para que este tipo de problemas sean más fáciles de resolver. El propio lenguaje favorece la legibilidad y la simplicidad. La forma en que fomenta el modelado de tu código concurrente favorece la corrección, la componibilidad y la escalabilidad. De hecho, ¡la forma en que Go gestiona la concurrencia puede ayudar a expresar dominios de problemas con mayor claridad! Veamos por qué.
La sencillez frente a la complejidad
Hasta ahora, he pintado un panorama bastante sombrío. La concurrencia es, sin duda, un área difícil de la informática, pero quiero dejarte con una esperanza: estos problemas no son intratables y, con las primitivas de concurrencia de Go, puedes expresar tus algoritmos concurrentes de forma más segura y clara. Las dificultades de tiempo de ejecución y comunicación de las que hemos hablado no están ni mucho menos resueltas con Go, pero se han facilitado notablemente. En el próximo capítulo, descubriremos la raíz de cómo se ha logrado este progreso. Aquí, vamos a dedicar un poco de tiempo a explorar la idea de que las primitivas de concurrencia de Go pueden, en realidad, facilitar el modelado de dominios de problemas y expresar algoritmos con mayor claridad.
El tiempo de ejecución de Go hace la mayor parte del trabajo pesado y proporciona la base para la mayoría de las sutilezas de concurrencia de Go. Dejaremos la discusión sobre cómo funciona todo esto para el Capítulo 6, pero aquí hablaremos de cómo estas cosas te facilitan la vida.
Hablemos primero del recolector de basura concurrente y de baja latencia de Go. A menudo se debate entre los desarrolladores si es bueno tener recolectores de basura en un lenguaje. Los detractores sugieren que los recolectores de basura impiden el trabajo en cualquier ámbito problemático que requiera un rendimiento en tiempo real o un perfil de rendimiento determinista: que detener toda la actividad de un programa para limpiar la basura simplemente no es aceptable. Aunque esto tiene cierto mérito, el excelente trabajo que se ha realizado sobre el recolector de basura de Go ha reducido drásticamente el público que necesita preocuparse por las minucias de cómo funciona la recolección de basura de Go. A partir de Go 1.8, ¡las pausas de la recogida de basura suelen ser de entre 10 y 100 microsegundos!
¿En qué te ayuda esto? La gestión de la memoria puede ser otro dominio problemático difícil en informática, y cuando se combina con la concurrencia, puede llegar a ser extraordinariamente difícil escribir código correcto. Si perteneces a la mayoría de desarrolladores que no necesitan preocuparse por pausas tan pequeñas como 10 microsegundos, Go ha facilitado mucho el uso de la concurrencia en tu programa al no obligarte a gestionar la memoria, y mucho menos a través de procesos concurrentes.
El tiempo de ejecución de Go también gestiona automáticamente la multiplexación de operaciones concurrentes en hilos del sistema operativo. Eso es un trabalenguas, y veremos exactamente lo que significa en la sección sobre "Goroutines". Para entender cómo te ayuda esto, todo lo que necesitas saber es que te permite mapear directamente problemas concurrentes en construcciones concurrentes, en lugar de tener que lidiar con la minucia de iniciar y gestionar hilos, y mapear la lógica uniformemente entre los hilos disponibles.
Por ejemplo, supongamos que escribes un servidor web y quieres que cada conexión aceptada se gestione simultáneamente con cualquier otra conexión. En algunos lenguajes, antes de que tu servidor web empiece a aceptar conexiones, es probable que tengas que crear una colección de hilos, comúnmente llamada grupo de hilos, y luego asignar las conexiones entrantes a los hilos. Luego, dentro de cada hilo que hayas creado, tendrías que hacer un bucle sobre todas las conexiones de ese hilo para asegurarte de que todas reciben algo de tiempo de CPU. Además, tendrías que escribir tu lógica de gestión de conexiones para que fuera pausable, de modo que compartiera equitativamente con las demás conexiones.
¡Uf! En cambio, en Go escribirías una función y luego antepondrías a su invocación la palabra clave go
. El tiempo de ejecución se encarga automáticamente de todo lo demás. Cuando estés llevando a cabo el proceso de diseño de tu programa, ¿con qué modelo crees que es más probable que llegues a la concurrencia? ¿Cuál crees que es más probable que resulte correcto?
Las primitivas de concurrencia de Go también facilitan la composición de problemas más grandes. Como veremos en la sección "Canales", la primitiva de canal de Go proporciona una forma componible y segura de comunicarse entre procesos concurrentes.
He pasado por alto la mayoría de los detalles de cómo funcionan estas cosas, pero quería darte una idea de cómo Go te invita a utilizar la concurrencia en tu programa para ayudarte a resolver tus problemas de una forma clara y eficaz. En el próximo capítulo hablaremos de la filosofía de la concurrencia y de por qué Go ha acertado tanto. Si estás ansioso por meterte de lleno en algo de código, quizá quieras pasar al Capítulo 3.
1 Existe una propuesta aceptada para permitir que el tiempo de ejecución detecte bloqueos parciales, pero no se ha implementado. Para más información, consulta https://github.com/golang/go/issues/13759.
2 En realidad, no tenemos ninguna garantía de en qué orden se ejecutarán las goroutinas, ni de cuánto tardarán en iniciarse. Es plausible, aunque improbable, que una goroutina pueda adquirir y liberar ambos bloqueos antes de que empiece la otra, ¡evitando así el punto muerto!
Get Concurrencia en 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.