I. L'article original

Qt Quarterly est une revue trimestrielle électronique proposée par Nokia à destination des développeurs et utilisateurs de Qt. Vous pouvez trouver les versions originales.

Nokia, Qt, Qt Quarterly et leurs logos sont des marques déposées de Nokia Corporation en Finlande et/ou dans les autres pays. Les autres marques déposées sont détenues par leurs propriétaires respectifs.

Cet article est la traduction de l'article Implementing a Read/Write MutexImplementing a Read/Write Mutex, par Volker Hilsheimer, paru dans la Qt Quarterly Issue 11.

Cet article est une traduction d'un des tutoriels écrits par Nokia Corporation and/or its subsidiary(-ies) inclus dans la documentation de Qt, en anglais. Les éventuels problèmes résultant d'une mauvaise traduction ne sont pas imputables à Nokia.

II. Introduction

Dans cet article, nous allons passer en revue les problèmes rencontrés lors de l'implémentation d'un mutex en lecture/écriture, un outil de synchronisation pour protéger les ressources pouvant être accédées pour la lecture et pour l'écriture. Nous voulons permettre à de multiples threads d'avoir des accès simultanés en lecture seule, mais dès que l'un des thread voudra écrire dans la ressource, les autres threads devront être bloqués jusqu'à ce que l'écriture soit terminée.

III. La compréhension des mutex et des sémaphores

La compréhension est une épée à trois tranchants. - Kosh

L'outil de synchronisation le plus basique est le mutex. Un mutex garantit un accès mutuellement exclusif à une ressource partagée à tout moment donné. Les threads qui veulent accéder à une ressource protégée par un mutex doivent attendre jusqu'à ce que le thread actuellement actif se termine et déverrouille le mutex. Les mutex sont simples à utiliser, mais peuvent considérablement ralentir un code threadé quand ils sont trop utilisés.

Un autre outils fréquemment utilisé est la sémaphore. Les sémaphores représentent les « ressources disponibles » pouvant être acquises par de multiples threads en même temps, jusqu'à ce que le regroupement de ressources soit vide. Les threads additionnels doivent alors attendre jusqu'à ce que le nombre requis de ressources soit de nouveau disponible. Les sémaphores sont très efficaces, comme elles permettent des accès simultanés aux ressources. Mais une utilisation abusive conduit souvent à une famine de threads, ou à des blocages - où deux threads se bloquent indéfiniment, chacun attendant une ressource que l'autre thread a verrouillée.

Avec Qt, les mutex et les sémaphores sont fournis par les classe QMutexQMutex et QSemaphoreQSemaphore.

IV. Lecteurs mutuellement exclusifs

Supposons que nous avons un ou plusieurs threads en lecture en train de lire un fichier, et que un ou plusieurs threads en écriture sont en train d'écrire dans le même fichier. Pour garantir que le fichier n'est pas modifié pendant qu'un thread est en train de le lire, nous pouvons utiliser un mutex. Par exemple :

 
Sélectionnez
QMutex mutex;void ReaderThread::run()
{
	...
	mutex.lock();
	read_file();
	mutex.unlock();
	...
}void WriterThread::run()
{
	...
	mutex.lock();
	write_file();
	mutex.unlock();
	...
}

Le mutex garantit qu'un seul thread à la fois pourra accéder au fichier. Cela fonctionne, mais si de multiples threads en lecture s'exécutent en même temps, les threads vont rapidement se terminer en dépensant un grand pourcentage de leur temps d'exécution à attendre que le mutex soit de nouveau disponible.

V. Rapide, mais pas équitable

La solution suivante utilise une sémaphore à la place d'un mutex pour fournir des accès simultanés au fichier aux threads en lecture.

 
Sélectionnez
const int MaxReaders = 32;
QSemaphore semaphore(MaxReaders);void ReaderThread::run()
{
	...
	semaphore++;
	read_file();
	semaphore--;
	...
}void WriterThread::run()
{
	...
	semaphore += MaxReaders;
	write_file();
	semaphore -= MaxReaders;
	...
}

Avec cette solution, jusqu'à MaxReaders threads peuvent lire le fichier en même temps. Dès qu'un thread en écriture voudra modifier le fichier, il essaiera d'allouer toutes les ressources de la sémaphore, donc rendant sûr le fait qu'aucun autre thread ne puisse accéder au fichier durant l'opération d'écriture.

