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 in diversi file. Infatti, al contrario di alcuni schemi di programmazione che spingono a costruire programmi monolitici, in C un programma è 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 xvf thread.tar.gz -z
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.
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 5 typedef char 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 consumatore 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, è ottenuta 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) { Qinit(&Chn->notFull, BUFFSIZE+1); Qinit(&Chn->notEmpty, 0); initbuffer(&Chn->Bu); } void Send(Channel *Chn, Message Msg) { Qwait(&Chn->notFull); putmsg(&Chn->Bu, Msg); Qsignal(&Chn->notEmpty); } void Recv(Channel *Chn, Message *Msg) { Qwait(&Chn->notEmpty); getmsg(&Chn->Bu, Msg); Qsignal(&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 */ /*------------------------------------------------------------------- | 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 void Thread(process *TID); extern void Qinit(semaphore *s, value k); extern void Qwait(semaphore *s); extern void Qsignal(semaphore *s); extern void Qvalue(semaphore *s, int *sval);
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 { perror("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 }; void Thread(process *TID) { #ifdef Solaris *TID = thr_self(); #endif #ifdef Linux *TID = pthread_self(); #endif }; /*------------------------------------------------------------------- | gestione dei semafori | -------------------------------------------------------------------*/ void Qinit(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 Qwait(semaphore *s) { #ifdef Solaris sema_wait( s ) ; #endif #ifdef Linux sem_wait( s ) ; #endif } ; void Qsignal(semaphore *s) { #ifdef Solaris sema_post( s ) ; #endif #ifdef Linux sem_post( s ) ; #endif } ; void Qvalue(semaphore *s, int *sval) { #ifdef Solaris w = sema_getvalue( s, sval ) ; #endif #ifdef Linux w = 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 trace.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 trace.c */ #include <stdio.h> #include <unistd.h> #include "mailbox.h" void *Leggi(); void *Scrivi(); Channel Mbx; main() { process T0, T1, T2 ; #ifdef DEBUG FILE *dbg; dbg = fopen("trace.dbg", "w"); Thread( &T0 ); fprintf(dbg, "Main thread = %d\n", T0); #endif newChannel( &Mbx ); /* inizializzazione del canale /* crea due processi "leggeri" */ NewThread( Leggi, 0, &T1 ); if (T1 == -1) fprintf(stderr, "Can't create thread 1\n"), exit(1); #ifdef DEBUG fprintf(dbg, "Nuovo thread %d\n", T1); #endif NewThread( Scrivi, 0, &T2 ); if (T2 == -1) fprintf(stderr, "Can't create thread 2\n"), exit(1); #ifdef DEBUG fprintf(dbg, "Nuovo thread %d\n", T2); #endif /* ricongiungi i due flussi */ if (JoinThread(T1, T2)) fprintf(stderr, "pthread_join error\n"), exit(2); #ifdef DEBUG fprintf(dbg, "Thread %d terminated.\n", T1); fprintf(dbg, "Thread %d terminated.\n", T2); #endif /* termina */ #ifdef DEBUG fprintf(dbg, "Process %d: exiting...\n", getpid()); #endif exit(0); } void *Leggi() { char ch; int cmd; int i; int w; int offset; process pid; #ifdef DEBUG int NotFull; int NotEmpty; FILE *dbg; dbg = fopen("trace.dbg", "w"); #endif Thread(&pid); printf("programma di test per i thread\n"); printf("enter an integer: "); w = scanf("%d\n", &cmd); if (cmd < 31) offset = 97; else if (cmd < 63) offset = 65; else if (cmd < 95) offset = 32; else offset = 48; for(i = 0; i < cmd; i = i + 1) { #ifdef DEBUG Qvalue(&(Mbx.notFull), &NotFull); Qvalue(&(Mbx.notEmpty), &NotEmpty); fprintf(dbg, "\n notFull = %d notEmpty = %d\n", NotFull, NotEmpty); #endif ch = offset + i; Send(&Mbx, ch); } Send(&Mbx, '!'); } void *Scrivi() { #define MAXLINE 24 char ch; integer again; process pid; int k; Thread(&pid); again = 1; k = 0; while(again) { Recv(&Mbx, &ch); if (ch == '!') again = 0 ; else { if (k == MAXLINE) {k = 0; printf("\n");} putchar(ch); k++; } } printf("\n\n"); }
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 avere la traccia esecutiva o meno.
# # 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 trace.c OBJS = $(SRCS:.c=.o) DEFS = $(SRCS:.c=.h) TARGET = trace $(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.