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.
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 |
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.
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.
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.
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.
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(next, &T1):
printf("Thread %d terminated.\n", pid);
pid = Gettid(next, &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.
Il metodo più generale per mantenere, aggiornare e rigenerare programmi è quello di disporre, come accade per il sistema operativo UNIX, del comando
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.
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.
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.
I seguenti caratteri hanno un significato speciale quando appaiono in un makefile.
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
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
dove str1 è un suffisso oppure una parola da sostituire nella definizione della macro, mentre str2 è la relativa sostituzione.
Quelle riportate di seguito sono alcune delle macro mantenute dinamicamente ed utili come abbreviazioni nella definizione delle regole
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.
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.