Il capitolo in un paragrafo. Un monitor è un oggetto che incapsula stato, operazioni e politica di sincronizzazione. Le sue procedure si eseguono in mutua esclusione senza scrivere nulla, e per aspettare una condizione sullo stato si usano le variabili condizione: waitC sospende il processo e rilascia il monitor, signalC ne risveglia uno. Dopo una signalC un solo processo può proseguire, e la disciplina di segnalazione decide quale. Java adotta una variante di signal-and-continue: chi viene risvegliato ritrova il monitor quando la condizione può essere già cambiata. Per questo ogni attesa si scrive while (!condizione) wait(), con synchronized oppure con ReentrantLock e Condition.
I semafori sono di basso livello: la politica di sincronizzazione è sparsa nel codice dei processi (capitolo 5). Il monitor, introdotto da Brinch Hansen (1973) e generalizzato da Hoare (1974), è un'astrazione di dati che incapsula la politica di sincronizzazione e di mutua esclusione nell'accesso ai dati stessi: stato, operazioni e politica di concorrenza in un unico modulo. Generalizza due idee note:
monitor Counter { -- monitor MonitorName {
int count -- variabili permanenti
-- inizializzazione
procedure inc() { -- procedure (entry)
count := count + 1 -- }
}
procedure getValue(): int {
return count
}
}
Le proprietà di un monitor sono tre:
Counter.inc();A queste si aggiunge la mutua esclusione implicita: al più una procedura del monitor è attiva in ogni istante, e chi trova il monitor occupato viene sospeso. Il contatore è thread-safe senza nessuna wait o signal: le operazioni di uno stesso monitor sono atomiche l'una rispetto all'altra, mentre quelle di monitor diversi possono alternarsi. Non c'è una coda esplicita per chi aspetta di entrare, quindi la starvation resta possibile.
Nella progettazione il monitor è il componente passivo che i thread, componenti attivi, usano per coordinarsi, ed è ciò che chiede il primo assignment (capitolo 8).
La mutua esclusione non basta: un consumatore deve poter aspettare che il buffer non sia vuoto. Una variabile condizione, usabile solo dentro il monitor, rappresenta una condizione sullo stato: serve a sospendere un processo finché la condizione non diventa vera e a risvegliarlo quando lo diventa. Ha una coda FIFO di processi bloccati e due operazioni atomiche:
waitC(cond) = < append p to cond.queue
p.state := blocked
monitor.lock := release > -- rilascia il monitor
signalC(cond) = < if cond.queue != empty
q := remove head of cond.queue
q.state := ready >
Altre primitive: emptyC(cond) dice se la coda è vuota; signalAllC(cond) risveglia tutti i processi in coda; waitC(cond, rank) accoda in ordine di rank crescente; minrank(cond) restituisce il rank del primo della coda.
Il legame tra la variabile condizione e il suo monitor è essenziale: waitC rilascia il lock del monitor nello stesso passo atomico in cui sospende il processo. Se non lo rilasciasse, chi aspetta terrebbe il monitor occupato e nessuno potrebbe entrare a rendere vera la condizione: deadlock. Se lo rilasciasse in un passo separato, una signalC arrivata nell'intervallo troverebbe la coda vuota e andrebbe persa. Per questo in Java una Condition si ottiene dal proprio lock con lock.newCondition(), e wait() si chiama solo tenendo il lock dell'oggetto.
«Perché la condition variable deve essere legata alla lock?» è una domanda annunciata: la risposta è il paragrafo precedente.
L'esempio delle slide è una cella sincronizzata: get aspetta che un valore sia stato scritto.
monitor SynchCell {
int value
boolean available := false
cond isAvail
procedure set(int v) {
value := v
available := true
signalC(isAvail)
}
procedure get(): int {
if (!available)
waitC(isAvail)
return value
}
}
Con più lettori in attesa, signalC ne sveglia uno solo e gli altri restano bloccati anche se il valore è disponibile. Ci sono due rimedi. set può usare signalAllC(isAvail). Oppure ogni lettore risvegliato, appena ripreso, esegue a sua volta signalC(isAvail) e sveglia il successivo: è una segnalazione a cascata.
Dopo una signalC due processi potrebbero eseguire nel monitor, chi ha segnalato e chi è stato risvegliato, ma ne può proseguire uno solo. La disciplina di segnalazione decide quale, e con che precedenza rispetto a chi aspetta di entrare. Nelle slide S è la precedenza di chi ha segnalato, W di chi aspettava sulla condizione, E di chi è bloccato all'ingresso.
waitC esce nella coda W e libera il monitor. Con signal-and-continue il segnalante resta dentro e il risvegliato rientrerà più tardi. Con signal-and-wait e signal-and-urgent-wait il risvegliato entra subito e il segnalante aspetta fuori.| Disciplina | Che cosa succede alla signalC | Precedenze |
|---|---|---|
| Signal and Continue (non preemptive) | Chi segnala continua; il risvegliato eseguirà più tardi. | E < W < S |
| Signal and Wait (preemptive) | Il risvegliato esegue subito; chi ha segnalato aspetta, in competizione con chi vuole entrare. | E = S < W |
| Signal and Urgent Wait (Immediate Resumption Requirement) | Come Signal and Wait, ma chi ha segnalato ha la precedenza su chi vuole entrare. È la soluzione classica dei monitor di Hoare. | E < S < W |
La conseguenza pratica riguarda il test prima di waitC. Con Signal and Wait o Signal and Urgent Wait il risvegliato riparte subito: se si segnala solo a condizione vera, la condizione vale ancora e basta un if. Con Signal and Continue, tra la signalC e la ripresa il segnalante continua e possono entrare altri processi, che possono renderla di nuovo falsa: va ricontrollata.
if (!B) waitC(cond) -- basta con signal-and-wait / signal-and-urgent-wait
while (!B) waitC(cond) -- necessario con signal-and-continue (e in Java)
<accesso>
Il while è corretto con qualsiasi disciplina: nel caso peggiore costa un controllo in più.
Con un monitor si realizza qualsiasi risorsa condivisa incapsulandone la politica. Le slide rivedono così i problemi del capitolo 5 e aggiungono l'allocatore di risorse: request blocca il richiedente finché la risorsa non è disponibile secondo una politica, release sblocca le richieste in attesa.
monitor BoundedBuffer {
int[] elems := new int[MAX_ELEMS]
int first := 0, last := 0
cond notFull, notEmpty
procedure put(int elem) {
if ((last + 1) % MAX_ELEMS = first) -- pieno
waitC(notFull)
elems[last] := elem
last := (last + 1) % MAX_ELEMS
signalC(notEmpty)
}
procedure take(): int {
if (first = last) -- vuoto
waitC(notEmpty)
int elem := elems[first]
first := (first + 1) % MAX_ELEMS
signalC(notFull)
return elem
}
}
-- producer: loop el := produce; BoundedBuffer.put(el)
-- consumer: loop el := BoundedBuffer.take(); consume(el)
I processi si limitano a chiamare put e take: mutua esclusione e sincronizzazione stanno nel monitor. Con questo test di «pieno» l'array usa MAX_ELEMS − 1 posti, per distinguere pieno da vuoto. L'if presuppone la ripresa immediata; in Java diventa while (sezione 5).
Il monitor delle slide è pensato per Signal and Continue, quindi usa while. Il suo invariante è (nr = 0 ∨ nw = 0) ∧ nw ≤ 1.
monitor RWLock {
int nr := 0, nw := 0
cond okToRead, okToWrite
procedure request_read() {
while (nw > 0) waitC(okToRead)
nr := nr + 1
}
procedure release_read() {
nr := nr - 1
if (nr = 0) signalC(okToWrite)
}
procedure request_write() {
while (nr > 0 or nw > 0) waitC(okToWrite)
nw := nw + 1
}
procedure release_write() {
nw := nw - 1
signalC(okToWrite)
signalAllC(okToRead)
}
}
-- reader: RWLock.request_read(); read the dbase; RWLock.release_read()
-- writer: RWLock.request_write(); write the dbase; RWLock.release_write()
Il monitor non offre read e write, ma operazioni che aprono e chiudono l'accesso. Se la lettura fosse una procedura del monitor, la mutua esclusione implicita impedirebbe a due lettori di leggere insieme: si tornerebbe alla soluzione sovravincolata del capitolo 5, con il monitor occupato per tutta la durata di letture e scritture. Il monitor gestisce quindi solo il protocollo di accesso, i permessi contati da nr e nw, come un allocatore di risorse. L'accesso vero avviene fuori, tra le due chiamate, e ogni processo deve ricordarsi di rilasciare il permesso. ReentrantReadWriteLock segue lo stesso schema (capitolo 10).
Domanda annunciata: «lettori e scrittori: che operazioni mette in campo un monitor? read e write? Perché no?». La risposta è il paragrafo precedente.
La seconda soluzione delle slide (Ben-Ari) è scritta per la ripresa immediata, e usa if e le code delle condizioni per decidere a chi tocca:
monitor RWLock {
integer readers := 0, writers := 0
cond okToRead, okToWrite
procedure startRead() {
if writers != 0
waitC(okToRead)
readers := readers + 1
signalC(okToRead) -- a cascata: sveglia il prossimo lettore
}
procedure endRead() {
readers := readers - 1
if readers = 0
signalC(okToWrite)
}
procedure startWrite() {
if writers != 0 or readers != 0
waitC(okToWrite)
writers := writers + 1
}
procedure endWrite() {
writers := writers - 1
if emptyC(okToRead)
then signalC(okToWrite)
else signalC(okToRead) -- i lettori in attesa hanno la precedenza
}
}
Nel libro di Ben-Ari startRead si blocca anche se ci sono scrittori in attesa: if writers != 0 or not emptyC(okToWrite). Con quella condizione in più un nuovo lettore non scavalca uno scrittore che aspetta, e si evita la starvation degli scrittori vista con i semafori (capitolo 5). È il vantaggio dei monitor: una politica di equità diventa una condizione esplicita, scritta in un solo punto.
Java offre due modi per scrivere un monitor: i meccanismi di base del linguaggio (synchronized e i metodi wait, notify, notifyAll di Object) oppure ReentrantLock e Condition di java.util.concurrent. Non vanno mescolati nella stessa classe.
synchronized, wait, notifyOgni oggetto Java ha un lock intrinseco, detto monitor lock (le istruzioni bytecode di un blocco synchronized sono monitorenter e monitorexit). Un oggetto segue il monitor pattern se incapsula tutto il suo stato mutabile e lo protegge con il proprio lock:
synchronized;wait fa da waitC, notify e notifyAll da signalC e signalAllC.Per ogni oggetto la JVM tiene l'entry set, i thread in attesa del lock, e il wait set, quelli che hanno eseguito wait. Sono insiemi, non code: nessun ordine garantito.
| Metodo | Semantica |
|---|---|
wait() | Se il thread è già stato interrotto, lancia subito InterruptedException. Altrimenti mette il thread nel wait set e rilascia il lock di quell'oggetto, per intero anche se è tenuto in modo rientrante. Gli altri lock tenuti dal thread restano presi. Alla ripresa il lock viene riacquisito con lo stesso numero di acquisizioni. |
notify() | Toglie dal wait set un solo thread, scelto arbitrariamente. Il thread risvegliato deve riacquisire il lock: aspetta almeno che chi ha notificato esca, e può perdere la gara con altri thread. Poi riprende dopo la sua wait. Se il wait set è vuoto, la notifica si perde. |
notifyAll() | Come notify, ma per tutti i thread del wait set. Poiché devono riacquisire il lock, ripartono uno alla volta. |
wait(ms) | Come wait, ma esce da solo allo scadere del tempo, senza dire se per timeout o per notifica. wait(0) equivale a wait(). |
Si chiamano solo tenendo il lock dell'oggetto, altrimenti si ottiene IllegalMonitorStateException. Un interrupt() su un thread in wait agisce come una notifica, ma dopo aver riacquisito il lock il thread esce con InterruptedException e il flag di interruzione torna falso. È il caso in cui wait, come sleep e join, lancia InterruptedException. Un thread bloccato all'ingresso di un blocco synchronized, invece, non si può interrompere.
while e notifyAllLa semantica di Java, con synchronized come con ReentrantLock, è una variante di Signal and Continue con E = W < S: chi notifica continua, e il risvegliato compete alla pari con chi vuole entrare. Nell'esperimento delle lab notes T1 entra ed esegue await; T2 entra, dorme cinque secondi, esegue signal ed esce; nel frattempo T3 si è bloccato all'ingresso.
First thread inside, going to wait
Second thread inside
Third thread started.
Second thread inside, going to signal
Second thread inside signaled.
Third thread inside. <- T3 entra prima di T1, già risvegliato
First thread unblocked.
In più il meccanismo di base ha una sola variabile condizione, quindi produttori e consumatori aspettano nello stesso wait set, e la specifica ammette i risvegli spuri: «un thread può svegliarsi anche senza essere stato notificato, interrotto o senza timeout». Lo schema sicuro è quindi un while sulla condizione specifica e notifyAll. Con notify si può svegliare il thread sbagliato. Buffer da un posto, due consumatori in attesa: il produttore inserisce, sveglia C1, riprova a inserire e aspetta perché il buffer è pieno. C1 preleva e con notify sveglia C2 invece del produttore; C2 trova il buffer vuoto e torna ad aspettare, C1 al giro dopo pure. Ora tutti e tre aspettano, e nessuno notificherà più.
«Perché in Java si usa while e non if? Quando si può usare if?» Perché con E = W < S il risvegliato riparte dopo il segnalante e magari dopo thread appena entrati, quando la condizione può essere tornata falsa, e perché esistono i risvegli spuri. L'if basterebbe solo con la ripresa immediata di Signal and Wait o Signal and Urgent Wait, che Java non offre. E attenzione: notify sveglia un thread, notifyAll tutti.
Un solo wait set per produttori e consumatori, quindi notifyAll e while. Scarica MonitorBuffer.java.
public final class MonitorBuffer<T> {
private final Object[] buffer;
private int first, last, count;
public MonitorBuffer(int capacity) {
buffer = new Object[capacity];
}
public synchronized void put(T item) throws InterruptedException {
while (count == buffer.length) {
wait();
}
buffer[last] = item;
last = (last + 1) % buffer.length;
count++;
notifyAll();
}
@SuppressWarnings("unchecked")
public synchronized T get() throws InterruptedException {
while (count == 0) {
wait();
}
T item = (T) buffer[first];
buffer[first] = null;
first = (first + 1) % buffer.length;
count--;
notifyAll();
return item;
}
}Due condizioni dallo stesso lock: si sveglia solo il lato interessato. Scarica ConditionBuffer.java.
public final class ConditionBuffer<T> {
private final Object[] buffer;
private int first, last, count;
private final ReentrantLock mutex = new ReentrantLock();
private final Condition notFull = mutex.newCondition();
private final Condition notEmpty = mutex.newCondition();
public ConditionBuffer(int capacity) {
buffer = new Object[capacity];
}
public void put(T item) throws InterruptedException {
mutex.lockInterruptibly();
try {
while (count == buffer.length) {
notFull.await();
}
buffer[last] = item;
last = (last + 1) % buffer.length;
count++;
notEmpty.signal();
} finally {
mutex.unlock();
}
}
@SuppressWarnings("unchecked")
public T get() throws InterruptedException {
mutex.lockInterruptibly();
try {
while (count == 0) {
notEmpty.await();
}
T item = (T) buffer[first];
buffer[first] = null;
first = (first + 1) % buffer.length;
count--;
notFull.signal();
return item;
} finally {
mutex.unlock();
}
}
}ReentrantLock e ConditionUn ReentrantLock per monitor, e il corpo di ogni metodo pubblico tra lock() e unlock(), con unlock() in un finally; niente synchronized. Le condizioni si creano dal lock con newCondition(), quante se ne vuole, e await, signal, signalAll fanno da waitC, signalC, signalAllC. Il vantaggio è la segnalazione mirata: put sveglia solo i consumatori, get solo i produttori. La disciplina resta E = W < S, e il while resta obbligatorio.
unlock(). Anche synchronized è rientrante; i semafori no.new ReentrantLock(true) favorisce chi aspetta da più tempo, il default non garantisce alcun ordine.lockInterruptibly() e tryLock() permettono di rinunciare all'attesa del lock, cosa impossibile con synchronized (capitolo 6).wait() o notifyAll() su una Condition. Compila, perché sono metodi di Object, ma a runtime lancia IllegalMonitorStateException; i metodi giusti sono await() e signalAll().Una procedura che chiama un altro monitor tiene il proprio lock per tutta la chiamata: se il secondo monitor è occupato, anche il primo resta bloccato per tutti. Se un altro thread fa il percorso inverso si ha il deadlock di ordinamento del capitolo 6, come nel pattern Observer. E se la procedura interna esegue wait, rilascia solo il lock interno: nessuno può entrare nel monitor esterno a rendere vera la condizione (nested monitor lockout). Le chiamate ad altri monitor da dentro un monitor vanno quindi evitate o tenute sotto stretto controllo.
Monitor e semafori hanno la stessa potenza espressiva: ciascuno si realizza con l'altro. Un semaforo come monitor, nelle due versioni delle slide:
monitor Semaphore { monitor Semaphore {
integer s := <InitValue> integer s := <InitValue>
cond notZero cond notZero
procedure wait() { procedure wait() {
if s = 0 if s = 0
waitC(notZero) waitC(notZero) -- riceve il permesso
s := s - 1 else
} s := s - 1
procedure signal() { }
s := s + 1 procedure signal() {
signalC(notZero) if emptyC(notZero)
} s := s + 1
} else
signalC(notZero) -- passa il permesso
}
}
La prima versione usa if, quindi presuppone la ripresa immediata; con Signal and Continue serve while s = 0. La seconda passa il permesso direttamente al processo risvegliato, esattamente come la signal del semaforo.
Un semaforo mutex garantisce la mutua esclusione (wait(mutex) all'inizio di ogni procedura, signal(mutex) alla fine). Ogni variabile condizione diventa un semaforo condsem, inizializzato a 0, con un contatore condcount dei processi sospesi.
-- Signal and Continue -- Signal and Wait
waitC(cond) = waitC(cond) =
condcount++ condcount++
signal(mutex) signal(mutex)
wait(condsem) wait(condsem) -- eredita il mutex
wait(mutex) signalC(cond) =
signalC(cond) = if condcount > 0
if condcount > 0 condcount--
condcount-- signal(condsem)
signal(condsem) wait(mutex) -- il segnalante aspetta
Nella versione Signal and Wait il segnalante non rilascia mutex: il permesso di stare nel monitor passa direttamente al risvegliato. Nella versione Signal and Continue un processo appena entrato può consumare il permesso di condsem destinato a chi aspettava. Con i while di Signal and Continue non è un errore: è solo un altro modo in cui la condizione può cambiare prima della ripresa.
A parità di potenza i monitor sono preferibili perché di livello più alto: la mutua esclusione è implicita e non si può dimenticare un rilascio, la politica sta in un solo punto dentro la risorsa invece che sparsa nei processi, e l'incapsulamento avvicina l'implementazione al design. Le variabili condizione però non sono semafori:
| Semaforo | Variabile condizione |
|---|---|
wait può non bloccare, se S.V > 0. | waitC blocca sempre. |
signal ha sempre un effetto: sveglia qualcuno o incrementa S.V. | signalC non ha effetto se la coda è vuota: il segnale si perde. |
signal sblocca un processo arbitrario. | signalC sblocca il primo della coda. |
| Il processo sbloccato può riprendere subito. | Secondo la disciplina, il processo sbloccato può dover aspettare che il segnalante lasci il monitor. |
Per questo una variabile condizione va sempre accompagnata da una condizione esplicita sullo stato (available, count, …): è lo stato, non la variabile, a ricordare che cosa è successo.
Con i monitor si costruiscono componenti di coordinazione riutilizzabili; le slide elencano latch, barriere, rendez-vous, message box, blackboard ed event service. In laboratorio si sono scritti la barriera e il latch.
Una barriera è un punto di sincronizzazione simmetrico. N processi eseguono una prima fase, e nessuno comincia la seconda finché tutti non hanno finito la prima. Il monitor deve contare gli arrivati: chi arriva e non trova tutti aspetta, e l'ultimo sveglia tutti. Un latch, o gate, è un cancello con due ruoli distinti. Chi chiama await aspetta che il cancello si apra. I processi che passano chiamano countDown senza mai bloccarsi, e dopo N countDown il cancello si apre e resta aperto per sempre. In breve, le barriere servono ad aspettare altri thread, i latch ad aspettare eventi.
public final class Barrier {
private final int nParticipants;
private int nArrived = 0;
private final ReentrantLock lock = new ReentrantLock();
private final Condition allArrived = lock.newCondition();
public Barrier(int nParticipants) { this.nParticipants = nParticipants; }
public void hitAndWaitAll() throws InterruptedException {
lock.lock();
try {
nArrived++;
if (nArrived == nParticipants) {
allArrived.signalAll(); // l'ultimo sveglia tutti
}
while (nArrived < nParticipants) {
allArrived.await();
}
} finally {
lock.unlock();
}
}
}
public final class Latch {
private final int nCountDowns;
private int nCounts = 0;
public Latch(int nCountDowns) { this.nCountDowns = nCountDowns; }
public synchronized void countDown() { // chi passa: non si blocca
nCounts++;
if (nCounts == nCountDowns) {
notifyAll(); // il cancello si apre
}
}
public synchronized void await() throws InterruptedException {
while (nCounts < nCountDowns) {
wait();
}
}
}
Nel latch si notifica solo quando il conteggio arriva a N. Notificare a ogni countDown sarebbe corretto, grazie al while, ma sveglierebbe chi aspetta solo per farlo riaddormentare. La barriera così scritta si usa una volta sola. Renderla ciclica non è banale. Azzerare nArrived quando arrivano tutti non basta, perché un thread veloce può rientrare nel giro successivo prima che quelli del giro precedente si siano risvegliati. Servono le «generazioni» di java.util.concurrent.CyclicBarrier, che con CountDownLatch è la versione di libreria di questi componenti (capitolo 10).
Un'astrazione di dati che incapsula stato, operazioni e politica di sincronizzazione e mutua esclusione. Dall'esterno si vedono solo le procedure; il codice interno non accede a variabili esterne; le variabili permanenti sono inizializzate prima di ogni chiamata; al più una procedura è attiva alla volta.
waitC e signalC, e come si chiamano in Java?waitC sospende il processo nella coda della condizione e rilascia il monitor. signalC risveglia il primo processo in coda, e non fa nulla se la coda è vuota. In Java sono wait e notify (o notifyAll) sul lock intrinseco, oppure await e signal (o signalAll) su una Condition.
signalC?Dipende dalla disciplina. Con Signal and Continue (E < W < S) prosegue chi ha segnalato. Con Signal and Wait (E = S < W) prosegue il risvegliato, e il segnalante compete con chi vuole entrare. Con Signal and Urgent Wait (E < S < W) prosegue il risvegliato, e il segnalante ha la precedenza su chi vuole entrare.
Una variante di Signal and Continue con E = W < S: chi notifica continua, e il risvegliato compete alla pari con i thread che vogliono entrare. Un thread appena arrivato può entrare prima di lui e cambiare lo stato, quindi le attese vanno scritte con while.
notifyAll e non notify nel buffer con synchronized?Produttori e consumatori aspettano nello stesso wait set. notify sveglia un solo thread arbitrario, che può essere del tipo sbagliato: si rimette ad aspettare, la notifica è sprecata e si può arrivare a uno stato in cui tutti aspettano.
read e write?Perché le procedure del monitor si eseguono in mutua esclusione: le letture sarebbero serializzate. Il monitor gestisce solo i permessi (startRead/endRead, startWrite/endWrite), e l'accesso vero avviene fuori.
Che il thread che lo possiede può riacquisirlo senza bloccarsi. Il lock conta le acquisizioni e si libera dopo altrettanti rilasci. Non ha a che fare con l'equità, che in ReentrantLock è un'opzione separata del costruttore.
Si tiene il primo lock mentre si aspetta il secondo. Con chiamate in ordine opposto si ha un deadlock, e se il monitor interno fa wait rilascia solo il proprio lock, lasciando bloccato quello esterno.
La barriera è simmetrica: tutti arrivano, aspettano gli altri e ripartono insieme. Il latch ha due ruoli: chi fa await aspetta che il cancello si apra, chi fa countDown passa senza bloccarsi. Una volta aperto, il latch resta aperto.
27 febbraio 2026 (§19: anticipazione) · 2 marzo (laboratorio: lock rientranti) · 6 marzo (§17–21: monitor, variabili condizione, discipline di segnalazione, buffer e lettori-scrittori, monitor con semafori) · 13 marzo (§15–18: discipline, if e while, barriere e latch) · 16 marzo (§9–14 e laboratorio: wait/notify, ReentrantLock e Condition, esercizi su barriera e latch, esperimento sulla semantica di Java) · 20 marzo (§15–17: ripasso; chiamate annidate tra monitor) · 27 marzo (§17–18: ripasso). La mappa completa è in coverage-matrix.json.