Aplicaciones Multihilo en Delphi: Metodos de sincronización de Delphi

Introducción

Probablemente la parte más compleja al programar una aplicación con varios hilos es la sincronización entre estos tanto en el acceso a los datos compartidos como en el correcto orden de ejecución que deben seguir.

Uno de los problemas de desarrollar una aplicación multihilo es que son bastante dificiles de depurar puesto que los hilos van entrando y saliendo de ejecución conforme se va acabando su tiempo de ejecución de forma que, al depurar, el depurador va saltando de una linea de código a otra dependiendo de la tarea que vaya estando en la CPU así que siempre hay que tener mucho cuidado al escribir el código por que, si vas a tener que depurarlo, vas a pasarte un buen rato en ello (por no hablar del hecho de que los hilos no tienen porqué, y probablemente no lo haga, ejecutar en el mismo orden y con los mismos tiempos en dos ejecuciones consecutivas).

En esta parte trataremos los objetos de sincronismo más importante que proporciona Delphi mientras que en el siguiente nos centraremos en las primitivas de sincronización que proporciona la API de windows.

Secciones críticas

Una sección crítica representa una sección de código que tan solo puede ser ejecutada por una tarea a la vez, a todos los efectos es como declarar toda una sección de código como atómica.

Delphi provee de una clase específica para declarar una sección crítica, la clase TCriticalSection ubicada en la unidad SyncObjs. Para entrar en la sección crítica establecemos una llamada al metodo enter (o aquire) y para salir de la sección crítica utilizaremos una llamada al metodo leave.

procedure ProcedimientoCompartido;
var
  readed : integer:
  data : string;
begin
  // Obtener los datos del archivo que está leyendo la tarea
  readed := GetDataFromFile('MyFile',data);
  // Entrar en la sección crítica
  GlobalCritical.Enter;
  // Incrementar el numero de datos leidos y copiar. Esto es atómico
  totalDataReaded := totalDataReaded + readed;
  globalData := dataStream + data;
  // Salir de la sección crítica
  GlobalCritical.Leave;
end;

Ventajas e inconvenientes

La principal ventaja de una sección crítica es que es relativamente fácil de implementar, tan solo es necesario declarar la clase, crearla y "blindar" aquellas secciones de código que sean conflictivas de forma que podemos evitar la llamada al metodo syncronize (que bloquea la ejecución de la tarea hasta que el hilo principal puede parar para ejecutarla) y es mucho más lento.

La principal desventaja es que es un metodo relativamente tosco, provee de exclusión mutua en la ejecución de cierta sección de código de forma que podemos evitar que dos tareas entren a la vez pero no tenemos ningún control sobre que tareas necesitan hacer exclusión mútua y cuales no, por otro lado debemos crear una sección crítica para cada región de código que debamos proteger (la mayor parte de los objetos predefinidos de delphi tienen esta limitación pero ya veremos un metodo para implementar algo más flexible).

Varios lectores y un solo escritor

En determinadas ocasiones solo necesitamos proteger una determinada región de datos ante su acceso desde varias tareas de forma que varios hilos puedan leer un determinado valor siempre y cuando no haya nadie escribiendo en él (puesto que como ya vimos dicha operación puede ser conflictiva) pero un escritor deberá tener un acceso exclusivo.

El siguiente fragmento de código representa una función de asignación de un puntero a un objeto dentro de una clase predefinida item (que por ejemplo puede almacenar varios tipos de valores, entre ellos punteros a objeto).

function TItem.GetObj : TObject;
begin
  result := TObject(Self.Ptr);
end;

procedure TItem.UpdateObject(newObject : TObject);
var
  oldObject : TObject;
begin
  self.Type = itm_object;
  oldObject := TObject(self.Ptr);
  if Assigned(oldObject) then
    FreeAndNil(oldObject);
  self.Ptr := newObject;
end;

Podemos observar que, de acceder a la función varios hilos simultaneamente puede producirse un conflicto si se produce un cambio de contexto justo después del FreeAndNil pero antes de la asignación de self.Ptr, Ptr estará apuntando a una dirección de memoria invalida y si otra tarea accede en ese momento a la función GetObj obtendrá una dirección erronea. Esta claro que el miembro Ptr de la clase es crítico y debe ser protegido.

Envolver las zonas críticas mediante una sección crítica sería una opción.

function TItem.GetObj : TObject;
begin
  critical.Enter;
  result := TObject(Self.Ptr);
  critical.Leave;
end;

procedure TItem.UpdateObject(newObject : TObject);
var
  oldObject : TObject;
begin
  self.Type = itm_object;
  oldObject := TObject(self.Ptr);
  critical.Enter;
  if Assigned(oldObject) then
    FreeAndNil(oldObject);
  self.Ptr := newObject;
  critical.Leave;
