Febrero de 2019

Volumen 34, número 2

[C#]

Minimizar la complejidad del código C# multiproceso

Por Thomas Hansen | Febrero de 2019

Las bifurcaciones, o programación multiproceso forman parte del grupo de cosas más difíciles a la hora de programar. Esto se debe a su naturaleza paralela, que requiere una mentalidad completamente diferente de la programación lineal con un único subproceso. Una buena analogía para el problema es un malabarista, que debe mantener varias bolas en el aire sin que interfieran negativamente entre sí. Es un gran reto. Sin embargo, con las herramientas adecuadas y la actitud correcta, se puede hacer.

En este artículo, profundizo en algunas de las herramientas que he creado para simplificar la programación con varios subprocesos y para evitar problemas como condiciones de carrera e interbloqueos, entre otros. Se puede decir que la cadena de herramientas se basa en el azúcar sintáctico y los delegados mágicos. Sin embargo, como dijo el gran músico de jazz Miles Davis, "En la música, el silencio es más importante que el sonido". La magia surge entre el ruido.

Dicho de otro modo, no se trata necesariamente de lo que puede programar, sino de lo que puede hacer y decide no hacer porque prefiere crear cierta magia entre las líneas. Esto me recuerda a una frase de Bill Gates: "Medir la calidad del trabajo por la cantidad de líneas de código es como medir la calidad de un avión por su peso". Por lo tanto, en lugar de enseñarle a programar más, espero ayudarle a programar menos.

El reto de la sincronización

El primer problema que presenta la programación multiproceso es la sincronización del acceso a un recurso compartido. Se producen problemas cuando dos o más subprocesos comparten acceso a un objeto y ambos podrían intentar modificar el objeto a la vez. Cuando se lanzó C# por primera vez, la instrucción de bloqueo implementó una forma básica de garantizar que solo un subproceso pudiera acceder a un recurso especificado, como un archivo de datos, y funcionaba bien. La palabra clave de bloqueo de C# se entiende tan fácilmente que, por sí misma, revolucionó nuestra forma de pensar acerca de este problema.

Sin embargo, un bloqueo simple tiene una desventaja importante: no discrimina el acceso de solo lectura del de escritura. Por ejemplo, podría tener 10 subprocesos diferentes que quieran leer en un objeto compartido, se podría dar a estos subprocesos acceso simultáneo a la instancia sin causar problemas a través de la clase ReaderWriterLockSlim en el espacio de nombres System.Threading. A diferencia de la instrucción de bloqueo, esta clase le permite especificar si el código está escribiendo en el objeto o, simplemente, leyendo en el objeto. Esto permite la entrada de varios lectores al mismo tiempo, pero deniega cualquier acceso de escritura de código hasta que el resto de subprocesos de lectura y escritura finalicen.

Y, ahora, el problema: la sintaxis, al utilizar la clase ReaderWriterLock, se hace tediosa, con una gran cantidad de código repetitivo que reduce la legibilidad y dificulta el mantenimiento a lo largo del tiempo. Además, a menudo, el código está disperso con varios bloqueos try y finally. Un simple error de escritura también puede producir efectos desastrosos que, a veces, son muy difíciles de detectar más adelante. 

Encapsular ReaderWriterLockSlim en una clase sencilla, de repente, resuelve el problema sin código repetitivo y, al mismo tiempo, reduce el riesgo de que un pequeño error de escritura le estropee el día. La clase, como se muestra en la figura 1, se basa completamente en trucos lambda. Se puede decir que se trata de azúcar sintáctico en torno a algunos delegados, suponiendo que existen un par de interfaces. Lo más importante es que puede conseguir que el código sea más DRY (Una vez y solo una).

Figura 1 Encapsulamiento de ReaderWriterLockSlim

public class Synchronizer<TImpl, TIRead, TIWrite> where TImpl : TIWrite, TIRead
{
  ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
  TImpl _shared;

  public Synchronizer(TImpl shared)
  {
    _shared = shared;
  }

  public void Read(Action<TIRead> functor)
  {
    _lock.EnterReadLock();
    try {
      functor(_shared);
    } finally {
      _lock.ExitReadLock();
    }
  }

  public void Write(Action<TIWrite> functor)
  {
    _lock.EnterWriteLock();
    try {
      functor(_shared);
    } finally {
      _lock.ExitWriteLock();
    }
  }
}

Solo hay 27 líneas de código en la figura 1, lo que ofrece una manera elegante y concisa de garantizar que los objetos estén sincronizados entre varios subprocesos. La clase asume que tiene una interfaz de lectura y una interfaz de escritura en su tipo. Otra forma de usarla es repetir la propia clase de plantilla tres veces si, por algún motivo, no se puede cambiar la implementación de la clase subyacente con la que necesita sincronizar el acceso. Un uso básico podría ser similar a lo que se muestra en la figura 2.

Figura 2 Uso de la clase Synchronizer

interface IReadFromShared
{
  string GetValue();
}

interface IWriteToShared
{
  void SetValue(string value);
}

class MySharedClass : IReadFromShared, IWriteToShared
{
  string _foo;

  public string GetValue()
  {
    return _foo;
  }

  public void SetValue(string value)
  {
    _foo = value;
  }
}

void Foo(Synchronizer<MySharedClass, IReadFromShared, IWriteToShared> sync)
{
  sync.Write(x => {
    x.SetValue("new value");
  });
  sync.Read(x => {
    Console.WriteLine(x.GetValue());
  })
}

En el código de la figura 2, independientemente de cuántos subprocesos se estén ejecutando para el método Foo, no se invocará ningún método de escritura mientras se ejecute otro método de lectura o escritura. Sin embargo, se pueden invocar varios métodos de lectura a la vez sin tener que dispersar el código con varias instrucciones try/catch/finally ni que repetir el mismo código una y otra vez. Usarlo con una cadena sencilla no tiene sentido porque System.String es inmutable. Aquí uso un objeto de cadena sencillo para simplificar el ejemplo.

La idea básica es que todos los métodos que pueden modificar el estado de la instancia se deben agregar a la interfaz IWriteToShared. Al mismo tiempo, todos los métodos que solo pueden leer en su instancia se deben agregar a la interfaz IReadFromShared. Al separar sus preocupaciones de este modo en dos interfaces distintas e implementar ambas interfaces en el tipo subyacente, puede usar la clase Synchronizer para sincronizar el acceso a su instancia. De este modo, el arte de sincronizar el acceso a su código es mucho más sencillo y puede hacer la mayor parte de una manera mucho más declarativa.

Cuando se trata de programación multiproceso, el azúcar sintáctico puede suponer la diferencia entre el éxito y el fracaso. La depuración de código multiproceso suele ser muy difícil, y crear pruebas unitarias para objetos de sincronización puede ser un ejercicio de futilidad.

Si quiere, puede crear un tipo sobrecargado con un solo argumento genérico que herede de la clase Synchronizer original y pase su único argumento genérico como argumento de tipo tres veces a su clase base. De esta manera, no necesitará las interfaces de lectura o escritura, ya que, simplemente, puede usar la implementación concreta de su tipo. Sin embargo, este enfoque requiere que se ocupe manualmente de las partes que deben usar los métodos de escritura o lectura. También es un poco menos seguro,pero le permite encapsular clases que no puede cambiar en una instancia de Synchronizer.

Colecciones de lambda para sus bifurcaciones

Después de dar sus primeros pasos en la magia de las expresiones lambda (o delegados, como se denominan en C#), no es difícil imaginar que puede hacer más con ellos. Por ejemplo, un tema recurrente común de los sistemas multiproceso es tener varios subprocesos que se conectan a otros servidores para recuperar datos y devuelven los datos al autor de llamada.

El ejemplo más básico sería una aplicación que lee datos de 20 páginas web y, al acabar, devuelve el código HTML a un único subproceso que crea un tipo de resultado agregado en función del contenido de todas las páginas. A menos que cree un subproceso para cada uno de los métodos de recuperación, este código será mucho más lento de lo deseado: probablemente, el 99 por ciento de todo el tiempo de ejecución se dedicaría a esperar que se devolviera la solicitud HTTP.

Ejecutar este código en un solo subproceso es ineficiente, y la sintaxis para crear un subproceso es compleja. El reto se hace aún más difícil si admite varios subprocesos y sus objetos de operador, lo que obliga a los desarrolladores a repetirse a medida que escriben el código. Cuando se da cuenta de que puede crear una colección de delegados y una clase para encapsularlos, también puede crear todos los subprocesos con la invocación de un único método. De este modo, crear subprocesos es mucho más sencillo.

En la figura 3 encontrará un fragmento de código que crea dos expresiones lambda que se ejecutan en paralelo. Tenga en cuenta que este código pertenece, en realidad, a las pruebas unitarias de mi primera versión del lenguaje de scripting Lizzie, que puede encontrar en bit.ly/2FfH5y8.

Figura 3 Creación de expresiones lambda

public void ExecuteParallel_1()
{
  var sync = new Synchronizer<string, string, string>("initial_");

  var actions = new Actions();
  actions.Add(() => sync.Assign((res) => res + "foo"));
  actions.Add(() => sync.Assign((res) => res + "bar"));

  actions.ExecuteParallel();

  string result = null;
  sync.Read(delegate (string val) { result = val; });
  Assert.AreEqual(true, "initial_foobar" == result || result == "initial_barfoo");
}

Si observa detenidamente este código, observará que, en el resultado de la evaluación, no se asume que se está ejecutando una de mis expresiones lambda antes que otra. No se especifica explícitamente el orden de ejecución y estas expresiones lambda se ejecutan en subprocesos independientes. Esto es porque la clase Actions de la figura 3 le permite agregar delegados de modo que, más adelante, puede decidir si desea ejecutarlos en paralelo o secuencialmente.

Para hacerlo, debe crear un montón de expresiones lambda y ejecutarlas mediante el mecanismo que prefiera. Puede ver la clase Synchronizer mencionada anteriormente en la figura 3, sincronizando el acceso al recurso de cadena compartido. Sin embargo, usa un nuevo método en Synchronizer, denominado Assign, que no incluí en el listado de la figura 1 para mi clase Synchronizer. El método Assign utiliza los mismos "trucos de lambda” que he descrito antes en los métodos de lectura y escritura.

Si quiere estudiar la implementación de la clase Actions, tenga en cuenta que es importante descargar la versión 0.1 de Lizzie, ya que reescribí el código completamente para que se convirtiera en un lenguaje de programación independiente en versiones posteriores.

Programación funcional en C#

La mayoría de los desarrolladores tienden a pensar en C# como casi sinónimo de la programación orientada a objetos o, al menos, como un lenguaje estrechamente relacionado con esta. Y, obviamente, así es. Sin embargo, al replantearse cómo usa C# y profundizar en sus aspectos funcionales, resolver los problemas resulta mucho más fácil. La OOP, en su forma actual, no facilita la reutilización y, uno de los motivos principales, es que está fuertemente tipada.

Por ejemplo, volver a usar una sola clase obliga a volver a usar cada clase a la que hace referencia la clase inicial: tanto las usadas durante la composición como mediante la herencia. Además, la reutilización de la clase obliga a reutilizar todas las clases a las que hacen referencia estas clases de terceros, y así sucesivamente. Y, si estas clases se implementan en ensamblados diferentes, debe incluir toda una gama de ensamblados, simplemente, para tener acceso a un único método en un solo tipo.

Una vez leí una analogía que ilustra este problema: "Quiere un plátano, pero acaba con un gorila que tiene un plátano y la selva donde vive el gorila". Compare esta situación con la reutilización en un lenguaje más dinámico, como JavaScript, que no tiene en cuenta el tipo siempre que implemente las funciones que sus propias funciones usan. Un enfoque más débilmente tipado produce código más flexible y más fácil de reutilizar. Los delegados le permiten hacerlo.

Puede trabajar con C# de manera que mejore la reutilización del código en varios proyectos. Solo debe tener en cuenta que una función o delegado también puede ser un objeto y que puede manipular colecciones de estos objetos de manera débilmente tipada.

Las ideas en torno a los delegados presentes en este artículo se basan en las de un artículo anterior que escribí en el número de noviembre de 2018 de MSDN Magazine: .NET: crear un lenguaje de script propio con delegados simbólicos (msdn.com/magazine/mt830373). En este artículo también presenté Lizzie, mi propio lenguaje de scripting que debe su existencia a esta mentalidad centrada en delegados. Si hubiera creado Lizzie mediante reglas de OOP, mi opinión es que, probablemente, su tamaño doblaría al actual.

Por supuesto, actualmente, la OOP y las características fuertemente tipadas tienen una posición tan dominante que es casi imposible encontrar una descripción de trabajo que no las mencione como principal aptitud requerida. Debo decir que he creado código de OOP durante más de 25 años, así que soy tan culpable como cualquiera del enfoque fuertemente tipado. Sin embargo, hoy en día, soy más pragmático en mi enfoque hacia la programación y me interesa menos el aspecto de mi jerarquía de clases.

No es que no sepa apreciar una jerarquía de clases bonita, pero conlleva una serie de desventajas. Cuantas más clases agrega a una jerarquía, menos elegante es, hasta que se contrae por su propio peso. A veces, el diseño de nivel superior tiene pocos métodos, menos clases y funciones de acoplamiento más flexibles, lo que permite que el código se extienda fácilmente sin tener que "traer el gorila y la selva".

Vuelvo al tema recurrente de este artículo, inspirado por el enfoque de Miles Davis hacia la música, donde menos es más y "el silencio es más importante que el sonido". El código también es así. A menudo, la magia se encuentra entre líneas y la mejor solución se mide más en términos de lo que no programa que de lo que programa. Cualquier idiota puede soplar en una trompeta y hacer ruido, pero pocos pueden crear música con ella. Y aún menos pueden hacer magia como la de Miles.


Thomas Hansen trabaja en el sector de tecnología financiera, y compra y venta de divisas como desarrollador de software y vive en Chipre.


Comente este artículo en el foro de MSDN Magazine