LEZIONE del 27 Novembre

Uno degli aspetti che hanno caratterizzato il C come ambiente di sviluppo ed implementazione dei sistemi, e che oggi si ritrova in molti altri linguaggi, è la facilità con cui è possibile strutturare i programmi su diversi file. Infatti, al contrario di alcuni schemi di programmazione che spingono a costruire programmi monolitici, nel C un programma C è tipicamente una collezione di funzioni.

Ad esse si aggiungono le necessarie definizioni di tipo e dichiarazioni di variabili le quali vengono distribuiti su un certo numero di file con estensioni .h e .c . I primi si dicono anche header file e contengono definizioni mentre i secondi sono costituiti da collezioni di funzioni. Si rammenta che fra queste deve comparire necessariamente il programma principale, che prende il nome di main.

Esempio di Programma MultiThread

Nell'esempio che segue si evince facilmente il caratteristico paradigma con con cui sono realizzati i programmi in C. La collezione di file descritta nel seguito comprende anche il file makefile il cui scopo è quello di contenere l'insieme di direttive ed informazioni supplementari necessarie alla generazione del codice eseguibile partendo dalla compilazione del codice sorgente contenuto nei singoli file.

L'insieme dei file che costituiscono il programma in questione è raccolto in un pacchetto tarball sorgente in formato di archivio compresso. Per ottenere da questo la directory contenente tutti i file sorgenti, è necessario eseguire nella bash shell il comando
   
tar zxvf thread.tar.gz
dopodichè nella directory corrente comparirà una nuova directory con il nome thread contenente tutti i file in questione.

La collezione di file riportati nel seguito rappresenta un programma per la generazione di due thread che si sincronizzano per trasmettere dati attraverso un buffer secondo lo schema produttore consumatore.

Allo scopo di chiarificare il meglio possibile la sua realizzazione, la sua implementazione è ottenuta secondo lo schema della programmazione per oggetti, in cui alcuni file contengono definizioni di tipo e di funzioni per manipolare specifiche strutture dati.

Nel caso in esame sono stati realizzati le strutture dati buffer, mailbox e kernel che rappresentano, rispettivamente, la coda circolare in cui inserire effettivamente i dati, il canale usato dai processi per comunicare e le procedure per gestire sia i thread che la loro sincronizzazione.

BUFFER CIRCOLARE

I due file riportati di seguito implementano la coda circolare di messaggi utilizzata per immagazzinare i dati scambiati dai due processi. Il primo file, denominato buffer.h contiene le necessarie definizioni.

/*
 *  file buffer.h
 */


#define BUFFSIZE 4

typedef struct {
        char cmd;
	int xval;
} Message;

typedef struct {
        int lf ;
	int ld ;
	Message dd[BUFFSIZE] ;

} Buffer ;

extern void initbuffer(Buffer *Bu);
extern void putmsg(Buffer *Bu, Message Msg);
extern void getmsg(Buffer *Bu, Message *Msg);

Il secondo, con il nome di buffer.c contiene il codice specifico che implementa le funzioni che manipolano la coda circolare.

/*
 *  file buffer.c
 */

#include "buffer.h"


void initbuffer(Buffer *Bu)
{
  Bu->lf = 0;
  Bu->ld = 0;
}


void putmsg(Buffer *Bu, Message Msg)
{
  Bu->dd[Bu->ld] = Msg;
  Bu->ld++;
  if (Bu->ld == BUFFSIZE) Bu->ld = 0;
}


void getmsg(Buffer *Bu, Message *Msg)
{
  *Msg = Bu->dd[Bu->lf];
  Bu->lf++;
  if (Bu->lf == BUFFSIZE) Bu->lf = 0;
}

Si noti come il primo debba essere necessariamente incluso nel secondo per poter guidare il compilatore nella corretta generazione del codice oggetto delle procedure.

CANALE DI COMUNICAZIONE

La sincronizzazione dei messaggi inseriti e depositati nel buffer viene ottenuta da due semafori che hanno l'effetto, se necessario, di bloccare il processo produttore quando il buffer è pieno oppure di sospendere il processo consumatore se il buffer risulta vuoto.

