Les problèmes de threads (qui m'ont également préoccupé ces derniers temps) découlent de l'utilisation de plusieurs cœurs de processeurs avec des caches séparés, ainsi que des conditions de course de base pour le remplacement des threads. Si les caches de cœurs distincts accèdent au même emplacement de mémoire, ils n'auront généralement aucune idée de l'autre et pourront suivre séparément l'état de cet emplacement de données sans qu'il ne retourne à la mémoire principale (ou même à un cache synchronisé partagé par tous les cœurs à L2 ou L3, par exemple), pour des raisons de performances du processeur. Ainsi, même les astuces de verrouillage de l'ordre d'exécution peuvent ne pas être fiables dans les environnements multithreads.
Comme vous le savez peut-être, le principal outil permettant de corriger ce problème est le verrou, qui fournit un mécanisme d'accès exclusif (entre les contentions pour le même verrou) et gère la synchronisation du cache sous-jacent de sorte que les accès au même emplacement mémoire par diverses sections de code protégées par un verrou soient correctement sérialisés. Vous pouvez toujours avoir des conditions de course entre qui obtient le verrou quand et dans quel ordre, mais c'est généralement beaucoup plus simple à gérer lorsque vous pouvez garantir que l'exécution d'une section verrouillée est atomique (dans le contexte de ce verrou).
Vous pouvez obtenir un verrou sur une instance de n'importe quel type de référence (par exemple, hérite de Object, pas des types de valeur comme int ou enums, et pas null), mais il est très important de comprendre que le verrou sur un objet n'a aucun effet inhérent sur les accès à cet objet, il interagit seulement avec d'autres tentatives d'obtenir le verrou sur le même objet. C'est à la classe de protéger l'accès à ses variables membres en utilisant un schéma de verrouillage approprié. Parfois, les instances peuvent protéger les accès multithread à leurs propres membres en se verrouillant elles-mêmes (par ex. lock (this) { ... }
), mais cela n'est généralement pas nécessaire car les instances sont généralement détenues par un seul propriétaire et il n'est pas nécessaire de garantir un accès threadsafe à l'instance.
Plus souvent, une classe crée un verrou privé (ex. private readonly object m_Lock = new Object();
pour des verrous séparés dans chaque instance afin de protéger l'accès aux membres de cette instance, ou private static readonly object s_Lock = new Object();
pour un verrouillage central afin de protéger l'accès aux membres statiques de la classe). Josh a un exemple de code plus spécifique de l'utilisation d'un verrou. Vous devez ensuite coder la classe pour utiliser le verrou de manière appropriée. Dans des cas plus complexes, vous pouvez même créer des verrous séparés pour différents groupes de membres, afin de réduire les conflits pour différents types de ressources qui ne sont pas utilisées ensemble.
Ainsi, pour en revenir à votre question initiale, une méthode qui n'accède qu'à ses propres variables et paramètres locaux serait thread-safe, car ceux-ci existent dans leurs propres emplacements mémoire sur la pile spécifique au thread actuel, et ne peuvent être accédés ailleurs - à moins que vous ne partagiez ces instances de paramètres entre les threads avant de les transmettre.
Une méthode non statique qui n'accède qu'aux membres propres de l'instance (pas de membres statiques) - et bien sûr aux paramètres et aux variables locales - n'aurait pas besoin d'utiliser des verrous dans le contexte où cette instance est utilisée par un seul propriétaire (il n'est pas nécessaire qu'elle soit thread-safe), mais si les instances étaient destinées à être partagées et que l'on voulait garantir un accès thread-safe, alors l'instance devrait protéger l'accès à ses variables membres avec un ou plusieurs verrous spécifiques à cette instance (le verrouillage sur l'instance elle-même étant une option) - plutôt que de laisser à l'appelant le soin d'implémenter ses propres verrous autour de l'instance lorsqu'il partage quelque chose qui n'est pas destiné à être partagé de manière thread-safe.
L'accès aux membres en lecture seule (statiques ou non) qui ne sont jamais manipulés est généralement sûr, mais si l'instance qu'il contient n'est pas elle-même thread-safe ou si vous devez garantir l'atomicité à travers de multiples manipulations de celle-ci, alors vous devrez peut-être protéger tous les accès à celle-ci avec votre propre schéma de verrouillage. C'est un cas où il pourrait être pratique que l'instance utilise le verrouillage sur elle-même, parce que vous pourriez simplement obtenir un verrou sur l'instance à travers de multiples accès à elle pour l'atomicité, mais vous n'auriez pas besoin de le faire pour les accès uniques à elle si elle utilise un verrou sur elle-même pour rendre ces accès individuellement thread-safe. (Si ce n'est pas votre classe, vous devrez savoir si elle se verrouille sur elle-même ou si elle utilise un verrou privé auquel vous ne pouvez pas accéder en externe).
Enfin, il y a l'accès aux membres statiques changeants (modifiés par la méthode donnée ou par d'autres) à partir d'une instance - et bien sûr les méthodes statiques qui accèdent à ces membres statiques et peuvent être appelées par n'importe qui, n'importe où, n'importe quand - qui ont le plus besoin d'utiliser un verrouillage responsable, sans quoi elles ne sont certainement pas thread-safe et sont susceptibles de provoquer des bogues imprévisibles.
Lorsqu'il s'agit de classes du cadre .NET, Microsoft documente dans MSDN si un appel API donné est thread-safe (par exemple, les méthodes statiques des types de collection génériques fournis comme List<T>
sont rendues thread-safe alors que les méthodes d'instance peuvent ne pas l'être - mais vérifiez spécifiquement pour être sûr). La grande majorité du temps (et à moins qu'il ne soit spécifiquement indiqué qu'il est "thread-safe"), il n'est pas "thread-safe" en interne, il est donc de votre responsabilité de l'utiliser de manière sûre. Et même lorsque des opérations individuelles sont implémentées en interne de manière thread-safe, vous devez toujours vous soucier des accès partagés et superposés par votre code s'il fait quelque chose de plus complexe qui doit être atomique.
L'itération sur une collection (par exemple, avec la fonction foreach
). Même si chaque accès à la collection obtient un état stable, il n'y a aucune garantie inhérente qu'il ne changera pas entre ces accès (si quelqu'un d'autre peut y accéder). Lorsque la collection est conservée localement, il n'y a généralement pas de problème, mais une collection qui pourrait être modifiée (par un autre thread ou pendant l'exécution de votre boucle !) pourrait produire des résultats incohérents. Une façon simple de résoudre ce problème est d'utiliser une opération atomique thread-safe (à l'intérieur de votre schéma de verrouillage de protection) pour faire une copie temporaire de la collection ( MyType[] mySnapshot = myCollection.ToArray();
) et ensuite itérer sur cette copie locale de l'instantané en dehors du verrou. Dans de nombreux cas, cela évite d'avoir à maintenir un verrou tout le temps, mais selon ce que vous faites dans l'itération, cela peut ne pas être suffisant et vous devez simplement vous protéger contre les changements tout le temps (ou vous pouvez déjà l'avoir à l'intérieur d'une section verrouillée se protégeant contre l'accès pour changer la collection avec d'autres choses, donc c'est couvert).
Ainsi, la conception de threads sécurisés est un peu un art, et savoir exactement où et comment obtenir des verrous pour protéger les choses dépend beaucoup de la conception globale et de l'utilisation de votre ou vos classes. Il peut être facile de devenir paranoïaque et de penser qu'il faut mettre des verrous partout pour tout, mais il s'agit en fait de trouver la bonne couche de protection.