end;

Aunque esta solución es válida presenta un problema si (como suele ser normal) el numero de accesos de lectura es muy superior al numero de accesos de escritura. Cuando dos tareas quieren obtener el valor del objeto al que apunta Ptr deben "pelear" por entrar en la sección crítica aunque, mientras nadie intente escribir en la variable no existe ningún problema por que dos hilos obtengan el valor de la variable.

Para prevenir este problema existe otro objeto de sincronización en Delphi que permite algo más de control sobre lo que vamos a hacer dentro del area protegida. El TMultiReadExclusiveWriteSynchronizer. Los cuatro metodos que nos interesan del objeto son BeginRead, EndRead, BeginWrite, EndWrite, que, como su nombre indica, nos permiten indicar el inicio y final de una operación de lectura o escritura respectivamente.

function TItem.GetObj : TObject;
begin
  MReaderOWriter.BeginRead;
  result := TObject(Self.Ptr);
  MReaderOWriter.EndRead;
end;

procedure TItem.UpdateObject(newObject : TObject);
var
  oldObject : TObject;
begin
  self.Type = itm_object;
  oldObject := TObject(self.Ptr);
  MReaderOWriter.BeginWrite
  if Assigned(oldObject) then
    FreeAndNil(oldObject);
  self.Ptr := newObject;
  MReaderOWrite.EndWrite;
end;

De esta forma cuando un hilo quiera escribir el puntero esperará a que todos los demás hilos terminen de leer y comenzará a escribir y durante el tiempo que este escribiendo ningún otro hilo podrá entrar ni para escribir ni para leer mientras que si varios hilos quieren leer el valor del puntero podrán hacerlo de forma simultanea.

Eventos

Cuando hablamos de eventos en Delphi podemos cometer el error de identificar esos eventos con las acciones asociadas a el clickeo de un botón por parte del usuario o la introducción de texto en un edit box, fundamentalmente por que la terminología normal es llamar a las funciones que manejan dichos "eventos" se llaman manejadores de evento y por que además Delphi define algunos delegados con nombres que llevan al error (el tipo TNotifyEvent a pesar de su nombre no es un evento).

Cuando hablamos de eventos en el contexto multihilo no estamos hablando de esto. Internamente Delphi tiene un manejador de mensajes que redirige los mensajes de windows (tales como el WM_MOUSEDOWN correspondiente a un click del mouse) a los controles adecuados y, en su caso, invoca los manejadores asociados a dichos mensajes pero esto es un concepto totalmente diferente del que vamos a tratar.

Eventos y tareas

Los metodos que hemos visto hasta el momento constituyen una forma de proteger los datos compartidos mientras que los eventos constituyen un metodo de sincronización.

Tal y como su nombre indica los eventos nos permiten hacer que un hilo de ejecución se quede a la espera de que ocurra un determinado evento que active su ejecución, por ejemplo si tenemos una tarea que se encarga de procesar la información que llega de un socket el evento asociado sería la llegada de información por ese socket o quizá nuestro hilo este esperando a que otro hilo termine su ejecución (o que llegue a un determinado punto de su ejecución) tras lo cual se producirá el evento por el que estará esperando nuestro hilo.

Por tanto podemos decir que existe una diferenciación fundamental a la hora de tratar con eventos, por un lado esta quien invoca el evento y por otro lado esta el hilo o los hilos que están esperando a que se produzca dicho evento.

El objeto que nos interesa para implementar nuestros eventos es la clase TEvent que es en realidad un envoltorio que proporciona Delphi para el objeto event de windows. De la clase TEvent nos interesan fundamentalmente:

  • La propiedad Handle Contiene el Handle al objeto event de windows (lo cual nos será de utilidad si queremos usar algunas de las funciones que proporciona windows para el manejado de objetos event que no encapsula delphi automáticamente).
  • El metodo SetEvent que 'dispara' el evento asociado, es decir, señala la activación del evento.
  • El metodo ResetEvent que 'anula' el evento, es decir, señala que el evento ya no está activo.
  • El metodo WaitFor que bloquea al llamante hasta que el evento se activa (a no ser claro que ya estuviera activado).
  • El constructor de la clase El constructor de la clase acepta cuatro parametros más uno opcional.

