J'essaie de créer une application C # qui envoie des notifications du serveur à ses utilisateurs (une application mobile). Étant donné que chaque utilisateur peut voir sa collection de notifications modifiée par n'importe quel thread, je dois verrouiller ces collections chaque fois que quelqu'un essaie de lire/ajouter/supprimer une notification.
Le problème que je pense que je pourrais rencontrer s'il y a beaucoup d'utilisateurs (j'espère des millions;)) connecté en même temps est que je devrai garder un verrou séparé pour chaque collection, mais un processus ne peut contenir qu'un nombre limité nombre de poignées et j'aurai besoin de plus de verrous que ce que je suis autorisé à avoir.
Est-ce un vrai problème ou suis-je inquiet pour rien? Existe-t-il une meilleure solution pour cela?
L'utilisation de nombreux verrous peut entraîner certains problèmes:
Mutex
et toutes les autres primitives de synchronisation dérivées de WaitHandle
est limité par le système d'exploitation. Les opérations de verrouillage de ligne de cache Compare-And-Swap (en .net fournies via la classe Interlocked
) sont des instructions CPU et peuvent être utilisées sans limitations. Sections critiques (fournies par le mot clé lock
en c # et la classe Monitor
dans .net) ne sont probablement limitées que par la mémoire disponible, comme peuvent être ReaderWriterLockSlim
et SemaphoreSlim
(comme ajoutés via un commentaire par Greeble31).Alternatives:
Comme mentionné dans les commentaires, lorsque vous évoluez, vous rencontrerez des problèmes en essayant de garder l'état en mémoire. Que se passe-t-il si votre serveur tombe en panne? Tous vos utilisateurs perdent-ils leurs notifications? À mesure que vous évoluez, vous introduisez généralement une base de données qui conserve vos notifications. La base de données gère le verrouillage dans le cas simple que vous avez décrit, vous n'avez donc pas à vous en préoccuper jusqu'à ce que vous commenciez à développer votre base de données.
En ce qui concerne la simultanéité en cours, il existe des alternatives au verrouillage:
Les structures de données simultanées aident à implémenter le verrouillage pour le problème que vous avez décrit. Vous pouvez conserver vos notifications dans une collection simultanée et y accéder à partir de différents threads sans vous bloquer vous-même. Il est préférable de les utiliser autant que possible au lieu d'implémenter le verrouillage vous-même, car vous êtes beaucoup plus susceptible de l'implémenter de manière incorrecte ou sous-optimale.
Votre système est modélisé comme un ensemble d'acteurs qui communiquent via des messages. Il existe des garanties sur l'ordre et le traitement des messages qui améliorent considérablement le caractère raisonnable d'un système simultané par rapport au verrouillage. Les messages sont traités séquentiellement, ce qui peut offrir un débit plus mauvais, mais une fois correctement effectué, vous ne bloquerez pas et il est moins probable que vous accédiez à votre état en toute sécurité (car l'état est privé pour l'acteur).
Avec une concurrence optimiste, vous ne maintenez pas de verrou pendant une longue période. Au lieu de cela, votre client garde une trace de la version de l'état depuis la dernière fois qu'il a lu l'état. Puis, lorsqu'il tente de définir l'état, vous vérifiez que les versions correspondent toujours. Si les versions correspondent, vous définissez l'état. S'il y a une incompatibilité de version, cela signifie qu'un thread différent a déjà modifié l'état. Vous pouvez ensuite appliquer une stratégie de nouvelle tentative pour réessayer la modification en récupérant le dernier état, en appliquant la mutation et en tentant de définir l'état. C'est une bonne approche lorsque vous avez beaucoup moins d'écritures que de lectures. Cela aura un meilleur débit que le verrouillage et le modèle d'acteur lorsque la plupart de vos demandes sont lues.
Ou plutôt, non, mais votre question fait allusion à une erreur conceptuelle fondamentale, alors ... oui.
C'est en fait "non" car des verrous légers qui prennent très peu de ressources et peu de poignées (ou pas du tout!) Peuvent être facilement implémentés. Pour une situation d'encombrement ultra-faible avec des durées de verrouillage courtes telles que "une douzaine de threads au plus" vs "un million de choses verrouillables", un spinlock est parfaitement adapté.
Si vous avez peur de tourner (ne devrait pas être dans cette situation - la rotation est mauvaise si, et seulement s'il y a conflit), des verrous qui peuvent bloquer et consommer une seule poignée pour tout le processus peuvent être construits autour de NtWaitForKeyedEvent
sur Windows, un verrou qui bloque et a besoin pas de poignée du tout peut être construit autour de WaitOnAddress
sur Windows> = 8, ou futex
sur les systèmes Linux> = 2.6. C'est ultra-léger, et vous pouvez littéralement en avoir des millions (même si vous n'en avez probablement pas voulez pour en avoir autant).
Notez que des millions d'utilisateurs simultanés sont pas des millions de threads exécutés sur le serveur. En général, en termes d'utilisateurs simultanés, vous voudrez peut-être penser à "10 000 à 50 000" plutôt qu'à "millions" de toute façon, car le premier est assez irréaliste à moins que vous n'ayez un tout ferme de serveurs. Ce qui est ... euh ... une bête complètement différente. Quoi qu'il en soit, vous n'avez pas besoin de plus d'une poignée de threads simultanés pour ceux-ci.
De plus, des millions d'utilisateurs sont pas des millions de collections singulières, distinctes, verrouillables (ou choses, peu importe) sur le serveur. Pas normalement, du moins. Normalement, il y aura quelque chose comme une base de données les contenant tous (sans vous dire ne pouvait pas faire les choses différemment, c'est juste très inhabituel). Nitpick: Oui, le verrouillage au niveau des lignes est une fonctionnalité que certaines bases de données implémentent, alors oui, vous pouvez avoir des millions de verrous de toute façon, sans le savoir.
En fonction de la fréquence des lectures par rapport aux mises à jour, vous pouvez envisager des techniques telles que la lecture-copie-mise à jour qui évitent également le verrouillage. RCU n'a pas besoin d'une poignée. Vous le faites soit via une indirection de pointeur supplémentaire et des lectures/écritures atomiques sur le pointeur, o en incrémentant doublement un compteur (atomiquement) et en redémarrant le lecteur si à mi-chemin une modification a été trouvée (c'est-à-dire si le compteur est impair la deuxième fois que vous le regardez). Pas de blocage, pas de serrures, pas de poignées. Selon le modèle d'accès, cette stratégie peut être très avantageuse.
Il existe différentes stratégies pour gérer un grand nombre de clients, l'une serait d'avoir un thread multiplexeur pour le réseau (éventuellement quelques-uns). Pour cela, des fonctions comme select
, poll
, GetQueuedCompletionStatus
, epoll
et kqueue
viennent à l'esprit, selon le système d'exploitation ( s) que vous ciblez.
Voyant comment C # implique souvent (mais pas nécessairement) Windows, GetQueuedCompletionStatus
ressemble à ce que vous voudriez utiliser (peut-être que C # a même une fonctionnalité de multiplexage de niveau supérieur construite en plus de celle disponible aussi , Je ne sais pas, sans utiliser C #). Pour cela, généralement plus d'un seul thread est utilisé (peut faire une demi-douzaine sans se soucier du nombre exact, la fonction les bloque/les réveille au besoin), mais vous n'en avez pas besoin de beaucoup. On fera probablement très bien aussi. Avec par ex. epoll
, utiliser un seul thread multiplexeur est la chose normale (bien que vous pouvez faire quelque chose de différent). Fonctionne parfaitement bien, aucun problème de performances.
Enfin, un pool de travailleurs (à peu près le nombre de cœurs de processeur) fait, eh bien, tout ce qui doit être fait en plus, et des files d'attente simultanées pour communiquer entre eux et les E/S. Ce que vous utilisez exactement (file d'attente verrouillée, file d'attente sans verrouillage/sans attente, perturbateur, rotation ou rendement occupé, ou même blocage, peu importe) dépend de la situation exacte. Chacun a ses forces et ses faiblesses. Notez que les structures simultanées verrouillées ne sont en aucun cas aussi abyssales que vous ne le pensez. Sauf en cas de congestion importante, ils sont en fait assez bons (et très simples à obtenir, alors que les trucs sans verrouillage sont cauchemardesques).
S'il vous arrive d'utiliser UDP plutôt que TCP (mais assurez-vous que vous comprenez les implications, notamment vous devez faire tout le travail de fiabilité vous-même), un seul thread dédié peut faire tous les envois dans un blocage simple, mode un par un. C'est le moyen le plus simple, sans tracas total, 100% portable, et suffit à physiquement saturer le câble Ethernet, et cela ne cause pas de perte inutile de paquets en raison de threads poussant des trucs vers la pile réseau simultanément.
Vous n'avez pas non plus strictement besoin d'attendre la préparation/l'achèvement si vous utilisez UDP. Les lectures de blocage simples et ordinaires d'un ou de plusieurs travailleurs feront parfaitement l'affaire, et vous ne pouvez guère en empêcher plus de performances (sauf en cas d'utilisation de fonctions spéciales de "triche" comme recvmmmsg
qui saisissent une douzaine de datagrammes en un aller).
Si aucun traitement lourd n'est nécessaire et que la latence n'est pas primordiale (donc les messages pouvant être retardés d'une demi-seconde ne sont pas un problème), vous pouvez probablement utiliser un serveur qui gère quelques milliers de connexions simultanées à un seul thread. Les API de multiplexage ci-dessus n'ont aucun problème réel à gérer cela, pas plus que la pile réseau.