Arquitecturas Concurrentes, Episodio 2: Algo llamado Concurrencia

Ya presentada la noción de arquitectura, introduzcamos el otro tópico que nos incumbe: la concurrencia, concepto diferente pero relacionado y a veces confundido con paralelismo.

Concurrencia vs Paralelismo

Sin entrar en definiciones de libro, repasemos qué significa cada cosa:

  • Paralelismo: es la ejecución simultánea de dos o más operaciones. Se trata de un propiedad que permite a los programas ejecutarse más rápido, y requiere un soporte físico: ya sean múltiples procesadores, núcleos o computadoras, tenemos múltiples computaciones ejecutándose al mismo tiempo, con mínima o nula comunicación entre ellas. Es una cuestión no funcional, normalmente no percibida cualitativamente por el usuario.
    Por ejemplo, tanto si procesamos un archivo de log para filtrar palabras en una sola computadora, o a través de un clúster Hadoop con decenas de computadoras, el resultado final será el mismo.
  • Concurrencia: es el existencia simultánea de múltiples contextos de ejecución. Se trata de una propiedad lógica que permite a los programas ser más usables, y que afecta a la funcionalidad del sistema.
    Por ejemplo, si tenemos un procesador de texto, mientras escribimos, en segundo plano tendremos tareas que validan la ortografía, guardan el documento, lo sincronizan contra una versión remota y descargan actualizaciones. Si estas tareas no se estuvieran ejecutando de forma concurrente, tendríamos que dejar de escribir, guardar el archivo, chequear la ortografía, etc, de forma secuencial, lo cual evidentemente afecta a la usabilidad del sistema. Y esto es independiente de que las tareas se ejecuten en paralelo o no.
    Como se desprende del ejemplo, cuando estamos ante un diseño concurrente, tendremos en general recursos compartidos (por ejemplo la hoja del editor) y por tanto deberemos comunicar y coordinar de alguna forma estas tareas.

Moraleja: si bien podemos tener sistemas concurrente en la que haya ejecución paralela, concurrencia no implica paralelismo. Y paralelismo no implica concurrencia.

Paréntesis: GIL

¿Y por qué queremos diferenciar estos dos conceptos con precisión? Porque a veces descubrimos como la sutil confusión entre ambos lleva tomar decisiones incorrectas, como veremos a continuación.

Por ejemplo, es bastante conocido que el intérprete estándar de Ruby (MRI) posee algo llamado GIL: Global Interpreter Lock. Sin entrar en detalles, este aspecto de MRI significa que no puede aprovechar el paralelismo que le ofrecería un procesador multicore: tengamos 1 o 8 cores, sólo uno podrá ejecutar código Ruby dentro de un mismo proceso.

Para ser justos, esta es una falencia de tener un GIL está presente también en otros lenguajes, por ejemplo Python. En ambos casos, existen implementaciones alternativas basadas en la JVM, JRuby y Jython, que no tienen esta limitación.

Además decimos que los Threads de Ruby son Green Threads: son planificados por el entorno de ejecución, y no por el sistema operativo.

Es bastante obvio que Ruby con MRI, entonces, no soporta multiprocesamiento. Pero aún así, es frecuente toparnos con afirmaciones, incorrectas, respecto a que:

  • No es un lenguaje concurrente, o al menos, no tiene “verdadera” concurrencia
  • No tenemos ninguno de los beneficios ni problemas de la concurrencia

Tomémosnos unos minutos para demostrar que esto no es así, y de paso, repasar los problemas de la concurrencia y el enfoque tradicional para su manejo.

Los problemas obvios

Como primer ejemplo de problemas con la concurrencia tradicional, va uno fácil, en un lenguaje imperativo: Ruby. Además utilizaremos la implementación de JRuby9, que no tiene GIL y por tanto puede aprovechar el paralelismo que nos da un procesador multicore.

Nota: el código de estos ejemplos se encuentra acá
i = 0
1000.times { Thread.new { i += 1 } }

Es bastante obvio que este ejemplo se rompe.

Si lo ejecutamos varias veces obtendremos resultados como i = 999, i = 995, etc. Y esto se debe a que la operación de incremento += no es atómica. Es decir, este código es equivalente al siguiente:

i = 0
1000.times { Thread.new { x = i; x += 1; i = x } }

Es fácil de ver también que si tenemos dos hilos A y B ejecutando en paralelo, los dos podrían estar leyendo simultáneamente a la variable i.

Por ejemplo:

  1. A lee i, que vale 0,
  2. A asigna x=0 y lo incrementa, quedando x en 1.
  3. Al mismo tiempo, B lee i, que sigue valiendo 0
  4. B incrementa x, quedando x también en 1
  5. B logra reasignar a i con 1, antes que A.
  6. A reasigna a i, también con 1.

Conclusión: los dos hilos intentaron incrementar i, pero sólo uno terminó haciéndolo.

¿Cuál es la solución tradicional para solucionar esto? Parar el mundo: usar locks que sincronicen el acceso a la variable compartida, asegurando un acceso exclusivo.

i = 0
lock = ...crear un lock...
1000.times { Thread.new { lock.synchronize { i += 1 } } }

Lamentablemente, no todos los problemas de concurrencia son tan evidentes, ni tan fácil de solucionar.

La concurrencia tradicional es difícil

Vamos ahora a casos menos evidentes de problemas de concurrencia al utilizar threads y locks.

Primero, vamos a acceder de forma concurrente a los slots de un HashMap (clase de Java, pero nuevamente la usamos desde JRuby).

@map = HashMap.new 
@executor = Executors.newFixedThreadPool(20)
500.times do
@executor.execute { @map[“foo”] = 1 }
@executor.execute { @map[“foo”] = 2 }
@executor.execute { @map[“foo”] = 3 }
@executor.execute { puts @map[“foo”] }
end

Este código crea hasta 20 hilos y los asigna a 4 tareas diferentes: asignar 1 en la clave “foo”, asignar 2, asignar 3, e imprimir por pantalla el estado de la clave “foo”.

HashMap es conocido popularmente como se una clase no sincronizada, no thread-safe, que no soporta acceso concurrente.

¿Qué sucederá al ejecutar este código? ¿Se romperá? ¿Dará resultados erróneos?

Bueno, no.

Consistentemente el resultado impreso será 1, 2 0 3. No debería sorprenderenos: no todo el conocimiento popular es correcto. Si vemos su documentación con más detalle, encontraremos que si sólo modificamos los valores (en lugar de agregar o quitar claves), no se romperá. ¿Por qué? ¡Detalles de implementación!

Moraleja 1: que un componente no sea threadsafe no significa en términos generales que todas sus operaciones se romperán, o que se romperán en todos los casos. El código no sincronizado no siempre se rompe.

Y quizás, aunque nuestros tests den verde siempre, aún así tengamos un problema de concurrencia en puerta.

Probemos ahora utilizar un componente sincronizado muy conocido, el Hashtable, otro diccionario de Java, pero que, esta vez, tiene operaciones sincronizadas mediante un lock.

@executor = Executors.newFixedThreadPool(20)
@map = Hashtable.new
@map[‘foo’] = 0
500.times do
@executor.execute do
@map[“foo”] += 1
puts @map[“foo”]
end
@executor.execute do
@map[“foo”] -= 1
puts @map[“foo”]
end
end

En este caso tenemos hasta 20 hilos ejecutando 500 tareas de incremento, y 500 tareas de decremento del valor en la clave "foo".

¿Cómo se comportará la concurrencia en este caso? Resulta evidente que si tenemos 500 tareas de incremento de una variable, y 500 de decremento, el estado final de la variable debería ser igual que el inicial: en este caso @map["foo"] == 0.

Dado que tenemos un Hashtable, clase sincronizada, deberíamos esperar este comportamiento, ¿verdad?

Nuevamente: no.

Sucede que Hashtable tiene operaciones sincronizadas como put y get, es decir, permite de forma concurrente escribir y leer valores. Pero no cuenta con ninguna operación de incremento, por lo que el += vuelve a no ser atómico como en nuestro primer ejemplo.

Por lo tanto, si ejecutamos el código anterior, en general el valor final de la clave "foo" no será cero. El error está en asumir que un componente sincronizado mediante locks en sus método, sincroniza también al código que lo usa.