L'implementazione di tale meccanismo, che può vedersi anche come un canale di comunicazione fra processi, è ottenuto dai due file riportati di seguito. Il primo, denominato mailbox.h contiene le necessarie definizioni.

/*
 *  file mailbox.h
 */


#include "buffer.h"
#include "kernel.h"

typedef struct {
        semaphore notFull ;
	semaphore notEmpty ;
	Buffer Bu ;

} Channel ;

extern void newChannel(Channel *Chn);
extern void Send(Channel *Chn, Message Msg);
extern void Recv(Channel *Chn, Message *Msg);

Il secondo, avente il nome di mailbox.c contiene il codice specifico che implementa le funzioni che gestiscono il canale.

/*
 *  file mailbox.c
 */


#include "mailbox.h"


void newChannel(Channel *Chn)
{
  Assign(&Chn->notFull, BUFFSIZE);
  Assign(&Chn->notEmpty, 0);
  initbuffer(&Chn->Bu);
}


void Send(Channel *Chn, Message Msg)
{
  Suspend(&Chn->notFull);
  putmsg(&Chn->Bu, Msg);
  Awake(&Chn->notEmpty);
}


void Recv(Channel *Chn, Message *Msg)
{
  Suspend(&Chn->notEmpty);
  getmsg(&Chn->Bu, Msg);
  Awake(&Chn->notFull);
}

Si noti come il primo debba essere necessariamente incluso nel secondo per poter guidare il compilatore nella corretta generazione del codice oggetto delle procedure.

THREAD E SINCRONIZZAZIONE

La gestione dei thread e la relativa sincronizzazione è ottenuta con la seguente coppia di file. Il primo, kernel.h, contiene le necessarie definizioni le quali dipendono, in generale, dal tipo di piattaforma utilizzata. Ciò si ottiene scrivendo codice C per la compilazione condizionata, resa possibile dal C PreProcessor

/*
 *  file kernel.h
 */

#include   <stdio.h>
#include   <stdlib.h>

typedef enum {once, next} Mode;
typedef unsigned int uint;
typedef union {
  int score;
  struct {
    uint over : 11;
    uint gate : 12;
    uint moan : 9;
  } index;
} Masque;

/*-------------------------------------------------------------------
|                   definizioni per Unix Solaris                    |
-------------------------------------------------------------------*/
#ifdef Solaris
#include   <thread.h>
#include   <synch.h>

typedef    thread_t       process ;
typedef    sema_t         semaphore ;
#endif

/*-------------------------------------------------------------------
|                      definizioni per Linux                        |
-------------------------------------------------------------------*/
#ifdef Linux
#include   <pthread.h>
#include   <semaphore.h>

typedef    pthread_t      process ;
typedef    sem_t          semaphore ;
#endif


#define Procedure(X) void *(*X)(void *)

typedef    long int       integer ;
typedef    unsigned int   value ;


extern void NewThread(Procedure(proc), integer StackSize, process *TID);
extern int JoinThread(process TID, process Target);
extern int Thread(process *TID);
extern int Gettid(Mode fx, process *Pid);
extern int pError(process T);

extern void Assign(semaphore *s, value k);
extern void Suspend(semaphore *s);
extern void Awake(semaphore *s);
extern int Score(semaphore *s);

Il secondo, denominato kernel.c contiene il codice specifico che implementa sia la creazione e la gestione dei processi che le funzioni per la manipolazione dei semafori.

/*
 *  file kernel.c
 */

#include "kernel.h"

/*-------------------------------------------------------------------
|                creazione e gestione dei processi                  |
-------------------------------------------------------------------*/

void NewThread(Procedure(proc), integer StackSize, process *TID) {
#ifdef Solaris
  if (thr_create(NULL, StackSize, proc, NULL, THR_NEW_LWP, TID))
    *TID = -1;
#endif
#ifdef Linux
  if (StackSize == 0) {
    if (pthread_create(TID, NULL, proc, NULL))
      *TID = -1; }
  else {
    fprintf(stderr, "thread cannot be created\n") ;
    exit(2) ;}
#endif
};


int JoinThread(process TID, process Target) {
#ifdef Solaris
   return thr_join(TID, &Target, NULL);
#endif
#ifdef Linux
   return pthread_join(TID, NULL);
#endif
};

