lunes, 23 de abril de 2018

Concurrencia - Clean Code


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