Metodologie di Programmazione: Lezione 15

Riccardo Silvestri

Parallelismo e sincronizzazione

Nella lezione precedente abbiamo trattato compiti IO-intensive, dominati cioè da operazioni di I/O i cui tempi di esecuzione sono per la gran parte dovuti a tempi d'attesa per l'accesso a risorse. Adesso vedremo compiti computation-intensive che in un certo senso sono all'altro estremo dello spettro perché i loro tempi di esecuzione sono dovuti esclusivamente a calcoli senza tempi d'attesa. Una conseguenza importante di questa differenza è che la mera programmazione multithreading, cioè l'uso di più thread, per quest'ultimo tipo di compiti non porta a miglioramenti se non ci sono due o più processori disponibili. In altri termini, per ottenere miglioramenti dalla programmazione multithreading è necessario eseguire calcoli in parallelo non semplicemente in modo concorrente.

Gli esempi discussi finora non richiedono che i thread debbano comunicare tra loro o che debbano accedere a dati condivisi mutabili durante la loro esecuzione. Ma ci sono molte situazioni in cui ciò è conveniente o indispensabile. In questa lezione introdurremo i meccanismi di base per la sincronizzazione dei thread e come questi permettono di risolvere semplici esempi di tali situazioni.

Parallelizzare calcoli intensivi

Un compito ad alta intensità di calcolo richiede esclusivamente esecuzioni di calcoli e non richiede operazioni di I/O bloccanti (come accessi a server remoti). Quindi l'esecuzione di compiti di questo tipo impegna esclusivamente le unità di calcolo e la memoria del computer. Non ci sono tempi d'attesa che possono essere compensati sfruttando l'esecuzione in multithreading. Se il computer dispone di una sola unità di calcolo, una sola CPU, non c'è modo di migliorare il tempo di calcolo usando più thread. Se però si dispone di più unità di calcolo che possono operare in parallelo, allora c'è la possibilità che suddividendo opportunamente i calcoli del nostro compito tra i processori si riesca a ridurre il tempo di calcolo. Se il numero di processori è N, il meglio a cui si può aspirare è che il tempo di calcolo T si riduca a T/N. Difficilmente si riesce a ottenere questo miglioramento ottimale, ma spesso ci si può avvicinare. In generale dipende sia dall'architettura hardware/software sia dal tipo di problema che si vuole risolvere. Per alcuni problemi è addirittura impossibile ottenere miglioramenti indipendentemente da quanti processori si dispone. All'altro estremo ci sono problemi o compiti che si parallelizzano molto facilmente. Nel mezzo ci sono una gran varietà di problemi con varie possibilità di parallelizzazione più o meno efficienti.

Esula da questo corso il trattamento di tecniche di parallelizzazione, il cui approfondimento richiede corsi a sé stanti. Ci limiteremo a mostrare alcuni semplici esempi la cui parallelizzazione è molto facile. Nonostante la loro semplicità, mettono in evidenza alcuni fenomeni che hanno una valenza più generale.

Congettura di Collatz

La congettura di Collatz, conosciuta anche come congettura 3n + 1, è un problema aperto da quando è stata enunciata per la prima volta nel 1937 da Lothar Collatz (si veda Collatz conjecture). La congettura è facile da enunciare. Dato un qualsiasi intero positivo n, si consideri la seguente procedura: se n è pari, dividilo per 2; altrimenti moltiplicalo per 3 e aggiungi 1; ripeti per sempre o finché n diventa 1. La ragione per cui ci si ferma a 1 è che dopo 1 la sequenza dei numeri prodotti dalla procedura diventa periodica 1,4,2,1,... La congettura afferma che partendo da un qualsiasi intero positivo la procedura arriva sempre ad 1 (e quindi termina). La congettura è stata verificata sperimentalmente fino a numeri molto grandi ma nessuno è riuscito finora a dimostrare che vale per tutti gli infiniti numeri interi.

La verifica sperimentale della congettura richiede di eseguire la procedura per un dato intervallo di numeri interi, determinando per ogni n il numero di passi che la procedura deve fare per arrivare ad 1. Non siamo interessati a trovare l'algoritmo più veloce, che magari mantiene in memoria una opportuna tabella, ecc. Ma semplicemente a vedere come tale compito può essere parallelizzato e con quale efficacia.