int Thread(process *TID) {
#ifdef Solaris
  return thr_self();
#endif
#ifdef Linux
  return pthread_self();
#endif
};

int Gettid(Mode fx, process *Pid) {
  Masque mask;
  switch (fx) {
    case once: mask.score = Thread(Pid); break;
    case next: mask.score = *Pid; break; }
  return mask.index.gate;
}

int pError(process T) {
  int Rsx, Pid;
  Rsx = (T == -1);
#ifdef DEBUG
  if (Rsx) printf("Can't create thread\n");
  else
    Pid = Gettid(next, &T);
    printf("Nuovo processo %d\n", Pid);
#endif
  return Rsx;
}

/*-------------------------------------------------------------------
|                        gestione dei semafori                      |
-------------------------------------------------------------------*/

void Assign(semaphore *s, value k) {

  /* initialize the semaphore */

#ifdef Solaris
  sema_init(s, k, USYNC_THREAD, 0) ;
#endif
#ifdef Linux
  sem_init(s, 0, k) ;
#endif
} ;

void Suspend(semaphore *s) {
#ifdef Solaris
  sema_wait( s ) ;
#endif
#ifdef Linux
  sem_wait( s ) ;
#endif
} ;

void Awake(semaphore *s) {
#ifdef Solaris
  sema_post( s );
#endif
#ifdef Linux
  sem_post( s );
#endif
} ;

int Score(semaphore *s, int *sval) {
#ifdef Solaris
  sema_getvalue( s, sval );
#endif
#ifdef Linux
  sem_getvalue( s, sval );
#endif
} ;

Si noti come il primo debba essere necessariamente incluso nel secondo per poter guidare il compilatore nella corretta generazione del codice oggetto delle procedure.

PROGRAMMA PRINCIPALE

La creazione e la gestione dei thread nel caso specifico è realizzata dal programma principale al quale si affiancano le due procedure attivate come thread. Il file in questione, che prende il nome di dies.c, è riportato di seguito e contiene istruzioni per la compilazione condizionata a seconda che si voglia avere o meno un traccia dettagliata della sua esecuzione.

/*
 *  file dies.c
 */

#include    <math.h>
#include    <stdio.h>
#include    <unistd.h>
#include    "mailbox.h"

#define     FALSE       0
#define     TRUE        1

void *Server();
void *Client();

Channel Port;
Channel Reply;


main() {
  process T0, T1, T2;

#ifdef DEBUG
  pid = Gettid(once, &T0);
  printf("Main thread = %d\n", pid);
#endif

  newChannel( &Port );  	/* attivazione della porta */
  newChannel( &Reply );  	/* canale di risposta */


      /* crea due processi "leggeri" */


  NewThread( Server, 0, &T1 );
  if (pError(T1)) exit(1);

  NewThread( Client, 0, &T2 );
  if (pError(T2)) exit(1);

      /* ricongiungi i due flussi */

  if (JoinThread(T1, T2))
    printf("Joining thread error\n"), exit(2);
#ifdef DEBUG
  pid = Gettid(once, &T1);
  printf("Thread %d terminated.\n", pid);
  pid = Gettid(once, &T2);
  printf("Thread %d terminated.\n", pid);
#endif

      /* termina */

  exit(0);
}


void *Server() {
  int running;
  Message Request;
  Message Answer;
  char CH;
  int N;
  unsigned seed;
  int i, R, Rx;
  int W;

  int toss(int K) {
    int N;
    N = rand();
    while (N > K) {
      N = N / K;}
    return N;
  }

  seed = time(NULL);
  srand(seed);

  running = TRUE;

  while (running) {
     Recv(&Port, &Request);
     CH = Request.cmd;
     N = Request.xval;
#ifdef DEBUG
     printf("*** Server[CH = %c <===> N = %d] ***\n", CH, N);
#endif
     switch (CH) {
       case 'q' :
         running = FALSE;
         Answer.cmd = 'z';
         break;
       case 'p' :
         Rx = 0;
         for (i = 0; i < N; i++) {
           W = toss(6);
#ifdef DEBUG
           printf("*** Server[W = %d] ***\n", W);
#endif
           Rx = W + Rx; }
         Answer.cmd = 'r';
         Answer.xval = Rx;
         break; }
     Send(&Reply, Answer); }
  }