constructor Create(EventAttributes: PSecurityAttributes; ManualReset: Boolean; InitialState: Boolean; const Name: string; UseCOMWait: Boolean);

  • El primer parametro es un puntero a una estructura de tipo SecurityAttributes (para definir los derechos de acceso al TEvent) que dejaremos a nil (lo cual indica valores por defecto).
  • El segundo parametro es un booleano que indicará si el evento se reseteará (anulará) de forma manual (mediante una llamada a ResetEvent cuando deseemos anular el evento) o de forma automática cada vez que alguien 'responda' al evento, es decir, cuando alguien que estuviera esperando a que el evento se produjera comience a ejecutar.
  • El tercer parametro indica el estado inicial del evento (activo o inactivo)
  • El cuarto parametro es el nombre del evento. Los eventos son globales al sistema, esto es, pueden ser accedidos desde cualquier proceso si se sabe su nombre (y si se tienen los permisos adecuados), en general, si el evento tenemos pensado que se use tan solo en nuestra aplicación dejaremos este parametro como ''.
  • El último parametro (que si no me equivoco se incluyo a partir del Delphi 9) es opcional e indica el tipo de espera que se realizará al llamar a la función WaitFor (hay dos funciones en windows que realizan espera de eventos, WaitForSingleObject y WaitForMultipleObjects, esta última no soportada en Win95 que se diferencian en el procesado de mensajes por parte de la aplicación). Si no tenemos previsto que se use la aplicación en windows 95 y estamos utilizando Delphi 9 o superior lo mejor es poner este parametro a true.

Usando eventos

Uno de los casos más habituales en los que es recomendable el uso de eventos es en los programas en los que existe un productor y un consumidor, de forma que el evento que el consumidor espera es la presencia de datos que consumir y el agente que activa el evento es el productor cada vez que proporciona un dato.

El evento siempre se utiliza de la misma forma, mediante llamadas a los metodos citados anteriormente, sin embargo hay varias formas de 'presentarlo'.

Una opción es ocultar la existencia del evento de forma que cualquier visor externo solo perciba que se trata de una llamada bloqueante, es decir, una llamada que puede suponer el bloqueo del hilo llamante.

interface
type TProductor = class(TThread)
  private
    FInternalData : TStringList;
    FEvent : TEvent;
  public
    constructor Create(createSuspended : boolean);
    { Lee una cadena del productor, la llamada no vuelve hasta
       que haya un dato en el productor o se consuma el tiempo
       de espera (si se ha especificado alguno)
       @param( timeout El tiempo máximo de espera en milisegundos)
    }

    function LeerCadena(timeout : integer) : string;
    { Va leyendo los datos y volcandolos }
    procedure Execute; override;
end;

implementation

constructor TProductor.Create(createSuspended : boolean);
begin
  // Aqui se guardarán los datos
  internalData := TStringList.Create;
  // Crear el evento, seguridad por defecto, reseteo automático
  Event := TEvent.Create(nil,false,false,'');
end;

function TProductor.LeerCadena(timeout: integer) : string;
var
  res : integer;
begin
  if timeout = 0 then
    res := Event.WaitFor(INFINITE)
  else
    res := Event.WaitFor(timeout);

  if res = wrSignaled then
  begin
    result := internalData[0];
    internalData.Delete(0);
    if internalData.Count >; 0 then
      Event.SetEvent; // Quedan datos, relanzamos el evento
  end
  else if res = wrTimeout then
    result := 'timeout'
  else
    result := 'error';
end;

procedure TProductor.Execute;
begin
  while ot Self.Terminated do
  begin
     ReadDataFromFile;
     FEvent.SetEvent;
     sleep(1000); // Dormir un segundo
  end;
end;

Nota: La estructura internalData debería estar protegida, no he puesto la protección para ahorrar espacio pero debería haber alguno de los sistemas de protección anteriores para prevenir la posibilidad de una escritura y lectura simulatanas.

La otra opción sería dar acceso directamente al consumidor al objeto TEvent de forma que antes de realizar una llamada a la función de lectura el consumidor espera la activación del evento activamente. Esta opción permite un control más directo de lo que vamos a hacer y resulta indispensable si deseamos realizar acciones más complejas que un simple WaitFor de un evento (como por ejemplo esperar por varios eventos en vez de uno).

interface
private
    FInternalData : TStringList;
    FEvent : TEvent;
  public
    { Lee una cadena del productor, la llamada no vuelve hasta
       que haya un dato en el productor o se consuma el tiempo
       de espera (si se ha especificado alguno)
       @param( timeout El tiempo máximo de espera en milisegundos)
    }

    function LeerCadena : string;

    property Event : TEvent read FEvent;
implementation

function TProductor.LeerCadena : string;
begin
  if FInternalData.Count >; 0 then
     result := FInternalData[0]
  else
     raise Exception.Create('Intento de leer sin esperar el evento');
end;

Evidentemente este segundo metodo es más 'arriesgado' por llamarlo de alguna forma, es decir, no se garantiza que un hilo haga un WaitFor antes de realizar la lectura (de ahí la excepción). Sin embargo proporciona más control al dar acceso al objeto TEvent.

10
Average: 10 (3 votes)
Your rating: None