Moraleja 2: Que usemos un componente “thread safe” no nos garantiza que el código lo sea. El código sincronizado no siempre anda.

El problema es la concurrencia, no el paralelismo

Los ejemplos anteriores los presentamos en JRuby, un ambiente Ruby basado en la JVM, desprovisto de GIL y que por tanto soporta paralelismo.

¿Hubiéramos tenido problemas similares de haber trabajado con MRI, que no soporta paralelismo paralelismo?

Para responder esta pregunta, vamos a implementar nosotros nuestro propio diccionario sincronizado, análogo al Hashtable que vimos antes:

class Table
def initialize(hash)
@hash = hash
@lock = Mutex.new
end
  def put(key, value)
@lock.synchronize do
@hash[key] = value
end
end
  def get(key)
@lock.synchronize do
@hash[key]
end
end
  ...etc...
  def accum(key, delta)
v = get(key) + delta
put(key, v)
end
end

Como vimos en la moraleja 2, es bastante evidente ahora que en JRuby, si creamos un programa que lanza múltiples hilos que le envíen el mensaje accum a una misma instancia, los resultados serán inconsistentes.

def concurrent_test
table = Table.new({counter: 0})
(1..500).map do |it|
if it.even?
Thread.new { table.accum(:counter, 1) }
else
Thread.new { table.accum(:counter, -1) }
end
end.each(&:join)
table
end

Ejecutemos ese mismo programa con MRI, y obtendremos, de forma consistente el mismo resultado:

#<Table:0x007f2c4d340b18 @hash={:counter=>0}, @lock=#<Mutex:0x007f2c4d340af0>>

¿Sorprendente, no? Hasta acá, parecería que en ausencia de paralelismo, el acceso concurrente no es un problema, dado que el resultado fue consistente…..

Ups, terminó la iteración, tenemos que salir a producción, los tests pasan, y dejamos así al código. Pero meses después agregamos logging a nuestra aplicación. Y caemos en nuestro método accum:

def accum(key, delta)
v = get(key) + delta
puts “INFO: …”
put(key, v)
end

Pensemos en abstracto: ¿que tanto cambio funcional puede introducir una impresión por pantalla? Intuitivamente, ninguno. Porque nadie sospecharía jamás de un puts.

Sin embargo, ejecutemos ahora nuestro programa, aún MRI, y obtendremos, por ejemplo:

#<Table:0x007f2c4d340b18 @hash={:counter=>3}, @lock=#<Mutex:0x007f2c4d340af0>>

Genial, introducir logging rompió al sistema.

Esto se debe a cómo MRI planifica sus green threads; hacer entrada/salida permite la introducción de context switch. En términos generales, interactuar con system calls sincrónicas tiene impacto en la planificación. Y esto no es algo particular de Ruby.

Moraleja 3: Si tenemos un código mal sincronizado, pequeños cambios pueden tener impacto devastador.

La solución al problema es lockear nuestra operación de acumulación

def accum(key, delta)
@lock.synchronize do
v = get(key) + delta
puts ‘INFO: ...’
put(key, v)
end
end
Para que esto funcione además hemos reemplazo a nuestro Mutex https://en.wikipedia.org/wiki/Java_virtual_machineoriginal por un lock reentrante: Monitor. De lo contrario, al intentar lockear en el synchronize, y luego, nuevamente, en el get, se producirá deadlock.

Moraleja 4: aunque estemos trabajando sobre un ambiente libre de paralelismo, igual podemos tener problemas de concurrencia. Y esto vale para cualquier enfoque que apliquemos para el manejo de concurrencia, ya sea basado en locks o no.


Reflexiones finales del episodio 2

  • La concurrencia no es fácil. Las tecnologías te pueden ayudar a lidiar con mejor con ella, pero en última instancia, los problemas de concurrencia, escala, o performance, están en el dominio de tu diseño.
  • En el manejo de concurrencia tradicional, basado en locks, por un lado no resulta fácil de razonar, y por otro lado, parte de la presunción de que podemos parar al mundo (lo cual no siempre es factible)

En los próximos episodios veremos, de todas formas, que utilizando herramientas no tradicionales, de más alto nivel, no basadas en locks (o al menos, no locking pesimista), podremos construir arquitecturas concurrentes más simples y robustas.

Para leer más