void *Client() {
  int running, keeping;
  Message To;
  Message From;
  char CH;
  int K, Y;
  
  printf("Gioco dei Dadi\n\n");
  printf("  Usage: q | p <int>\n\n");

  running = TRUE;
  keeping = TRUE;
  while (running) {
     if (keeping) printf("> ");
     scanf("%c", &CH);
     swith (CH) {
       case 'q':
         running = FALSE;
         keeping = FALSE;
         To.cmd = CH;
         To.xval = -1;
         break;
       case 'p':
         keeping = FALSE;
         scanf("%d", &K);
         To.cmd = CH;
         To.xval = K;
         break;
       case '\n':
         keeping = FALSE;
         break; }
     if (keeping) {
#ifdef DEBUG
       printf("*** Client[K = %d] ***\n", K);
#endif
       Send(&Port, To);
       Recv(&Reply, &From);

       if (running) {
         CH = From.cmd;
         Y = From.xval;
         if (CH == 'r') {
           printf("\n risultato della giocata : %d\n\n", Y);} }

    } } }

Nell'ultimo file riportato sono contenute tutte le istruzioni necessarie per la compilazione dei singoli file sorgenti, in base alla piattaforma che si utilizza e se si intende averne traccia esecutiva o meno.

Tali capacità come molte altre sono rese possibili da opportuni programmi la cui esecuzione dipende anche dalla disponibilità di risorse adatte allo scopo. Queste risorse non necessariamente rimangono immutate ma, in generale, cambiano in ragione del cambiamento dell'hardware e del software di base. In quest'ottica si rende necessario uno strumento flessibile e potente per poter tenere aggiornato il software rispetto ai cambiamenti citati.

Manutenzione dei Programmi

Il metodo più generale per mantenere, aggiornare e rigenerare programmi è quello di disporre, come accade per il sistema operativo UNIX, del comando

make [ -f makefile ] ... [ options ] [ target ] [ macro-name=value ... ]

che esegua una lista di comandi di shell associata a ciascun target, tipicamente per creare o aggiornare un file dello stesso nome. Il file makefile contiene riferimenti ai target, che descrivono quali azioni intraprendere per aggiornarli rispetto a file e/o altri target da cui dipendono, chiamati dipendenze.

Un target è datato quando il file che lo descrive è stato omesso oppure quando una o più delle sue dipendenze ha una data di modifica che risulta essere più recente dello stesso target. In questo caso make analizza ricorsivamente la lista delle dipendenze per ciascun target al fine di generare una lista di target da controllare. La verifica avviene bottom-up, per ciascun target si considerano tutti i file da cui dipende per vedere se ne esiste qualcuno avente data di modifica meno recente. In caso affermativo make ricostruisce il target.

A tal fine make esegue una serie di comandi di shell associati ad esso, detti regole, le quali possono apparire esplicitamente nei punti d'ingresso del makefile oppure essere fornite implicitamente dallo stesso make. Quindi, l'utilizzo del comando make richiede la preventiva implementazione del corrispondente file makefile per la quale si possono seguire le brevi indicazioni riportate di seguito.

TARGET E REGOLE

Sebbene non sia strettamente necessaria la presenza di un file per ogni target che appare nel makefile, ogni dipendenza della lista deve essere il nome di un file oppure il nome di un altro target.

Se non c'è alcuna lista di dipendenza o di regole associate a quel target, allora make cerca di produrre un punto d'inizio selezionando una regola fra l'insieme delle sue regole implicite, altrimenti utilizza quella specificata nel target .DEFAULT, se questo appare nel makefile.

ACCESSO AL TARGET

La definizione di un target nel makefile assume la seguente forma

target...:[dependency]...[; command]...
        [command]
        ...

dove la prima linea contiene il nome del target (oppure una lista di target separati da blank) che termina con il carattere : a cui può far seguito una lista di una o più dipendenze su cui viene fatto il controllo nell'ordine indicato. Tale lista può terminare con ; e, a sua volta, essere seguita da comandi della Bourne shell. Le righe successive, contenenti comandi di shell, devono necessariamente iniziare con un TAB. Tali comandi costituiscono una regola per costruire il target e sono eseguiti quando questo viene aggiornato dal make. Se un comando utilizza più righe allora è necessarie terminare le precedenti con il carattere \ di escape del terminatore di linea.

