Friday, April 17, 2020

Producer-Consumer Determinism


The Producer-Consumer pattern is a common pattern used in concurrent programming. In this model one task writes values to a fixed length buffer and another task reads values from the fixed length buffer.
There are many variations on the Producer-Consumer pattern, mostly dealing with various numbers of producers and various numbers of consumers.
This article will use a simple Producer-Consumer pattern with a single producer and a single consumer. 

Determinism

The Producer-Consumer pattern has some simple limitations. The Consumer task cannot read data from an empty buffer and the Producer task cannot write data to a full buffer. These limitations might lead the programmer to assume that the Producer and Consumer will spend their time alternately reading and writing the buffer. First the Producer will write a value then the Consumer will read a value. While the general idea is correct, the actual viewed results may be a bit confusing.

Non-Deterministic Example

with Ada.Text_IO; use Ada.Text_IO;

procedure Main is
   Max_Iter : constant Positive := 40;
   Buf_Size : constant          := 6;
   subtype Index is Integer range 0 .. Buf_Size - 1;
   type Buf_Array is array (Index) of Integer;

   protected Buffer is
      entry Write (Item : in Integer);
      entry Read (Item : out Integer);
   private
      Buf       : Buf_Array;
      Read_Idx  : Index   := 0;
      Write_Idx : Index   := 0;
      Count     : Natural := 0;
   end Buffer;

   protected body Buffer is
      entry Write (Item : in Integer) when Count < Buf_Size is
      begin
         Buf (Write_Idx) := Item;
         Write_Idx       := (Write_Idx + 1) mod Buf_Size;
         Count           := Count + 1;
         Put_Line ("Producer wrote" & Item'Image);
      end Write;

      entry Read (Item : out Integer) when Count > 0 is
      begin
         Item     := Buf (Read_Idx);
         Read_Idx := (Read_Idx + 1) mod Buf_Size;
         Count    := Count - 1;
         Put_Line ("Consumer read" & Item'Image);
      end Read;
   end Buffer;

   task Producer;
   task Consumer;

   task body Producer is
   begin
      for I in 1 .. Max_Iter loop
         Buffer.Write (I);
      end loop;
      Put_Line ("       Producer finished");
   end Producer;

   task body Consumer is
      Num : Integer;
   begin
      for I in 1 .. Max_Iter loop
         Buffer.Read (Num);
      end loop;
      Put_Line ("       Consumer finished");
   end Consumer;
begin
   null;
end Main;

This example provides a shared buffer of 6 data elements. The Producer writes to the buffer when it is not full and the Consumer reads from the buffer when it is not empty. A typical execution of this program results in the following output:

Producer wrote 1
Consumer read 1
Producer wrote 2
Consumer read 2
Producer wrote 3
Consumer read 3
Producer wrote 4
Producer wrote 5
Producer wrote 6
Producer wrote 7
Producer wrote 8
Producer wrote 9
Consumer read 4
Producer wrote 10
Consumer read 5
Consumer read 6
Consumer read 7
Consumer read 8
Consumer read 9
Consumer read 10
Producer wrote 11
Consumer read 11
Producer wrote 12
Producer wrote 13
Producer wrote 14
Producer wrote 15
Producer wrote 16
Producer wrote 17
Consumer read 12
Producer wrote 18
Consumer read 13
Consumer read 14
Consumer read 15
Consumer read 16
Consumer read 17
Consumer read 18
Producer wrote 19
Consumer read 19
Producer wrote 20
Consumer read 20
Producer wrote 21
Consumer read 21
Producer wrote 22
Consumer read 22
Producer wrote 23
Producer wrote 24
Producer wrote 25
Producer wrote 26
Producer wrote 27
Producer wrote 28
Consumer read 23
Producer wrote 29
Consumer read 24
Consumer read 25
Consumer read 26
Consumer read 27
Consumer read 28
Consumer read 29
Producer wrote 30
Consumer read 30
Producer wrote 31
Producer wrote 32
Producer wrote 33
Producer wrote 34
Producer wrote 35
Producer wrote 36
Consumer read 31
Producer wrote 37
Consumer read 32
Consumer read 33
Consumer read 34
Consumer read 35
Consumer read 36
Consumer read 37
Producer wrote 38
Consumer read 38
Producer wrote 39
Producer wrote 40
       Producer finished
Consumer read 39
Consumer read 40
       Consumer finished

As you can see, while the Producer and Consumer sometimes alternate in their execution, there are also periods during which either the Producer or the Consumer dominates, creating a series of actions from one task or the other. In other words, the execution of the two tasks is not deterministic. The non-deterministic nature shown is due to the operating system choosing when to schedule each task.

Deterministic Examples

How can the programmer create deterministic behavior given the whims of the operating system?
One way is to reduce the buffer size to 1 element.

with Ada.Text_IO; use Ada.Text_IO;

procedure Main is
   Max_Iter : constant Positive := 40;
   Buf_Size : constant          := 1;
   subtype Index is Integer range 0 .. Buf_Size - 1;
   type Buf_Array is array (Index) of Integer;

   protected Buffer is
      entry Write (Item : in Integer);
      entry Read (Item : out Integer);
   private
      Buf       : Buf_Array;
      Read_Idx  : Index   := 0;
      Write_Idx : Index   := 0;
      Count     : Natural := 0;
   end Buffer;

   protected body Buffer is
      entry Write (Item : in Integer) when Count < Buf_Size is
      begin
         Buf (Write_Idx) := Item;
         Write_Idx       := (Write_Idx + 1) mod Buf_Size;
         Count           := Count + 1;
         Put_Line ("Producer wrote" & Item'Image);
      end Write;

      entry Read (Item : out Integer) when Count > 0 is
      begin
         Item     := Buf (Read_Idx);
         Read_Idx := (Read_Idx + 1) mod Buf_Size;
         Count    := Count - 1;
         Put_Line ("Consumer read" & Item'Image);
      end Read;
   end Buffer;

   task Producer;
   task Consumer;

   task body Producer is
   begin
      for I in 1 .. Max_Iter loop
         Buffer.Write (I);
      end loop;
      Put_Line ("       Producer finished");
   end Producer;

   task body Consumer is
      Num : Integer;
   begin
      for I in 1 .. Max_Iter loop
         Buffer.Read (Num);
      end loop;
      Put_Line ("       Consumer finished");
   end Consumer;
begin
   null;
end Main;

Now the entry guard conditions on the Protected Object named Buffer force the Producer and Consumer tasks to take turns in a deterministic manner.

Producer wrote 1
Consumer read 1
Producer wrote 2
Consumer read 2
Producer wrote 3
Consumer read 3
Producer wrote 4
Consumer read 4
Producer wrote 5
Consumer read 5
Producer wrote 6
Consumer read 6
Producer wrote 7
Consumer read 7
Producer wrote 8
Consumer read 8
Producer wrote 9
Consumer read 9
Producer wrote 10
Consumer read 10
Producer wrote 11
Consumer read 11
Producer wrote 12
Consumer read 12
Producer wrote 13
Consumer read 13
Producer wrote 14
Consumer read 14
Producer wrote 15
Consumer read 15
Producer wrote 16
Consumer read 16
Producer wrote 17
Consumer read 17
Producer wrote 18
Consumer read 18
Producer wrote 19
Consumer read 19
Producer wrote 20
Consumer read 20
Producer wrote 21
Consumer read 21
Producer wrote 22
Consumer read 22
Producer wrote 23
Consumer read 23
Producer wrote 24
Consumer read 24
Producer wrote 25
Consumer read 25
Producer wrote 26
Consumer read 26
Producer wrote 27
Consumer read 27
Producer wrote 28
Consumer read 28
Producer wrote 29
Consumer read 29
Producer wrote 30
Consumer read 30
Producer wrote 31
Consumer read 31
Producer wrote 32
Consumer read 32
Producer wrote 33
Consumer read 33
Producer wrote 34
Consumer read 34
Producer wrote 35
Consumer read 35
Producer wrote 36
Consumer read 36
Producer wrote 37
Consumer read 37
Producer wrote 38
Consumer read 38
Producer wrote 39
Consumer read 39
Producer wrote 40
       Producer finished
Consumer read 40
       Consumer finished

Using the Ada programming language this seems to be task coordination done the hard way. The easy way to achieve this same behavior is to use the Ada Rendezvous facility to communicate between the Producer and the Consumer.
The Rendezvous facility provides very direct synchronization between tasks. The task calling a task entry must suspend until the called task handles the task entry. The called task must suspend at the point of handling an entry until another task calls the entry.
The same Producer-Consumer problem implemented using a task entry is shown below.

with Ada.Text_IO; use Ada.Text_IO;

procedure PC_Rendezvous is
   Max_Iter : constant := 40;

   task Producer;
   task Consumer is
      entry Write (Item : in Integer);
   end Consumer;

   task body Producer is
   begin
      for I in 1 .. Max_Iter loop
         Put_Line ("Producer writing" & I'Image);
         Consumer.Write (I);
      end loop;
      Put_Line("      Producer finished");
   end Producer;

   task body Consumer is
      Num : Integer;
   begin
      for I in 1 .. Max_Iter loop
         accept Write (Item : in Integer) do
            Num := Item;
         end Write;
         Put_Line ("Consumer read" & Num'Image);
      end loop;
      Put_Line("      Consumer finished");
   end Consumer;
begin
   null;
end PC_Rendezvous;

The output of this program is:

Producer writing 1
Consumer read 1
Producer writing 2
Consumer read 2
Producer writing 3
Consumer read 3
Producer writing 4
Consumer read 4
Producer writing 5
Consumer read 5
Producer writing 6
Consumer read 6
Producer writing 7
Consumer read 7
Producer writing 8
Consumer read 8
Producer writing 9
Consumer read 9
Producer writing 10
Consumer read 10
Producer writing 11
Consumer read 11
Producer writing 12
Consumer read 12
Producer writing 13
Consumer read 13
Producer writing 14
Consumer read 14
Producer writing 15
Consumer read 15
Producer writing 16
Consumer read 16
Producer writing 17
Consumer read 17
Producer writing 18
Consumer read 18
Producer writing 19
Consumer read 19
Producer writing 20
Consumer read 20
Producer writing 21
Consumer read 21
Producer writing 22
Consumer read 22
Producer writing 23
Consumer read 23
Producer writing 24
Consumer read 24
Producer writing 25
Consumer read 25
Producer writing 26
Consumer read 26
Producer writing 27
Consumer read 27
Producer writing 28
Consumer read 28
Producer writing 29
Consumer read 29
Producer writing 30
Consumer read 30
Producer writing 31
Consumer read 31
Producer writing 32
Consumer read 32
Producer writing 33
Consumer read 33
Producer writing 34
Consumer read 34
Producer writing 35
Consumer read 35
Producer writing 36
Consumer read 36
Producer writing 37
Consumer read 37
Producer writing 38
Consumer read 38
Producer writing 39
Consumer read 39
Producer writing 40
Consumer read 40
      Consumer finished
      Producer finished

Task deterministic behavior is achieve by forcing synchronization between the two tasks.

No comments:

Post a Comment

Comparison of three algorithms for summing an array of integers

This article shares a comparison of the time taken to sum an array of integers. This article tests three algorithms for summing the array. •...