20.11.2020

foto Petr Bravenec

Petr Bravenec
Twitter: @BravenecPetr
+420 777 566 384
petr.bravenec@hobrasoft.cz

Když se pustíte do vícevláknového nebo i paralelního programování, záhy musíte řešit i sdílení dat mezi vlákny. Problematické jsou zvláště situace, kdy se snaží několik vláken měnit stejná data. Pokud se změny sejdou v jeden okamžik, data se zákonitě pokazí a aplikace přestane fungovat. Data mohou být uložená ve složitých strukturách (QHash, QMap a podobně), takže v době změn může být nebezpečné i čtení. Aby se jednotlivá vlákna ochránila před nežádoucími efekty při sdíleném přístupu k datům, používá se v aplikacích zamykání — v Qt se typicky zamyká třídou QMutex.

Paralelní přístupy existují typicky dva

1. Paralelní programování

Úlohu lze snadno rozdělit na velké množství samostatných jednotek, které lze zpracovávat nezávisle. Názorným příkladem je třeba úprava (zmenšení) tisícovky obrázků na disku. Takovou úlohu lze snadno provést paralelně, množství sdílených dat přitom zůstává malé, pokud vůbec nějaká jsou. Zamykání obvykle není třeba řešit.

Příklady:

2. Programování ve více vláknech

Úlohu nelze zpracovat paralelně, zpracování relativně dlouho trvá a v době zpracování musí být aplikace schopná odpovídat na další požadavky. Déle trvající zpracování se proto odsune do jiného vlákna, hlavní část aplikace mezitím může pracovat na jiných úlohách. Tento typ úlohy často může mezi vlákny sdílet velká množství dat. Zde bývá zamykání nutností.

Příklady:

QMutex

QMutex je jednoduchá třída, která dovoluje zamykání mezi různými vlákny.

Dobře je zamykání vysvětleno v dokumentaci Qt ke třídě QMutex. V našich aplikacích vypadá typické použití mutexu trochu jinak, užitečným pomocníkem je přitom třída QMutexLocker. Třída QMutexLocker slouží k automatickému odemčení mutexu po dokončení funkce:

class Vypocet {
    public:
        double  data(const QDateTime& t) const;

    private slots:
        void    vypocet();
        QHash    m_data;
        mutable QMutex m_mutex;     // Bez mutable by nemohla být metoda data() deklarována jako const
};

....
double Vypocet::data(const QDateTime& t) const {
    QMutexLocker locker(&m_mutex);
    return data[t];
}

void Vypocet::vypocet()  {
    QMutexLocker locker(&m_mutex);
    QHashIterator iterator(m_data);
    while (iterator.hasNext()) {
        ... provádění výpočtu, mění m_data
        }
}
Díky zamykání je zajištěno, že všechny přístupy do datové struktury m_data budou probíhat sekvenčně, za sebou. To je řešení, které perfektně vyhovuje pro množství situací. S přibývajícími požadavky na přístup k datům pomocí metody data() však začně být aplikace stále pomalejší. Navíc se při takto naprogramovaném zamykání nedají volat různé přístupové funkce rekurzivně. Zamykání původně vícevláknovou aplikaci spolehlivě zjednovlákní. A to je třeba zmínit i fakt, že v momentě, kdy se objeví deadlock — různá vlákna se uzamknou navzájem, je konec a aplikace zamrzne. Jednoduchá úvaha pak vede k myšlence, že pokud data neměníme, tj. přístupová metoda sdílená data pouze čte, je zamykání zhola zbytečné. Ostatní vlákna, která také jenom čtou, mohou sdílená data číst ve stejný okamžik, aniž by se vlákna mezi sebou jakkoliv ovlivňovala. Zámek je nutný pouze při změně sdílených dat!

QSemaphore

Dokumentace Qt přestavuje třídu QSemaphore jako zobecnění mutexu. Třída QMutex nese pouze informaci zamčeno × odemčeno. Třída QSemaphore naproti tomu nese i informaci kolikrát celkově lze semafor uzamknout a kolik volných zámků zbývá. Pokud chceme naše data zamknout pouze v případě změny, můžeme použít QSemaphore a třídu Vypocet upravit:

class Vypocet {
    public:
        double  data(const QDateTime& t) const;
    
    private slots:
        void vypocet();
        QHash m_data;
        mutable QSemaphore m_semaphore(1000);   // Semafor lze 1000× uzamknout, než dojde k zablokování
};

....
double Vypocet::data(const QDateTime& t) const {
    m_semaphore.acquire(); // Zamkne pouze jeden zámek, ještě 999 zbývá...
    double r = data[t];
    m_semaphore.release();
    return r;
}

void Vypocet::vypocet()  {
    m_semaphore.acquire(1000); // Zamkne všech tisíc zámků, žádný další už nelze použít
    QHashIterator iterator(m_data);
    while (iterator.hasNext()) {
        ... provádění výpočtu, mění m_data
        }
    m_semaphore.release(1000);
}

Co se při výpočtu děje? Dokud přicházejí požadavky pouze pro čtení, zde metoda data(), nedochází k zablokování při pokusu o uzamčení, protože počet zámků je nastavený poměrně velkoryse (zde tisíc zámků). Požadavky na čtení se vyřizují paralelně, nedochází k ovlivňování.

Jakmile se spustí metoda vypocet(), provádění se zarazí hned na prvním řádku m_semaphore.acquire(1000). Dokud nejsou ukončené všechny požadavky na čtení, nelze semafor uzamknout pro zápis (tj. zabrat všech tisíc zámků najednou).

Jakmile se však podaří uzamknout všech tisíc zámků semaforu, nelze už semafor uzamknout ani jednou — čtení dat není možné. V tuto chvíli může začít program měnit sdílená data bez nebezpečí, že se to nějak dotkne ostatních vláken. Jakmile jsou změny hotové, uvolní se všech tisíc zámků a aplikace může pokračovat ve své běžné ReadOnly práci.

Hobrasoft s.r.o. | Kontakt