CARATTERI SPECIALI

I seguenti caratteri hanno un significato speciale quando appaiono in un makefile.

ESECUZIONE DI COMANDI

Le linee di comandi vengono eseguite una alla volta, ciascuna nella propria shell. Nel caso si utilizzi il comando if è conveniente riferirsi al seguente schema

	if expression ; \
	then command ; \
	command ; \
	...
	elif command ; \
	...
	else command ; \
	fi

Se, invece, si utilizza il comando for della Bourne shell, allora lo schema consigliato è il seguente

	for var in list ; do \
	command ; \
	...
	done

MACRO

Punti d'ingresso della forma

	macro-name=value

definiscono una macro avente nome name e valore value, che può consistere di qualunque carattere diverso da # oppure newline, purchè non preceduto da \. I suddetti caratteri terminano la definizione della macro. Il suo nome è delimitato da SPACE, TAB oppure newline preceduto da \.

Ogni successivo riferimento alla macro nella forma $(name) oppure ${name} sarà sostituito con value. Le parentesi si possono omettere se il nome è costituito da un solo carattere.

La definizione di una macro può contenere riferimenti ad altre macro ma questi non saranno espansi immediatamente ma soltanto quando richiesto dai riferimenti alla macro stessa. Le sostituzioni nelle macro possono essere fatte nel modo seguente

${name:str1=str2}

dove str1 è un suffisso oppure una parola da sostituire nella definizione della macro, mentre str2 è la relativa sostituzione.

MACRO MANTENUTE DINAMICAMENTE

Quelle riportate di seguito sono alcune delle macro mantenute dinamicamente ed utili come abbreviazioni nella definizione delle regole

REGOLE IMPLICITE

Il comando make fornisce alcune regole implicite per certi tipi di target per i quali non sono presenti regole esplicite nel relativo makefile. Per selezionare una di tali regole make si basa sull'associazione fra il target e un file nel proprio direttorio e la scelta deriva direttamente dalla relazione esistente fra il suffisso del target e quella dei file delle possibili dipendenze.

Compilazione di DIES

Consideriamo l'esempio di programma multithreading presentato in una lezione precedente e che simula una comunicazione fra processi di tipo produttore-consumatore, realizzato con quattro file sorgenti kernel.c, buffer.c, maibox.c e dies.c, a cui sono associati i file di intestazione kernel.h, buffer.h e maibox.h. Alla luce di quanto visto il Makefile che contiene le azioni per la compilazione dei sorgenti e la generazione dell'eseguibile deve essere organizzato in definizioni di macro e regole (implicite e/o esplicite) per ottenere i target che concorrono alla formazione del target finale playdies.

#
# makefile per costruire il file eseguibile trace
#
# Seguono le definizioni di alcune macro
#
.SUFFIXES: .C $(SUFFIXES)

CC = gcc

CCLIBS_SunOS_5  = -lthread
CCLIBS_Linux    = -lpthread
CCLIBS          = $(CCLIBS_Linux)

OSFLAGS_SunOS_5 = -DSolaris -DDEBUG
OSFLAGS_Linux   = -DLinux -DDEBUG
CCFLAGS = -O2 -pipe $(OSFLAGS_Linux)

SRCS            = buffer.c kernel.c mailbox.c dies.c
OBJS            = $(SRCS:.c=.o)
DEFS            = $(SRCS:.c=.h)

TARGET          = playdies

$(TARGET):	$(OBJS)
	$(CC) -o $@ $(OBJS) $(CCLIBS)

.c.o:
	$(CC) -c $(CCFLAGS) $<

clean:
	rm -f $(TARGET) $(OBJS) *core *bak *~

depend:
	makedepend $(DEFS) $(SRCS)

Il file di comandi è configurato per essere compilato in ambiente Linux ma, con una semplice variazione di due righe, può compilarsi anche sotto Solaris. Inoltre, togliendo il commento alla riga che definisce il flag DEBUG e commentando la precedente, si può avere una traccia della sua esecuzione.