Cette approche semble raisonnable, mais elle a un problème. La chance du thread en écriture d'accéder à toutes les ressources diminue rapidement avec un nombre croissant de threads en lecture, jusqu'au point où le thread en écriture mourra complètement de faim. C'est le cas parce que l'opérateur += de QSemaphoreQSemaphore bloque jusqu'à ce que le compteur de la sémaphore soit à zéro, au lieu de permettre un accès ressource par ressource, de la même manière qu'elles sont libérées par les threads lecteurs.

En d'autres mots, QSemaphoreQSemaphore favorise les threads qui requièrent peu de ressources. Cela est « injuste », mais empêche un blocage, comme nous allons le voir dans le prochain exemple.

VI. Équitable, mais équitablement limité

Il est attrayant de remplacer l'opérateur += par une boucle qui appelle l'opérateur ++ à plusieurs reprises pour résoudre le problème décrit ci-dessus :

 
Sélectionnez
void WriterThread::run()
{
	...
	for (int i = 0 ; i < MaxReaders ; ++i)
	semaphore++;
	write_file();
	semaphore -= MaxReaders;
	...
}

Maintenant, le thread en écriture a une chance plus juste d'allouer toutes les ressources. Mais dès qu'il y aura deux threads en écriture se faisant concurrence pour la même sémaphore, il serait possible de terminer avec un blocage, avec chaque thread possédant des ressources, mais n'étant pas en mesure d'obtenir toutes les ressources qu'il nécessite.

VII. Partager et apprécier

Pour éliminer cette limitation, nous devons introduire un thread en addition à la sémaphore. Le mutex est uniquement basé sur les threads en écriture :

 
Sélectionnez
void WriterThread::run()
{
	...
	mutex.lock();
	for (int i = 0; i < MaxReaders; ++i)
	semaphore++;
	mutex.unlock();
	write_file();
	semaphore -= MaxReaders;
	...
}

Dès qu'un thread en écriture commence à allouer des ressources depuis la sémaphore, les autres threads en écriture devront attendre. Les threads en lecture n'utilisent pas le mutex, donc ils peuvent travailler simultanément pendant que le thread en écriture est inactif. Une fois que le thread en écriture commence à travailler, il aura une chance juste d'acquérir la sémaphore.

VIII. Une classe mutex en lecture et en écriture

Nous pouvons encapsuler notre solution dans une classe ReadWriteMutex :

 
Sélectionnez
class ReadWriteMutex
{
public:
	ReadWriteMutex(int maxReaders = 32)
	: semaphore(maxReaders)
	{
	}
	
	void lockRead() { semaphore++; }
	void unlockRead() { semaphore--; }
	void lockWrite() {
		QMutexLocker locker(&mutex);
		for (int i = 0; i < maxReaders(); ++i)
			semaphore++;
		}
	void unlockWrite() { semaphore -= semaphore.total(); }
	int maxReaders() const { return semaphore.total(); }private:
	QSemaphore semaphore;
	QMutex mutex;
};

Voici une manière de l'utiliser dans des applications :

 
Sélectionnez
ReadWriteMutex mutex;void ReaderThread::run()
{
	...
	mutex.lockRead();
	read_file();
	mutex.unlockRead();
	...
}void WriterThread::run()
{
	...
	mutex.lockWrite();
	write_file();
	mutex.unlockWrite();
	...
}

Pour votre confort, la classe ReadWriteMutex développée dans cet article sera disponible dans Qt Solution, avec une classe ReadWriteMutexLocker.

IX. Conclusion

Une synchronisation correcte de l'accès aux ressources partagées par de multiples threads est l'un des aspects les plus importants de la programmation threadée. Une synchronisation manquante ou insuffisante produira des résultats imprévisibles, et dans le pire des cas un crash de l'application.

En même temps, une synchronisation trop ou improprement utilisée engendrera le fait que les threads dépenseront inutilement un temps en étant bloqués, réduisant ainsi de manière significative le bénéfice d'un développement threadé.

X. Remerciements

J'adresse ici de chaleureux remerciements à Thibaut Cuvelier pour sa relecture !

Au nom de toute l'équipe Qt, j'aimerais adresser le plus grand remerciement à Nokia pour nous avoir autorisé la traduction de cet article !