Iniziamo a scrivere una versione sequenziale che per ogni intero in un dato intervallo esegue la procedura di Collatz, calcola il numero di passi e determina il massimo numero di passi per tutti gli interi nell'intervallo. Introduciamo una classe CompIntensive nel package mp.concur.

/** Classe per testare implementazioni parallele di compiti ad alta intensità
 * di calcolo. */
public class CompIntensive {
    /** Ritorna il massimo numero di passi dell'algorimo della congettura di
     * Collatz, per tutti gli interi nell'intervallo [a, b].
     * @param a  inizio intervallo
     * @param b  fine intervallo
     * @return il massimo numero di passi */
    public static long collatz(long a, long b) {
        long max = 0;
        for (long i = a ; i <= b ; i++) {
            long t = 0, n = i;
            while (n != 1) {
                if ((n % 2) == 0) n = n/2;
                else n = 3*n + 1;
                t++;
            }
            if (t > max) max = t;
        }
        return max;
    }

Scriviamo anche un metodo per metterlo alla prova. Lo definiamo in modo tale che prenda in input una funzione generale con due parametri di tipo Long e ritorna un Long. Così possiamo usarla anche per mettere alla prova altri metodi con la stessa intestazione. Quindi sempre in mp.concur.CompIntensive,

public static void test_comp(long a, long b, BiFunction<Long,Long,Long> cmp) {
    out.println("  Intervallo ["+a+", "+b+"]: ");
    long time = System.currentTimeMillis();
    long max = cmp.apply(a, b);
    out.println("  "+max+"  time "+(System.currentTimeMillis() - time)+"ms");
}

public static void main(String[] args) {
    out.println("Test collatz");
    test_comp(1, 60_000_000, CompIntensive::collatz);
}

Le figure seguenti mostrano alcune istantanee durante l'esecuzione su un macchina con 8 core e mostrano l'attività per ognuno di essi.

La seguente figura mostra l'attività degli 8 core durante l'esecuzione, campionata ogni secondo. Ogni colonnina rappresenta quindi un campionamento effettuato in un dato secondo e il numero di colonnine è all'incirca pari alla durata del calcolo (circa 29 secondi).

Dalla figura potrebbe sembrare che la JVM effettui una qualche forma di parallelizzazione automatica del calcolo perché 4 degli 8 core sono abbastanza impegnati. Ma se si studiano le colonnine degli 8 core in ogni secondo si osserva che la somma dell'attività non supera mai il massimo che si può avere su un singolo core1. L'uso di più core invece che uno solo è probabilmente dovuto a molteplici fattori che riguardano le politiche degli scheduler della JVM e del sottostante sistema operativo.

Passiamo ora a un'implementazione parallela. Cerchiamo cioè di sfruttare gli 8 core della nostra macchina. Il modo più semplice è di assegnare ad ogni core il calcolo relativamente ad un sotto-intervallo. Se abbiamo 8 core possiamo dividere l'intervallo in 8 sotto-intervalli consecutivi ognuno di dimensione pari all'incirca a un ottavo dell'intervallo originale. Se siamo "fortunati" ogni core svolgerà in parallelo con altri un ottavo del calcolo totale e il tempo complessivo dovrebbe ridursi a circa un ottavo di quello sequenziale. Per ottenere il numero di processori disponibili, possiamo usare il metodo Runtime.getRuntime().availableProcessors. Quindi usiamo un esecutore con un gruppo fisso di thread pari al numero di processori e usiamo tanti task quanti sono i thread. Ogni task può usare il metodo collatz che abbiamo già scritto.

/** Implementazione parallela di {@link CompIntensive#collatz(long, long)}.
 * @param a  inizio intervallo
 * @param b  fine intervallo
 * @return il massimo numero di passi */
public static long collatzParallel(long a, long b) {
    int np = Runtime.getRuntime().availableProcessors();
    ExecutorService exec = Executors.newFixedThreadPool(np);
    long size = (b - a + 1);
    long nParts = Math.min(np, size), partSize = size/nParts;
    List<Future<Long>> tasks = new ArrayList<>();
    long max = 0;
    try {
        for (int i = 0 ; i < nParts ; i++) {
            long ta = i*partSize + a;
            long tb = (i == nParts - 1 ? b : ta + partSize - 1);
            tasks.add(exec.submit(() -> collatz(ta, tb)));
        }
        for (Future<Long> t : tasks) {
            long m = t.get();
            if (m > max) max = m;
        }
    } catch (InterruptedException | ExecutionException e) {
    } finally { exec.shutdown(); }
    return max;
}

Ricordiamoci sempre di effettuare lo shutdown dell'esecutore una volta che abbiamo finito di usarlo (altrimenti i thread di alcuni esecutori, come newFixedThreadPool, rimangono in vita). Proviamo la versione parallela,

out.println("Test collatzParallel");
test_comp(1, 60_000_000, CompIntensive::collatzParallel);

Le figure sottostanti mostrano due istantanee una durante il calcolo e l'altra alla fine.

Si può notare che gli 8 core lavorano a pieno regime durante il calcolo. Questo è ulteriormente confermato dalla figura qui sotto che mostra l'attività, campionata ogni secondo, degli 8 core durante i circa 6 secondi del calcolo.

Le colonnine sono quasi sempre al massimo su ogni core. Questo ci dice che la nostra semplice suddivisione del lavoro tra gli 8 core ha già raggiunto il massimo che potevamo ottenere (non modificando l'algoritmo sequenziale). Il fattore di miglioramento del tempo di calcolo è 28975/5770, cioè circa 5. Siamo abbastanza lontani dal fattore ideale 8, però le ragioni potrebbero essere molto complesse e dovute alla particolare architettura hardware della macchina in questione oltre che da come i vari thread sono gestiti dalla JVM e dal sistema operativo. In generale, anche se la macchina dispone di più processori, o core, questi non possono funzionare come fossero completamente indipendenti l'uno dall'altro. Spesso ci sono molti dispositivi hardware che sono ad uso condiviso, come la memoria, memorie cache, bus, ecc. Tutto ciò limita la possibilità di raggiungere un pieno parallelismo.

Tentare di aumentare il numero di thread non può portare a miglioramenti perché non ci sono tempi d'attesa da compensare. Tentare di aumentare il numero di task, riducendo così il lavoro svolto da ogni singolo task, ha di solito l'effetto di migliorare il bilanciamento del lavoro svolto dai diversi thread perché compensa le eventuali differenze di lavoro dei task. Però provando ad aumentare il numero di task, non si nota alcun effetto significativo. Confermando quello che risulta anche dai campionamenti dell'attività dei core, cioè la suddivisione del lavoro è già ben bilanciata.

Numeri primi

Consideriamo ora un altro esempio di compito che richiede un alta intensità di calcolo. Vogliamo calcolare il numero di numeri primi contenuti in un dato intervallo. Definiamo quindi il seguente metodo sempre in mp.concur.CompIntensive,

/** Ritorna il numero di primi nell'intervallo [a, b].
 * @param a  inizio intervallo
 * @param b  fine intervallo
 * @return il numero di primi nell'intervallo [a, b] */
public static long numPrimes(long a, long b) {
    long nPrimes = 0;
    for (long n = a ; n <= b ; n++) {
        long d = 2;
        double sqrt = Math.sqrt(n);
        while (d <= sqrt && n % d != 0) d++;
        if (d > sqrt) nPrimes++;
    }
    return nPrimes;
}

Proviamolo su un intervallo abbastanza grande

out.println("Test numPrimes");
test_comp(1, 20_000_000, CompIntensive::numPrimes);

Le due figure sottostanti mostrano un'istantanea durante il calcolo e una alla fine.

La figura seguente mostra l'attività, campionata al secondo, degli 8 core durante il calcolo. Si può notare che è abbastanza simile a quella per il calcolo sequenziale del precedente metodo collatz e valgono per essa le stesse considerazioni.

L'implementazione parallela può essere fatta sulla falsariga di quella che abbiamo già fatto per collatz. Suddivisione dell'intervallo in un numero di sotto-intervalli pari al numero di thread. Numero di thread pari al numero di processori disponibili. Anche in questo caso i task possono usare il metodo numPrimes che abbiamo già scritto.

/** Implementazione parallela di
 * {@link mp.concur.CompIntensive#numPrimes(long, long)}.
 * @param a  inizio intervallo
 * @param b  fine intervallo
 * @return il numero di primi nell'intervallo [a, b] */
public static long numPrimesParallel(long a, long b) {
    int np = Runtime.getRuntime().availableProcessors();
    ExecutorService exec = Executors.newFixedThreadPool(np);
    long size = (b - a + 1);
    long nParts = Math.min(np, size), partSize = size/nParts;
    List<Future<Long>> tasks = new ArrayList<>();
    long nPrimes = 0;
    try {
        for (int i = 0; i < nParts; i++) {
            long ta = i*partSize + a;
            long tb = (i == nParts - 1 ? b : ta + partSize - 1);
            tasks.add(exec.submit(() -> numPrimes(ta, tb)));
        }
        for (Future<Long> t : tasks)
            nPrimes += t.get();
    } catch (InterruptedException | ExecutionException e) {
    } finally { exec.shutdown(); }
    return nPrimes;
}

Mettiamolo alla prova sullo stesso intervallo

out.println("Test numPrimesParallel");
test_comp(1, 20_000_000, CompIntensive::numPrimesParallel);

Ecco le due istantanee prese durante il calcolo

Le attività dei core nella prima istantanea non sembrano essere così buone come quelle evidenziate per la versione parallela di collatz. Questo è confermato anche dalla figura sottostante che mostra le attività dei core durante l'intero calcolo

Infatti verso gli ultimi secondi alcuni core non sembrano lavorare a pieno regime. D'altronde i sotto-intervalli non richiedono tutti lo stesso carico di lavoro. Il primo sotto-intervallo, quello relativo ai numeri più piccoli, sicuramente richiede molto meno lavoro dell'ultimo, quello relativo ai numeri più grandi. Per tentare di compensare ciò, possiamo aumentare il numero di sotto-intervalli e quindi anche il numero di task. Così ogni thread non eseguirà più un singolo task, cioè il calcolo di un singolo sotto-intervallo, ma più task e quindi più sotto-intervalli. In questo modo sotto-intervalli meno onerosi e quelli più onerosi si mescoleranno, sperando che possano compensarsi a vicenda. Modifichiamo la seguente linea del metodo numPrimesParallel, moltiplicando per 10 il numero di task,

public static long numPrimesParallel(long a, long b) {
    . . .
    long nParts = Math.min(10*np, size), partSize = size/nParts;
    . . .
}

Ecco il tempo impiegato dalla versione con più task. Si può notare un qualche miglioramento.

La figura sottostante conferma che l'aumento dei task ha migliorato il bilanciamento del carico di lavoro dei thread e quindi dei core.

Non sembra esserci ulteriore spazio per il miglioramento. Nel momento in cui i processori appaiono lavorare tutti a pieno regime non possiamo sperare di ottenere miglioramenti suddividendo il lavoro in qualche altro modo. Abbiamo già ottenuto il massimo che potevamo ottenere fermi restando l'architettura hardware/software e l'algoritmo del calcolo.

Nonostante la loro semplicità i due esempi mettono in evidenza alcune caratteristiche che hanno valenza generale. Per migliorare l'efficienza di compiti computation-intensive sono necessari più processori e il lavoro (cioè i calcoli) devono essere suddivisi tra i diversi processori. Tale suddivisione deve cercare di bilanciare il più possibile il carico di lavoro eseguito da ogni processore per evitare di avere tempi morti su qualche processore. Inoltre, non ci sono ricette che permettono di determinare a priori il modo migliore di parallelizzare un dato compito. La dipendenza dall'architettura hardware/software rende necessario fare sempre delle sperimentazioni per poter calibrare i vari parametri.

Sincronizzazione

Gli esempi che abbiamo visto finora non richiedono uno scambio di dati tra i thread in esecuzione. Né richiedono accesso a dati condivisi mutabili, cioè che possono essere modificati durante l'esecuzione dei thread. Ma in molti casi è conveniente poter scambiare dati tra thread o condividere dati mutabili, in altri è quasi indispensabile. Si pensi ad esempio a un sistema software che deve gestire le transazioni di una banca effettuate tramite sportelli, bancomat, siti online, ecc. È chiaro che il sistema usa molti processi o thread di esecuzione che possono accedere a dati condivisi (la base di dati della banca) ed eventualmente li possono modificare. Mentre un thread sta accedendo a un certo conto corrente e magari lo sta aggiornando a seguito di un prelievo un altro thread potrebbe dover accedere allo stesso conto corrente per fare un versamento. Se i due thread non sono propriamente sincronizzati i due aggiornamenti potrebbero non avvenire in modo corretto lasciando il conto corrente in uno stato inconsistente. Come si vede dalla figura qui sotto

le istruzioni di due thread che accedono allo stesso conto corrente non sono sincronizzate e si intrecciano in un modo tale che solamente il secondo aggiornamento ha effetto mentre quello del primo thread viene perso.

Introduciamo una classe TestSync in mp.concur per fare esempi relativi alla sincronizzazione. Consideriamo il caso molto semplice di un oggetto che serve a generare numeri distinti che possono essere richiesti da diversi thread. Il generatore potrebbe essere utile per generare numeri che devono essere unici perché servono ad identificare delle risorse, ad esempio file o altri oggetti. Definiamo un'interfaccia per un generatore così che possiamo cambiarne agevolmente l'implementazione. Diamo anche una semplice implementazione del generatore.

/** Classe per testare la sincronizzazione di threads */
public class TestSync {
    /** Un generatore di interi (tutti diversi) */
    public interface Gen {
        int getNext();
    }

    /** Implementazione semplice di {@link mp.concur.TestSync.Gen} */
    public static class SimpleGen implements Gen {
        @Override
        public int getNext() { return counter++; }

        private int counter = 0;
    }
}

A questo punto scriviamo un metodo che mette alla prova un generatore creando task, eseguiti in un dato numero di thread, che fanno richieste al generatore e controlla se i numeri ottenuti dai task sono tutti diversi.

/** Mette alla prova un generatore con un dato numero di threads e tasks.
 * @param g  un generatore
 * @param nThreads  numero threads
 * @param nTasks  numero tasks */
public static void test_Gen(Gen g, int nThreads, int nTasks) {
    out.println("Threads: "+nThreads+"  Tasks: "+nTasks);
    ExecutorService exec = Executors.newFixedThreadPool(nThreads);
    List<Future<Integer>> tasks = new ArrayList<>();
    Set<Integer> vals = new HashSet<>();
    try {
        for (int i = 0; i < nTasks; i++)
            tasks.add(exec.submit(g::getNext));
        for (Future<Integer> t : tasks) {
            try {
                int v = t.get();
                vals.add(v);
            } catch (InterruptedException | ExecutionException e) { }
        }
    } finally { exec.shutdown(); }
    out.println("Valori ripetuti: "+(nTasks - vals.size()));
}

Ogni task semplicemente chiede il prossimo numero al generatore e lo ritorna. I numeri ritornati dai task sono inseriti in un insieme così da poter controllare che alla fine ve ne siano tanti distinti quanti sono i task. Se questo non accade vuol dire che due o più thread hanno ottenuto lo stesso numero. Proviamo con un solo thread e un milione di task.

public static void main(String[] args) {
    test_Gen(new SimpleGen(), 1, 1_000_000);
}

Otteniamo

Threads: 1  Tasks: 1000000
Valori ripetuti: 0

Quindi è andato tutto bene, ogni task ha ottenuto un numero diverso così come deve essere. Però proviamo adesso con due thread e con un numero minore di task

test_Gen(new SimpleGen(), 2, 10_000);

Potremmo ottenere un risultato del tipo

Threads: 2  Tasks: 10000
Valori ripetuti: 8

Ci sono valori ripetuti, questo vuol dire che alcuni task hanno ottenuto lo stesso numero. Se proviamo a ripetere l'esecuzione probabilmente si avranno diversi numeri di valori ripetuti. Come è potuto accadere? Apparentemente l'istruzione return counter++; sembra essere monolitica, un blocco indivisibile. Ma in realtà non è così. Invece sono tre operazioni: leggi il valore di counter, incrementa tale valore e aggiorna il valore di counter. Quindi può accadere che due thread si intreccino in modo simile all'esempio del conto corrente. Ad esempio, se ad un dato momento counter ha valore 8 e due thread invocano getNext in rapida successione. Il primo thread legge il valore 8 e prima che possa incrementarne il valore il secondo thread legge il valore di counter ottenendo anch'esso il valore 8.

Per ottenere un funzionamento corretto dobbiamo garantire che una certa sequenza di operazioni avvenga in modo sequenziale ed indivisibile, evitando così che un thread possa eseguire una parte della sequenza e prima che finisca qualche altro thread inizi ad eseguire la stessa sequenza di operazioni. Per garantire ciò ci sono come vedremo diversi meccanismi. Quello che è direttamente supportato dal linguaggio Java è la sincronizzazione. La parola chiave synchronized può essere usata come modificatore di un metodo. Si possono sincronizzare sia metodi statici che metodi dell'oggetto. L'effetto per i metodi dell'oggetto è il seguente. Se un thread t invoca un metodo con modificatore synchronized su un oggetto Obj finché tale esecuzione non finisce, cioè il thread t non esce dal blocco sincronizzato del metodo, nessun altro thread può iniziare l'esecuzione di qualsiasi metodo sincronizzato dell'oggetto Obj. Quindi l'effetto è esattamente uguale a quello che si avrebbe se il thread t ottenesse un lock relativamente all'oggetto Obj. Finché detiene il lock nessun altro thread può ottenere lo stesso lock. In realtà il modificatore synchronized è proprio equivalente a ottenere un lock sull'oggetto Obj. Ma su questo torneremo più avanti. Per adesso vediamo subito un esempio implementando il nostro generatore usando la sincronizzazione.

/** Implementazione thread safe di {@link mp.concur.TestSync.Gen} che usa la
 * sincronizzazione */
public static class SyncGen implements Gen {
    @Override
    public synchronized int getNext() { return counter++; }

    private int counter = 0;
}

Proviamo il generatore sincronizzato con 1000 thread e un milione di task

test_Gen(new SyncGen(), 1000, 1_000_000);

E otteniamo un comportamento corretto, tutti i task prendono numeri diversi.

Threads: 1000  Tasks: 1000000
Valori ripetuti: 0

A volte un'alternativa alla sincronizzazione è l'uso di una variabile atomica. Una variabile atomica è una variabile che permette di effettuare alcune combinazioni di operazioni molto comuni come ad esempio nel nostro caso un valore deve essere letto e poi incrementato in modo atomico, cioè indivisibile, esattamente come se fossero in un blocco sincronizzato. Il package java.uti.concurrent.atomic ha diversi tipi di variabili atomiche. Nel nostro caso possiamo usare AtomicInteger. Il metodo int getAndIncrement() in modo atomico incrementa il valore e ritorna il valore prima dell'incremento.

/** Implementazione thread safe di {@link mp.concur.TestSync.Gen} che usa una
 * variabile atomica. */
public static class AtomGen implements Gen {
    @Override
    public int getNext() { return counter.getAndIncrement(); }

    private final AtomicInteger counter = new AtomicInteger(0);
}

Provando questa versione del generatore

test_Gen(new AtomGen(), 1000, 1_000_000);

otteniamo il comportamento corretto

Threads: 1000  Tasks: 1000000
Valori ripetuti: 0

Quello che abbiamo visto circa la sincronizzazione è solamente l'inizio. In seguito approfondiremo l'uso di tali meccanismi e ne vedremo altri.

Esercizi

[Fattori]    Scrivere un metodo che preso in input un intervallo di interi [a, b] ritorna il numero medio di fattori primi dei numeri nell'intervallo. Scrivere una versione parallela e confrontarla con la versione sequenziale.

[HappyNumbers]    Scrivere un metodo che preso in input un intervallo di interi [a, b] ritorna il numero di happy number (vedere HappyNumber per la definizione) contenuti nell'intervallo. Scrivere una versione parallela e confrontare le due versioni.

[Parallelizza]    I metodi collatzParallel e numPrimesParallel hanno una struttura molto simile. Scrivere un metodo parallelize che li generalizza entrambi e permette di sostituirli con un'opportuna invocazione a parallelize. Dovrebbe anche poter essere usato per ottenere la versione parallela per il problema dell'esercizio precedente.

[CC]    Scrivere un programma che simula operazioni su un conto corrente da parte più thread concorrentemente. Il conto corrente è un oggetto con due metodi double getBalance e setBalance(double b). Ogni task legge l'attuale totale con getBalance e lo aggiorna con un versamento o un prelievo tramite setBalance. Il programma deve verificare che alla fine il saldo del conto corrente è corretto, cioè che risulti uguale alla somma algebrica del saldo iniziale e di tutti i versamenti e prelievi effettuati.

[Bonifici]    Usando conti correnti definiti come nell'esercizio precedente. Scrivere un programma che simula bonifici concorrenti tra due conti correnti, cioè un prelievo da un conto e un versamento di pari importo sull'altro conto. Il programma deve quindi usare due oggetti di tipo conto corrente. Inoltre deve verificare che alla fine i saldi dei due conti correnti siano corretti.

28 Apr 2015


  1. Ogni colonnina ha al massimo 15 tacche, se si sommano le tacche delle 8 colonnine relative ad uno dato secondo, queste non superano mai 15 o quasi.