Esta publicación se
centra en el capítulo de Concurrencia de Clean
Code (Robert
C. Martin).
Por qué concurrencia
Las aplicaciones que
se ejecutan en un sólo hilo son relativamente fáciles de entender
ya que el qué se hace y el cuando se hace están fuertemente
acoplados. En cualquier momento se puede añadir un punto de ruptura
en la ejecución para averiguar el estado del sistema.
Hay casos en los que
el sistema se verá en la obligación de hacer varias tareas
simultáneamente. Por ejemplo, si tomamos como ejemplo la creación
de un agregador de noticias con una frecuencia de actualización de
un minuto que se nutra de distintos RSS seleccionados, a medida que
la cantidad de fuentes ascienda, la agregación de noticias podría
aumentar la frecuencia de adición haciendo que sea superior a un
minuto.En este caso, se podría hacer una consulta simultánea de las
fuentes para mejorar el rendimiento considerablemente.
Desacoplar el qué
hay que hacer del cuándo puede mejorar tanto el rendimiento como la
estructura de la aplicación. Desde el punto de vista estructural, la
aplicación es como un conjunto de pequeños sistemas que colaboran
entre sí más que un sólo sistema el cual tiene un sólo hilo de
ejecución.
Mitos y confusiones
La concurrencia
implica cierta complejidad y si no se tiene en cuenta puede derivar
en situaciones inesperadas.
Los principales
mitos y confusiones:
-
La concurrencia siempre mejora el rendimiento. Esto dependerá de si el sistema tiene un tiempo de espera por hacer ciertas tareas de maneras secuencial. Si estas pueden ser realizadasde manera paralela, en este caso habrá una mejora del rendimiento.
-
El diseño no cambia cuando se desarrollan programas concurrentes. Desacoplar el qué del cuándo deriva, inevitablemente, en diseños distintos.
-
Entender los problemas de concurrencia no es importante cuando se trabaja con contenedores como una web o un contenedor EJB. Es importante saber qué está sucediendo en el contenedor y cómo abordar los problemas que puedan surgir.
Aspectos de la
concurrencia:
-
La concurrencia implica cierta sobrecarga ya que hay que escribir código adicional.
-
La concurrencia es compleja incluso para problemas simples.
-
Los problemas de concurrencia no son fácilmente reproducibles por lo que se suelen ignorar o tratar como problemas puntuales.
-
A veces la concurrencia necesita un cambio fundamental en la estrategia de diseño.
Retos
La concurrencia
plantea nuevos retos hasta en sistemas triviales. Observemos un
sistema sencillo con la siguiente clase:
Si el sistema crea
una instancia de esta clase, le asigna el valor 10 y esta instancia
se comparte en dos hilos de ejecución diferentes. Dependiendo de
cómo se hagan las llamadas al método «getNextId» podrían darse
los siguientes casos:
-
El hilo de ejecución número 1 obtiene el valor 11 y el hilo de ejecución número 2 obtiene el valor 12. El valor del atributo «lastIdUsed» es 12.
-
El hilo de ejecución número 2 obtiene el valor 11 y el hilo de ejecución número 1 obtiene el valor 11. El valor del atributo «lastIdUsed» es 12.
-
El hilo de ejecución número 1 obtiene el valor 12 y el hilo de ejecución número 2 obtiene el valor 12. El valor del atributo «lastIdUsed» es 12.
En el primer caso el
hilo de ejecución número uno se ejecuta antes que el dos mientras
que en el segundo caso es al revés. El tercer caso es especial, ya
que los hilos se ejecutan simultáneamente y estos adoptan el valor
inicial de manera que los dos hilos de ejecución tienen un valor de
10, el cual incrementan y devuelven.
Principios de defensa con la concurrencia
Principio de responsabilidad única o Single Responsibility Principle
El principio de
responsabilidad única, o SRP
por su siglas en inglés, establece que un método, clase o
componente debería tener una única razón para cambiar. El diseño
concurrente es lo suficientemente complejo, por sí mismo, como para
ser un razón para cambiar y, por lo tanto, merece ser separado del
resto del código. A pesar de esto, la implementación de la
concurrencia está incluida directamente en el código de producción.
Aspectos a tener en cuenta:
-
El código relativo a la concurrencia tiene sus propio ciclos de desarrollo, cambio y personalización.
-
El código relativo a la concurrencia tiene sus propios retos, distintos de los que plantea el código no concurrente.
-
El número de formas en las que puede fallar el código basado en una concurrencia mal escrita es suficiente reto como para aislarlo del resto del código.
La recomendación es
separar el código relativa a la concurrencia del no concurrente.
Corolario: límite del alcance de datos
Como se vio
anteriormente, dos hilos de ejecución pueden compartir un objeto y
modificarlo de manera que se obtenga un comportamiento inesperado.
Una solución consiste en usar alguna característica del lenguaje
para la sincronización y proteger las secciones críticas que son
compartidas por los hilos de ejecución. Es importante reducir el
número de estas secciones ya que:
-
Se puede olvidar proteger esta sección.
-
Habrá una duplicación de esfuerzo para que todo funcione correctamente (esto es una violación del DRY).
-
Será difícil modificar la fuente de los fallos ya que son complicados de encontrar.
La recomendación es
llevar la encapsulación al extremo y limitar el acceso de cualquier
dato que se comparta.
Corolario: usar copias de datos
Una estrategia para
evitar compartir datos es no compartir datos.
En algunas situaciones será posible copiar los objetos y tratarlos
como un copia de sólo lectura. En otros casos será posible
recopilar los objetos de los distintos hilos de ejecución y
mezclarlos en un hilo determinado.
Si existe una forma
sencilla de evitar los objetos compartidos el código será menos
complejo. La creación de objetos adicionales es una ventaja en
comparación con la dificultad del código compartido.
Corolario: los hilos de ejecución deberían ser tan independientes como sea posible
Se recomienda crear
el código de forma que los procesos no compartan datos para evitar
problemas de sincronización.
Conocer la librería
Dependiendo del
lenguaje de programación este tendrá sus propias características
que el programador deberá considerar.
Conocer los modelos de ejecución
Hay diversas maneras
de separar el comportamiento en una aplicación concurrente. Hay
algunos conceptos previos para poder entender cómo separar el
comportamiento:
-
Recursos vinculados: recursos que tienen un tamaño o número fijo como puede ser una conexión a la base de datos.
-
Exclusión mutua: sólo un proceso puede acceder a la vez a un recurso o datos compartidos.
-
Inanicción: se impide que los procesos consuman demasiado tiempo o se ejecuten indefinidamente dejando relegados a otros que pueden no llegar a terminar de ejecutarse nunca.
-
Bloqueo: dos o más procesos esperan a que el otro termine. Se puede dar el caso de que dos procesos tengan cada uno un recurso compartido que el otro está esperando.
-
Bloqueo activo: procesos que tratan de avanzar pero se estorban unos a otros.
Productor-consumidor
En este modelo de
ejecución uno o más hilos de ejecución (los productores) crean
algún trabajo y lo añaden a un buffer o cola mientras que uno o más
hilos de ejecución (consumidores) obtienen ese trabajo del buffer o
cola.
El buffer o la cola
es el recurso compartido. Los productores deben esperar a que se
libere espacio para seguir produciendo mientras que los consumidores
deben esperar a que haya algo para consumir.
Lectores-escritores
Como en el modelo
anterior hay un recurso compartido en el cual los escritores
actualizan información mientras que los lectores utilizan esa
información. Si los escritores actualizan la información
regularmente pueden llegar a impactar en el rendimiento de los
lectores y viceversa.
El desafío consiste
en equilibrar las necesidades de los escritores y lectores.
La cena de los filósofos
Imaginando una cena
de un grupo de filósofos en una mesa redonda en la cual cada
filósofo tiene un tenedor a su izquierda. Hay un gran cuenco de
comida en el centro. Los filósofos sólo sólo comen si tienen
hambre y no piensan. Cuando uno de ellos tiene hambre tiene que coger
los dos tenedores (el suyo y el de su compañero) y comer del cuenco.
Si el filósofo de su izquierda o derecha ya está usando uno de los
tenedores tendrá que esperar a que termine de comer y lo deje en su
sitio.
Reemplazando los
filósofos por hilos de ejecución y los tenedores por recursos
compartidos se tiene un problema de concurrencia.
Cuidado con las dependencias entre métodos sincronizados
La recomendación es
evitar usar más de un método en un objeto compartido ya que se
pueden producir bugs sutiles.
Cuando no se puede
evitar hay unas reglas:
-
Bloque basado en cliente: el cliente bloquea el recurso antes de invocar el método y asegura que este método incluye el código que invoque al último método.
-
Bloqueo basado en servidores: se crea un método en el servidor que bloquee el propio servidor e invoque los métodos para más tarde anular el bloqueo. El cliente debe invocar este nuevo método.
-
Servidor adaptado: se crea un intermediario que es el que realiza el bloqueo y los métodos.
Reducir al mínimo las secciones sincronizadas
Las secciones de
código que están protegidas añaden un bloqueo que genera retrasos
y sobrecarga en el sistema, por ello este ćodigo debe ser el
estrictamente necesario para proteger la sección crítica.
Escribir un código de cierre correcto es complicado
Los problemas de
concurrencia como los bloqueos son habituales. La recomendación en
este caso es repasar los algoritmos y dedicarles tiempo ya que no es
algo trivial. Hay que pensar cómo hacer un cierre con antelación y
probarlo hasta que funcione.
Probar el código con procesos
Probar el código
que es correcto es muy complicado ya que las pruebas no garantizan
que esté funcionando correctamente aunque puede minimizar el riesgo.
La recomendación es
crear test que expongan los problemas potenciales y ejecutarlos
frecuentemente con diferentes configuraciones y cargas. Si se produce
un fallo hay que inspeccionarlo y no se debe ignorar porque en
pruebas posteriores no se produzca.
Recomendaciones más
concretas:
-
Considerar fallos como posibles problemas de los hilos de ejecución.
-
Hacer que funcione primero el código sin distintos hilos de ejecución.
-
Hacer que el código interaccione con otros hilos de ejecución.
-
Hacer el código modificable.
-
Ejecutar el código con más hilos de ejecución que procesadores.
-
Ejecutar en diferentes plataformas.
-
Diseñar el código para probar y forzar los fallos.
Considerar los fallos como posibles problemas de los hilos de ejecución
El código que se
ejecuta en distintos hilos puede fallar aún en los casos más
simples. Replicarlo puede llegar a ser una tarea frustrante por lo
que, a veces, se ignoran estos errores. Cuantos más errores de este
tipo se ignoren más desarrollo se hace sobre este código
defectuoso.
La recomendación es
no ignorar los fallos del sistema como algo puntual.
Hacer que funcione primero el código sin distintos hilos de ejecución
La recomendación es
que el código funcione en un hilo de ejecución y asegurar que
funciona correctamente.
Hacer que el código interaccione con otros hilos de ejecución
En este momento el
código que ya funciona en un único hilo pasa a interaccionar con
otros hilos de ejecución. El código tiene que estar desarrollado de
manera que se pueda ejecutar:
-
Un proceso, varios procesos y variar el número de estos según se está ejecutando.
-
El código puede interactuar con algo que puede ser verdad o pruebas dobles.
-
Ejecutar el código con pruebas dobles de forma rápida, lenta y variable.
-
Configurar pruebas que ejecutar y ejecutarlas en distintas interacciones.
La recomendación es
que el código debe poder interactuar con otros elementos y
ejecutarse en distintas configuraciones.
Hacer el código modificable
La obtención del
equilibro adecuado del sistema para los procesos suele requerir de
prueba y error. En las fases iniciales hay que medir el rendimiento
del sistema con distintas configuraciones. Una configuración
fundamental es permitir un número de hilos y que el número de estos
pueda variar mientras el sistema se está ejecutando. También se
debería permitir la modificación automática en función de la
producción y la utilización del sistema.
Ejecutar el código con más hilos de ejecución que procesadores
Si hay más hilos de
ejecución que procesadores entonces habrá más frecuencia de
intercambio entre los hilos de ejecución lo que aumentará las
posibilidades de que surjan problemas.
Ejecutar en diferentes plataformas
Si el código va a
ejecutarse en distintas plataformas es fundamental probarlas en todas
ya que cada sistema operativo tiene sus propias políticas de
ejecución.
La recomendación es
probar en todas las plataformas y desde el principio del desarrollo.
Diseñar el código para probar y forzar los fallos.
Como la detección
de los errores puede ser complicada y su replicación aún más, se
puede diseñar el software de manera que se pueda forzar a que se
ejecute en distinto orden añadiendo algunos métodos como pueden ser
«wait», «sleep», «yield» y «priority».
Cada uno de estos
métodos altera el orden de ejecución pudiendo aumentar así las
posibilidades de que se produzca un error. Hay dos estrategias para
forzar los fallos:
-
Manual
-
Automática
Manual
En esta estrategia
se utilizan los métodos manualmente con el fin de probar un
fragmento de código más problemático. Esto se hace mediante
pruebas y no deben llegar a producción.
Automática
Existen herramientas
como la estructura orientada a aspectos para tal fin. Estas
herramientas se encargan de utilizar estos métodos en ejecuciones
aleatorias aumentando considerablemente la capacidad de detectar los
errores.
Conclusiones
El código
concurrente no es fácil de desarrollar ya que hasta el código más
sencillo se puede complicar.
Se debe diseñar el
código pensando en el principio de responsabilidad única, empezando
por la ejecución en un único hilo para más tarde interaccionar
entre los recursos compartidos.
Conocer los
problemas de concurrencia siempre es de ayuda para enfrentarse a
otros más comunes.
Conocer las
herramientas que proporciona el lenguaje de programación o la
biblioteca es fundamental para poder resolver los problemas.
Es necesario reducir
al mínimo el código que se puede bloquear, evitar invocar una
sección bloqueada desde otra y reducir la cantidad de objetos
compartidos.
Inicialmente será
complicado detectar errores hasta que se hagan pruebas de carga más
exhaustivas. Por esta razón el código se debe poder ejecutar con
distintas configuraciones y plataformas.
La probabilidad de
detectar errores mejora si se diseña el código y se hacen pruebas
forzando los fallos ya sea de manera manual o automática.
No hay comentarios:
Publicar un comentario