Premiers pas avec le multithreading
Interblocages
Un blocage se produit lorsque chaque membre d’un groupe de deux threads ou plus doit attendre que l’un des autres membres fasse quelque chose (par exemple, pour libérer un verrou) avant de pouvoir continuer. Sans intervention, les threads attendront indéfiniment.
Un exemple de pseudocode d’une conception sujette aux blocages est :
thread_1 {
acquire(A)
...
acquire(B)
...
release(A, B)
}
thread_2 {
acquire(B)
...
acquire(A)
...
release(A, B)
}
Un blocage peut se produire lorsque thread_1
a acquis A
, mais pas encore B
, et thread_2
a acquis B
, mais pas A
. Comme le montre le diagramme suivant, les deux threads attendront indéfiniment.
Comment éviter les blocages
En règle générale, minimisez l’utilisation des verrous et minimisez le code entre le verrouillage et le déverrouillage.
Acquérir des verrous dans le même ordre
Une refonte de thread_2
résout le problème :
thread_2 {
acquire(A)
...
acquire(B)
...
release(A, B)
}
Les deux threads acquièrent les ressources dans le même ordre, évitant ainsi les blocages.
Cette solution est connue sous le nom de “Solution de hiérarchie des ressources”. Il a été proposé par Dijkstra comme solution au “problème des philosophes de la restauration”.
Parfois, même si vous spécifiez un ordre strict pour l’acquisition des verrous, un tel ordre d’acquisition des verrous statiques peut être rendu dynamique au moment de l’exécution.
Considérez le code suivant :
void doCriticalTask(Object A, Object B){
acquire(A){
acquire(B){
}
}
}
Ici, même si l’ordre d’acquisition du verrou semble sûr, il peut provoquer un blocage lorsque thread_1 accède à cette méthode avec, par exemple, Object_1 comme paramètre A et Object_2 comme paramètre B et que thread_2 le fait dans l’ordre opposé, c’est-à-dire Object_2 comme paramètre A et Object_1 comme paramètre B.
Dans une telle situation, il est préférable d’avoir une condition unique dérivée en utilisant à la fois Object_1 et Object_2 avec un certain type de calcul, par ex. en utilisant le hashcode des deux objets, donc chaque fois qu’un thread différent entre dans cette méthode dans n’importe quel ordre paramétrique, chaque fois que cette condition unique dérivera l’ordre d’acquisition du verrou.
par exemple. Say Object a une clé unique, par ex. accountNumber en cas d’objet Compte.
void doCriticalTask(Object A, Object B){
int uniqueA = A.getAccntNumber();
int uniqueB = B.getAccntNumber();
if(uniqueA > uniqueB){
acquire(B){
acquire(A){
}
}
}else {
acquire(A){
acquire(B){
}
}
}
}
Conditions de course
Une course aux données ou race condition est un problème qui peut survenir lorsqu’un programme multithread n’est pas correctement synchronisé. Si deux ou plusieurs threads accèdent à la même mémoire sans synchronisation et qu’au moins l’un des accès est une opération « d’écriture », une course aux données se produit. Cela conduit à un comportement dépendant de la plate-forme, voire incohérent, du programme. Par exemple, le résultat d’un calcul peut dépendre de l’ordonnancement des threads.
Problème des lecteurs-écrivains :
writer_thread {
write_to(buffer)
}
reader_thread {
read_from(buffer)
}
Une solution simple :
writer_thread {
lock(buffer)
write_to(buffer)
unlock(buffer)
}
reader_thread {
lock(buffer)
read_from(buffer)
unlock(buffer)
}
Cette solution simple fonctionne bien s’il n’y a qu’un seul thread lecteur, mais s’il y en a plusieurs, cela ralentit inutilement l’exécution, car les threads lecteurs pourraient lire simultanément.
Une solution qui évite ce problème pourrait être :
writer_thread {
lock(reader_count)
if(reader_count == 0) {
write_to(buffer)
}
unlock(reader_count)
}
reader_thread {
lock(reader_count)
reader_count = reader_count + 1
unlock(reader_count)
read_from(buffer)
lock(reader_count)
reader_count = reader_count - 1
unlock(reader_count)
}
Notez que reader_count
est verrouillé pendant toute l’opération d’écriture, de sorte qu’aucun lecteur ne peut commencer à lire tant que l’écriture n’est pas terminée.
Maintenant, de nombreux lecteurs peuvent lire simultanément, mais un nouveau problème peut survenir : le reader_count
peut ne jamais atteindre 0
, de sorte que le thread d’écriture ne peut jamais écrire dans le tampon. C’est ce qu’on appelle la famine, il existe différentes solutions pour l’éviter.
Même des programmes qui peuvent sembler corrects peuvent être problématiques :
boolean_variable = false
writer_thread {
boolean_variable = true
}
reader_thread {
while_not(boolean_variable)
{
do_something()
}
}
Le programme d’exemple peut ne jamais se terminer, car le thread de lecture peut ne jamais voir la mise à jour du thread d’écriture. Si, par exemple, le matériel utilise des caches CPU, les valeurs peuvent être mises en cache. Et comme une écriture ou une lecture dans un champ normal ne conduit pas à un rafraîchissement du cache, la valeur modifiée peut ne jamais être vue par le thread de lecture.
C++ et Java définissent dans le soi-disant modèle de mémoire, ce que signifie correctement synchronisé : Modèle de mémoire C++ , Modèle de mémoire Java.
En Java, une solution serait de déclarer le champ comme volatile :
volatile boolean boolean_field;
En C++, une solution serait de déclarer le champ comme atomique :
std::atomic<bool> data_ready(false)
Une course aux données est une sorte de condition de course. Mais toutes les conditions de course ne sont pas des courses de données. Ce qui suit, appelé par plusieurs threads, conduit à une condition de concurrence mais pas à une course aux données :
class Counter {
private volatile int count = 0;
public void addOne() {
i++;
}
}
Il est correctement synchronisé selon la spécification Java Memory Model, il n’y a donc pas de course aux données. Mais cela conduit toujours à des conditions de course, par ex. le résultat dépend de l’entrelacement des threads.
Toutes les courses de données ne sont pas des bogues. Un exemple d’une soi-disant condition de concurrence bénigne est le sun.reflect.NativeMethodAccessorImpl :
class NativeMethodAccessorImpl extends MethodAccessorImpl {
private Method method;
private DelegatingMethodAccessorImpl parent;
private int numInvocations;
NativeMethodAccessorImpl(Method method) {
this.method = method;
}
public Object invoke(Object obj, Object[] args)
throws IllegalArgumentException, InvocationTargetException
{
if (++numInvocations > ReflectionFactory.inflationThreshold()) {
MethodAccessorImpl acc = (MethodAccessorImpl)
new MethodAccessorGenerator().
generateMethod(method.getDeclaringClass(),
method.getName(),
method.getParameterTypes(),
method.getReturnType(),
method.getExceptionTypes(),
method.getModifiers());
parent.setDelegate(acc);
}
return invoke0(method, obj, args);
}
...
}
Ici, les performances du code sont plus importantes que l’exactitude du nombre de numInvocation.
Hello Multithreading - Création de nouveaux threads
Cet exemple simple montre comment démarrer plusieurs threads en Java. Notez qu’il n’est pas garanti que les threads s’exécutent dans l’ordre et que l’ordre d’exécution peut varier pour chaque exécution.
public class HelloMultithreading {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new MyRunnable(i));
t.start();
}
}
public static class MyRunnable implements Runnable {
private int mThreadId;
public MyRunnable(int pThreadId) {
super();
mThreadId = pThreadId;
}
@Override
public void run() {
System.out.println("Hello multithreading: thread " + mThreadId);
}
}
}
Le même thread peut-il s’exécuter deux fois ?
C’était la question la plus fréquente qu’un même thread puisse être exécuté deux fois.
La réponse à cela est de savoir qu’un thread ne peut s’exécuter qu’une seule fois.
** si vous essayez d’exécuter le même thread deux fois, il s’exécutera pour la première fois mais donnera une erreur pour la deuxième fois et l’erreur sera IllegalThreadStateException .**
Exemple:
public class TestThreadTwice1 extends Thread{
public void run(){
System.out.println("running...");
}
public static void main(String args[]){
TestThreadTwice1 t1=new TestThreadTwice1();
t1.start();
t1.start();
}
}
production:
running
Exception in thread "main" java.lang.IllegalThreadStateException
Objectif
Les threads sont les parties de bas niveau d’un système informatique sur lesquelles se produit le traitement des commandes. Il est pris en charge/fourni par le matériel CPU/MCU. Il existe également des méthodes logicielles. Le but du multi-threading est de faire des calculs en parallèle les uns des autres si possible. Ainsi, le résultat souhaité peut être obtenu dans une tranche de temps plus petite.