MaCocoa 063

Capitolo 063 - Multithread

Ho avuto il mio primo riscontro sull'applicazione (grazie a Paolo e a Mauro). È stata criticata l'assenza del pulsante di cancellazione delle operazioni di catalogazione (o meglio, il pulsante c'era, ma non funzionava). Ne ho approfittato allora per rendere l'applicazione multithread, e di permettere la catalogazione in background.

Sorgente: ho guardato un esempio di Apple, ma poi ho fatto a modo mio.

Prima stesura: 16 novembre 2004.

Task e Thread

Prima di cominciare, un po' di teoria (semplifico e traviso, ma cerco di spiegarmi). Molti dei miei lettori (forse tutti) usano un sistema operativo multitasking. Cosa significa? Che il PC è in grado di svolgere contemporaneamente diversi programmi, l'utente è in gradi saltare tranquillamente da un programma ad un altro, e le attività di un programma non sono influenzate (o lo sono ad alto livello) dalle attività di un altro programma (detta in termini crudi: se un programma va in crash, gli altri continuano bellamente a lavorare). L'unità minima di esecuzione è detta task; un task è quindi un programma in grado di lavorare in autonomia, e capace di interagire con altri task secondo protocolli ben definiti. Oltre al multitasking, è possibile un'altra suddivisione delle operazioni di un programma, un po' meno drastica e quindi più leggera: i thread.

Due task diversi operano in contesti diversi, in aree di memoria differenti, e per accedere alla stessa risorsa utilizzano meccanismi ben definiti del sistema operativo; crudamente, un task non ha accesso alle variabili o risorse proprie di un altro task. Per comunicare tra loro, i task devono accordarsi su un dato protocollo, e scambiarsi informazioni in maniera precisa e strutturata. Il sistema operativo poi esegue l'uno o l'altro task a seconda delle condizioni operative correnti, ne permette la convivenza pacifica e li sorveglia benignamente dall'alto.

I thread invece condividono la memoria, le risorse e quant'altro. I thread sono unità di esecuzione che possono essere svolte in parallelo (in particolare, su due processori diversi), ma in cui l'interazione comune deve essere ben gestita da parte del programmatore; sono insomma un meccanismo che permette di eseguire operazioni in parallelo, ma la cui robustezza dipende essenzialmente dalla buona programmazione piuttosto che dal sistema operativo.

Normalmente, un programma diviso in task o thread è meno efficiente di un programma monolitico: bisogna tenere conto del carico aggiuntivo dovuto allo scambio di informazioni tra i vari task/thread. Di contro, la suddivisione in task o thread rende il programma più adatto alla vita in un contesto mutante, come ad esempio nell'interazione con l'utente. In tal caso si dice che il programma è più responsivo, cioè risponde con più prontezza all'utente (perché ad esempio c'è un thread che si occupa di interagire con l'utente, mentre le operazioni di lunga durata sono svolte da un altro thread).

Cocoa mette a disposizione due classi per realizzare i task e i thread. Il loro nome è, sorprendentemente e rispettivamente, NSTask e NSThread. Con NSTask si lancia un processo indipendente dall'applicazione in esecuzione (come se si lanciasse da Terminale), mentre con NSThread si lancia appunto un thread all'interno dell'applicazione corrente. Per comunicare tra task, occorre mettere in piede un meccanismo di comunicazione (pipe, oppure oggetti distribuiti); per comunicare tra thread, ci si potrebbe limitare a scambiarsi messaggi tra oggetti, come se niente fosse.

Il problema sorge quando diversi thread tentano di accedere contemporaneamente alle stesse risorse (diciamo alla stessa locazione di memoria). Avere thread che si disinteressano della possibilità è un metodo molto semplice per creare danni ed inserire errori difficili da trovare. Cocoa fornisce meccanismi di protezione, in modo che thread diversi non possano accedere contemporaneamente alla stessa risorsa, ma rimane compito del programmatore scrivere bene il codice.

Di più, non tutte le librerie fornite da Apple sono thread-safe, ovvero, funzionano correttamente se chiamate da diversi thread. Ad esempio, le classi della Application Kit (in pratica, tutte le classi dell'interfaccia grafica) non lo sono. Questo significa che non posso avere diversi thread che gestiscono l'interfaccia grafica, pena la confusione massima. Le librerie standard C e quelle per la gestione dello I/O sono generalmente sicure, ma in generale bisogna fare parecchia attenzione.

Il server di catalogazione

Una idea che mi girava in testa da molto tempo era appunto quella di rendere la procedura di catalogazione in un thread separato da quello principale dell'applicazione. In questo modo è possibile lanciare un thread per ogni procedura di aggiunta volumi; così facendo, l'utente non deve aspettare necessariamente il termine della catalogazione, ma può proseguire le operazioni sull'applicazione (ad esempio, operando su un altro catalogo). Poiché la parte di catalogazione non ragiona fondamentalmente sull'interfaccia, ma si interessa principalmente di ricevere informazioni dai volumi tramite chiamate Carbon, dovrebbe essere thread-safe. In effetti, finora, ho lavorato tranquillo senza problemi (questo non significa che non ci siano errori).

Ho estratto quindi le istruzioni che eseguono la catalogazione ed ho costruito una nuova classe CatServer.

@interface CatServer : NSObject
{
    NSAutoreleasePool    * pool;
    CdCatDoc            * theCat ;
}

// metodo per l'inizializzazione
- (void) makeVolCat: (NSArray *) theArgs ;
- (void) stopCatalog ;

La classe possiede solo due metodi: il primo esegue le operazioni di catalogazione utilizzando i meccanismi messi a disposizione da VolInfo, FileStruct e CatFileInfo. Il secondo metodo serve per terminare le operazioni di catalogazione e sistemare le cose.

Il metodo makevolCat: ha un solo argomento; si tratta tuttavia di un NSArray, che contiene una serie di informazioni. La presenza di un unico argomento è imposta dal meccanismo di creazione di un thread (ci arrivo tra un po'). L'oggetto NSArray contiene le due informazioni necessarie per effettuare una catalogazione. In primo luogo un riferimento all'oggetto CdCatDoc destinato a contenere le informazioni; il secondo elemento è a sua volta un NSArray con la lista dei nomi dei volumi da catalogare.

Detto questo, il metodo è presto presentato: non ho fatto altro che estrarre la maggior parte delle istruzioni dal metodo performAddFilesModal di CdCatDoc:

- (void)
makeVolCat: (NSArray *) theArgs
{
    unsigned long long    totalSpace, freeSpace, usedspace ;
    NSEnumerator     * enumerator ;
    NSString        * volPath ;
    NSDictionary    * fsattrs ;
    NSArray            * volList = [ theArgs objectAtIndex: 1 ];
    // recupero il catalogo
    theCat = [ theArgs objectAtIndex: 0 ];
    // devo costruire un pool di memoria per le attivita' correnti
    pool = [[NSAutoreleasePool alloc] init];
    // spazzolo tutti i vari volumi interessati
    enumerator = [volList objectEnumerator];
    // finche' ci sono volumi da trattare
    while ( volPath = [enumerator nextObject] )
    {
        // il volPath ho il nome completo del volume
        VolInfo            * fInfo ;
        FSSpec            myFSSpec ;
        FSRef            myFSRef;
        FSVolumeInfo    myInfo;
        FSCatalogInfo    catinfo ;
        OSStatus        status ;
        NSArray            * stsresp;
        // recupero informazioni specifiche di volume
        status = FSPathMakeRef ([volPath fileSystemRepresentation], &myFSRef, NULL);
        if (status == noErr)
            status = FSGetCatalogInfo (&myFSRef, kFSCatInfoNone, & catinfo, NULL,
                                    & myFSSpec, NULL);
        // adesso in myFSSpec ho il mio FSSpec
         status = FSGetVolumeInfo ( myFSSpec.vRefNum, 0, NULL, kFSVolInfoFileCount,
            & myInfo, NULL, NULL );
        fsattrs = [[NSFileManager defaultManager] fileSystemAttributesAtPath: volPath];
        totalSpace = [[fsattrs objectForKey:NSFileSystemSize] unsignedLongLongValue] ;
        freeSpace = [[fsattrs objectForKey:NSFileSystemFreeSize] unsignedLongLongValue] ;
        usedspace = totalSpace - freeSpace ;
        // aggiorno la finestra di attesa
        stsresp = [NSArray arrayWithObjects:
                        [ NSNumber numberWithLong: 0 ],
                        [ NSNumber numberWithLong: myInfo.fileCount ],
                        [NSString stringWithFormat: @"Cataloging: %@",
                            [[ NSFileManager defaultManager] displayNameAtPath: volPath] ],
                        nil];
        [ theCat updateInterfaceStr: stsresp ];
        // costruisco l'alberatura dei file a partire dalla scelta
        fInfo = [[ VolInfo alloc ] initTreeFromPath: volPath withCat: theCat ];
        // metto a posto le altre informazioni
        [ fInfo setFileSize: usedspace ];
        [ fInfo setVolSize: totalSpace ];
        [ fInfo setVolFreeSize: freeSpace ];
        // aggiungo la cosa al catalogo
        [ theCat updateCatalog: fInfo ];
    }
    [ self stopCatalog ] ;
}

Devo giustificare due istruzioni che coinvolgono il catalogo; c'è il problema di evitare chiamate dell'Application Kit dal momento che sono dichiarate non sicure nei confronti dei thread. Allora, per aggiornare il contenuto della finestra di avanzamento lavori WaitPanCtl, invece che invocare direttamente i metodi, passo attraverso la classe CdCatDoc (che è eseguita nel thread principale), utilizzando i due metodi updateInterfaceStr e updateCatalog. Il primo metodo è direttamente responsabile dell'aggiornamento della finestra, mentre il secondo aggiunge i dati realtivi al volume al catalogo (ed aggiorna l'interfaccia di conseguenza...).

L'altra istruzione aggiunta è la costruzione di un oggetto della classe NSAutoreleasePool. Si tratta della lista degli oggetti che hanno ricevuto un messaggio di autorelease e sono destinati ad essere distrutti alla fine delle operazioni. Ogni thread e task e applicazione deve possederne uno; finora la cosa era gestita nascostamente dalla classe NSApp; qui, costruendo un thread, va scritto esplicitiamente.

Ho dovuto modificare i metodi initTreeFromPath di FileStruct e initWithPath di CatFileInfo aggiungendo un riferimento al catalogo come argomento. Così facendo è possibile, dall'interno di questi metodi, effettuare una chiamata di aggiornamento alla finestra con il metodo updateInterfaceStr. Ad esempio:

- (id)
initWithPath: (NSString*) aFile withCat: (CdCatDoc*) theCat
{
    ...
    // aggiorno la barra di attesa
    if ( theCat )
    {
        NSArray * stsresp = [NSArray arrayWithObjects:
                    [ NSNumber numberWithLong: CATSRV_UPDATECOUNT ],
                    [ NSNumber numberWithLong: CATSRV_NOVALUE ],
                    nil, nil];
        [ theCat updateInterfaceStr: stsresp ];
    }
    return ( self ) ;
}

Rimane da descrivere il metodo che chiude le operazioni di catalogazione:

- (void)
stopCatalog
{
    // faccio mettere a posto le cose al catalogo
    [ theCat endOfCatalog ];
    // rilascio il pool di memoria
    [ pool release ];
    // uccido il thread (servira'?)
    [ NSThread exit ] ;    
}

Semplicemente, si segnala al catalogo che si ha finito, si distruggono tutti gli oggetti residui per poi suicidare il thread.

Lanciare un thread

Pensavo di dover fare molto lavoro per lanciare un thread in esecuzione, invece è di una semplicità disarmante:

- (void)
performAddFilesModal: ( NSArray *)volList
{
    // costruisco un vettore con gli argomenti di lancio del thread
    NSArray        * theArgs = [NSArray arrayWithObjects: self, volList, nil];
    // costruisco la finestra di attesa
    waitSheet = [[ WaitPanCtrl alloc] init ] ;
    // apro la finestra e la rendo modale per la finestra
    [ NSApp beginSheet: [ waitSheet window ]
        modalForWindow: [ NSApp mainWindow ]
        modalDelegate: self
        didEndSelector: @selector( endOfCatalog )
        contextInfo: waitSheet
    ];
    // costruisco il server di catalogazione
    theCatServer = [ [[CatServer alloc] init] autorelease ];
    // lancio il server come nuovo thread
    [NSThread detachNewThreadSelector:@selector(makeVolCat:)
                    toTarget: theCatServer withObject: theArgs ];
    // e non ho piu' nulla da fare...
}

Si tratta di utilizzare la classe NSThread ed il metodo di costruzione di un thread. Occorre specificare tre argomenti: quale oggetto è il tenutario del thread, quale metodo deve essere eseguito e l'unico argomento di questo metodo (ecco giustificato lo NSArray visto in precedenza).

Ho anche trasformato la finestra di avanzamento lavoro da modale per tutta l'applicazione a modale per la sola finestra del catalogo, dal momento che non occorre più bloccare tutte le altre operazioni. Ho inserito un riferimento alla finestra costruito come variabile d'istanza, dal momento che possono esserci più catalogazioni in corso.

Infatti, per aggiornare il catalogo e la finestra ci sono i due metodi seguenti:

- (void)
updateCatalog: (VolInfo*) theCatVol
{
    // aggiungo il valume al catalogo
    [ dataSource addFileEntry: theCatVol ];
    // rinfresco le finestre
    [ coverWin refreshWindow ] ;
}

- (void)
updateInterfaceStr: (NSArray*) theArgs
{
    // ci sono fino a tre valori nello array: il valore della barra,
    // il valore massimo delal barra, la stringa da visualizzare
    int            numargs = [ theArgs count ] ;
    long        cv1 = [ [ theArgs objectAtIndex: 0] longValue] ;
    long        cv2 = [ [ theArgs objectAtIndex: 1] longValue] ;
    // aggiornamento del valore corrente della barra
    if ( cv1 == CATSRV_UPDATECOUNT )
    {
        // il valore speciale CATSRV_UPDATECOUNT indica
        // incremento del valore della barra
        double xx = [ waitSheet localDoubleValue ] ;
        [ waitSheet localSetDoubleValue: (xx+1) ];
    }
    // il valore speciale CATSRV_NOVALUE indica nessuna attivita'
    else if ( cv1 != CATSRV_NOVALUE )
        // impostazione diretta del valore delal barra
        [ waitSheet localSetDoubleValue: cv1 ];
    // aggornamento del massimo valore della barra
    // il valore speciale CATSRV_NOVALUE indica nessuna attivita'
    if ( cv2 != CATSRV_NOVALUE )
        [ waitSheet localSetMaxValue: cv2 ];
    // eventuale aggiornamento della stringa nella finestra
    if ( numargs > 2 )
    {
        // solo se ci sono almeno tre argomenti
        NSString    * cv3 = [ theArgs objectAtIndex: 2] ;
        [ waitSheet setMainText: cv3 ];
    }
    // verifico se si deve fermare la catalogazione
    if ( [ waitSheet stopMe ] )
    {
        [ theCatServer stopCatalog ] ;
    }
}

Il secondo metodo utilizza qualche accorgimento; avevo bisogno di tre argomenti (valore corrente e massimo della barra di avanzamento, e la stringa da visualizzare), ma li ho raccolti tutti in un NSArray; il terzo argomento può non essere presente, mentre ho utilizzato due valori illegali per discriminare operazioni specifiche sulla barra di avanzamento. In particolare mi occorreva un meccanismo per fare avanzare di un passo la barra dall'interno del thread di catalogazione (senza fare troppi giri di variabili), e quindi ho utilizzato un valore particolare (-1) per indicare questo tipo di aggiornamento. Se invece non aveva senso specificare parametri sulla barra, ma solo la stringa da visualizzare, utilizzavo un altro valore particolare (-2) per indicare nessuna operazione.

C'è infine il metodo chiamato alla chiusura delle operazioni di catalogazione:

- (void)
endOfCatalog
{
    // nascondo la finestra di attesa
    [[waitSheet window] setIsVisible: FALSE ];
    [ waitSheet setStopMe: NO ] ;
    // ho finito di lavorare modale con la sheet
    [NSApp endSheet: [waitSheet window] returnCode: nil ];
    // aggiorno il documento
    [ self updateChangeCount: NSChangeDone ] ;
    [ self refreshWinsWithListSts: @"" coverSts: @"" ];
}

Si chiude la finestra di avanzamento, si distrugge l'oggetto che ha eseguito la catalogazione, e si aggiorna l'aspetto delle finestre rimaste.

Fermate quella catalogazione

Staccare la parte di catalogazione dal resto dell'applicazione consente la gestione responsiva dell'interfaccia utente, ed in particolare permette di poter intervenire ed interrompere il processo.

Ho così abilitato il pulsane di cancellazione della catalogazione sulla finestra di avanzamento, e ho associato una azione molto semplice, che si limita ad impostare una variabile d'istanza:

- (void)
stopThatTrain:(id)sender
{
    stopMe = TRUE ;
}

In questo modo, nel momento più opportuno, si può verificare se l'utente ha fatto clic sul pulsante di arresto della catalogazione, e terminare le operazioni in corso. Ho scelto di effettuare l'eventuale arresto in corrispondenza di un aggiornamento della finestra di avanzamento stessa, quindi all'interno del metodo updateInterfaceStr di CdCatDoc. In questo modo evito interruzioni anomale del thread e gestisco con tranquillità la cessazione delle operazioni.

Licenza Creative Commons
Eccetto dove diversamente specificato, i contenuti di questo sito sono rilasciati sotto Licenza Creative Commons.
Pagina a cura di Livio Sandel (macocoa2012@gmail.com).