Depuis l'avènement de async
/ wait , Typescript a publié de nombreux articles qui vantent cette approche de développement ( hackernoon , blog.bitsrc.io , habr.com ). Nous les utilisons depuis le tout début côté client (lorsque les générateurs ES6 prenaient en charge moins de 50% des navigateurs). Et maintenant, je veux partager mon expérience, car l' exécution parallèle n'est pas tout ce qui serait bien de savoir le long de ce chemin.
Je n'aime pas vraiment le dernier article: quelque chose peut être incompréhensible. En partie à cause du fait que je ne peux pas fournir de code propriétaire - uniquement pour décrire l'approche générale. Par conséquent:
- n'hésitez pas à fermer l'onglet sans lire
- si vous y parvenez, demandez des détails peu clairs
- J'accepterai volontiers les conseils et les critiques des plus persistants et minutieusement découverts.
Liste des technologies de base:
- Le projet est écrit principalement en Typescript en utilisant plusieurs bibliothèques Javascript. La bibliothèque principale est ExtJS. Il est inférieur en termes de mode à React, mais il convient le mieux à un produit d'entreprise avec une interface riche: de nombreux composants prêts à l'emploi, des tables bien conçues et prêtes à l'emploi, un riche écosystème de produits connexes pour simplifier le développement.
- Serveur multithread asynchrone.
- RPC via Websocket est utilisé comme transport entre le client et le serveur. L'implémentation est similaire à .NET WCF.
- Tout objet est un service.
- Tout objet peut être transmis à la fois par valeur et par référence.
- L'interface de demande de données ressemble à GraphQL de Facebook, uniquement sur Typescript.
- Communication bidirectionnelle: l'initialisation de la mise à jour des données peut être lancée à la fois depuis le client et depuis le serveur.
- Le code asynchrone est écrit séquentiellement grâce à l'utilisation des fonctions
async
/ await
de Typesrcipt. - L'API du serveur est générée en Typescript: si elle change, la build l'affichera immédiatement en cas d'erreur.
Quelle est la sortie
Je vais vous dire comment nous travaillons avec cela et ce que nous avons fait pour une exécution sûre et non compétitive du code asynchrone: nos décorateurs Typesrcipt qui implémentent la fonctionnalité des files d'attente. Des principes de base à la solution de la condition de concurrence et d'autres difficultés qui surviennent au cours du processus de développement.
Structure des données reçues du serveur
Le serveur renvoie un objet parent qui contient des données (autres objets, collections d'objets, lignes, etc.) dans ses propriétés sous la forme d'un graphique. Cela est dû, entre autres, à l'application elle-même:
- il fait de l'analyse des données / ML un graphe orienté des nœuds de gestionnaire.
- chaque nœud à son tour peut contenir son propre graphique intégré
- les graphes ont des dépendances: les nœuds peuvent être «hérités», et de nouveaux nœuds sont créés par leur «classe».
Mais la structure de requête sous la forme d'un graphique peut être appliquée dans presque toutes les applications, et GraphQL, pour autant que je sache, le mentionne également dans sa spécification.
Exemple de structure de données:
Comment un client reçoit-il des données
C'est simple: lorsque vous demandez une propriété d'un objet d'un type non scalaire, RPC renvoie Promise
:
let Nodes = Parent.Nodes;
Asynchronie sans "Hell Callback".
Pour organiser un code asynchrone "séquentiel", la fonctionnalité async
/ await
Typescript est utilisée:
async function ShowNodes(parent: IParent): Promise<void> {
Cela n'a aucun sens de s'y attarder en détail, sur le moyeu il y a déjà suffisamment de matériel détaillé . Ils sont apparus dans Typescript en 2016. Nous utilisons cette approche depuis qu'elle est apparue dans la branche des fonctionnalités du référentiel Typescript, c'est pourquoi nous avons des bosses depuis longtemps et maintenant nous travaillons avec plaisir. Depuis quelque temps maintenant, et en production.
En bref, l'essence pour ceux qui ne connaissent pas le sujet:
Dès que vous ajoutez le mot clé async
Ă la fonction, il renverra automatiquement la Promise<_>
. Caractéristiques de ces fonctions:
- Les expressions à l'intérieur des fonctions
async
avec await
(qui renvoient Promise
) arrêteront l'exécution de la fonction et continueront après la résolution de la Promise
attendue. - Si une exception se produit dans la fonction
async
, la Promise
retournée sera rejetée avec cette exception. - Lors de la compilation en code Javascript, il y aura des générateurs pour le standard ES6 (
function*
au lieu de async function
et yield
au lieu d' await
) ou code effrayant avec switch
pour ES5 (machine d'état). await
est un mot clé qui attend le résultat d'une promesse. Au moment de la réunion, pendant l'exécution du code, la fonction ShowNodes
s'arrête, et en attendant les données, Javascript peut exécuter un autre code.
Dans le code ci-dessus, la collection a une méthode forEachParallel
qui appelle un rappel asynchrone pour chaque nœud en parallèle. En même temps, await
avant que Nodes.forEachParallel
n'attende tous les rappels. A l'intérieur de l'implémentation - Promise.all
:
export async function forEachParallel<T>(items: IItemArray<T>, callbackfn: (value: T, index: int, items: IItemArray<T>) => Promise<void | any>, thisArg?: any): Promise<void> { let xCount = items ? await items.Count : 0; if (!xCount) return; let xActions = new Array<Promise<void | any>>(xCount); for (let i = 0; i < xCount; i++) { let xItem = items.Item(i); xActions[i] = ExecuteCallback(xItem, callbackfn, i, items, thisArg); } await Promise.all(xActions); } /** item callbackfn */ async function ExecuteCallback<T>(item: Promise<T> | T, callbackfn: (value: T, index: int, items: IItemArray<T>) => Promise<void | any>, index: int, items: IItemArray<T>, thisArg?: any): Promise<void> { let xItem = await item; await callbackfn.call(thisArg, xItem, index, items); }
C'est du sucre syntaxique: de telles méthodes doivent être utilisées non seulement pour leurs collections, mais aussi pour les tableaux Javascript standard.
La fonction ShowNodes
semble extrêmement non optimale: lorsque nous demandons une autre entité, nous l'attendons à chaque fois. La commodité est qu'un tel code peut être écrit rapidement, donc cette approche est bonne pour le prototypage rapide. Dans la version finale, vous devez utiliser le langage de requête pour réduire le nombre d'appels au serveur.
Langue de la requĂŞte
Il existe plusieurs fonctions permettant de «créer» une demande de données à partir du serveur. Ils «indiquent» au serveur quels nœuds du graphe de données retourner dans la réponse:
selectAsync<T extends IItem>(item: T, properties: () => any[]): Promise<T>; selectAsyncAll<T extends ICollection>(items: T[], properties: () => any[]): Promise<T[]>; select<T>(item: T, properties: () => any[]): T; selectAll<T>(items: T[], properties: () => any[]): T[];
Examinons maintenant l'application de ces fonctions pour demander les données intégrées nécessaires en un seul appel au serveur:
async function ShowNodes(parentPoint: IParent): Promise<void> {
Un exemple d'une requête légèrement plus complexe avec des informations profondément intégrées:
Le langage de requête permet d'éviter les demandes inutiles au serveur. Mais le code n'est jamais parfait, et il contiendra certainement plusieurs demandes concurrentielles et, par conséquent, des conditions de concurrence.
Condition de course et solutions
Comme nous nous abonnons aux événements du serveur et écrivons du code avec un grand nombre de demandes asynchrones, une condition de FuncOne
peut se produire lorsque la fonction async
FuncOne
interrompue, en attente de Promise
. À ce moment, un événement de serveur (ou de la prochaine action de l'utilisateur) peut survenir et, après exécution concurrentielle, changer le modèle sur le client. Ensuite, FuncOne
après avoir résolu la promesse, peut se tourner, par exemple, vers des ressources déjà supprimées.
Imaginez une telle situation simplifiée: l'objet IParent
a un IParent
serveur IParent
.
Parent.OnSynchronize.AddListener(async function(): Promise<void> {
Il est appelé lorsque la liste des nœuds INodes
sur le serveur est mise à jour. Ensuite, dans le scénario suivant, une condition de concurrence est possible:
- Nous provoquons la suppression asynchrone du nœud du client, en attendant la fin pour supprimer l'objet client
async function OnClickRemoveNode(node: INode): Promise<void> { let removedOnServer: boolean = await Parent.RemoveNode(node);
- Par le biais de
Parent.OnSynchronize
, une mise à jour de l'événement de liste de nœuds se produit. Parent.OnSynchronize
traité et supprime l'objet client.async OnClickRemoveNode()
continue de s'exécuter après la première await
et une tentative est faite pour supprimer un objet client déjà supprimé.
Vous pouvez vérifier l'existence d'un objet client dans OnClickRemoveNode
. Il s'agit d'un exemple simplifié et une vérification similaire est normale. Mais que faire si la chaîne d'appel est plus compliquée? Par conséquent, l'utilisation d'une approche similaire après chaque await
est une mauvaise pratique:
- Le code tellement gonflé est compliqué à prendre en charge et à étendre.
- Le code ne fonctionne pas comme prévu: la suppression dans
OnClickRemoveNode
et la suppression réelle de l'objet client se produit ailleurs. Il ne doit pas y avoir de violation de la séquence définie par le développeur, sinon il y aura des erreurs de régression. - Ce n'est pas assez fiable: si vous oubliez de vérifier quelque part, il y aura une erreur. Le danger est, tout d'abord, qu'une vérification oubliée ne conduise pas à une erreur localement et dans un environnement de test, et pour les utilisateurs avec un délai réseau plus long, elle se produira.
- Et si le contrôleur auquel appartiennent ces gestionnaires peut être détruit? Après chaque
await
pour vérifier sa destruction?
Une autre question se pose: que se passe-t-il s'il existe de nombreuses méthodes de concurrence similaires? Imaginez qu'il y en ait plus:
- Ajout d'un nœud
- Mise à jour du nœud
- Ajouter / supprimer des liens
- Méthode de conversion à nœuds multiples
- Comportement complexe de l'application: nous changeons l'état d'un nœud et le serveur commence à mettre à jour les nœuds qui en dépendent.
Une mise en œuvre architecturale est requise, ce qui élimine en principe la possibilité d'erreurs dues à la condition de concurrence, aux actions utilisateur parallèles, etc. La bonne solution pour éliminer le changement simultané du modèle du client ou du serveur est d'implémenter une section critique avec une file d'attente d'appels. Les décorateurs dactylographiés seront utiles ici pour baliser de manière déclarative ces fonctions de contrôleur asynchrones compétitives.
Nous décrivons les exigences et les principales caractéristiques de ces décorateurs:
- A l'intérieur, une file d'attente d'appels aux fonctions asynchrones doit être implémentée. Selon le type de décorateur, un appel de fonction peut être mis en file d'attente ou rejeté s'il contient d'autres appels.
- Les fonctions marquées nécessiteront un contexte d'exécution pour se lier à la file d'attente. Vous devez soit créer explicitement une file d'attente, soit le faire automatiquement en fonction de la vue à laquelle appartient le contrôleur.
- Des informations sont requises sur la destruction de l'instance de contrôleur (par exemple, la propriété
IsDestroyed
). Pour empêcher les décorateurs de faire des appels en file d'attente après la destruction du contrôleur. - Pour le contrôleur View, nous ajoutons la fonctionnalité d'appliquer un masque translucide pour exclure les actions au moment de l'exécution de la file d'attente et indiquer visuellement le traitement en cours.
- Tous les dĂ©corateurs doivent terminer par un appel Ă
Promise.done()
. Dans cette méthode, vous devez implémenter le handler
exceptions non gérées. Une chose très utile:
Maintenant, nous donnons une liste approximative de ces décorateurs avec une description, puis montrons comment ils peuvent être appliqués.
@Lock @LockQueue @LockBetween @LockDeferred(300)
Les descriptions sont assez abstraites, mais dès que vous verrez un exemple d'utilisation avec des explications, tout deviendra plus clair:
class GraphController implements ILockTarget { private View: IView; public GetControllerView(): IView { return this.View; } @Lock private async OnClickRemoveNode(): Promise<void> { ... } @Lock private async OnClickRemoveLink(): Promise<void> { ... } @Lock private async OnClickAddNewNode(): Promise<void> { ... } @LockQueue private async OnServerUpdateNode(): Promise<void> { ... } @LockQueue private async OnServerAddLink(): Promise<void> { ... } @LockQueue private async OnServerAddNode(): Promise<void> { ... } @LockQueue private async OnServerRemoveNode(): Promise<void> { ... } @LockBetween private async OnServerSynchronize(): Promise<void> { ... } @LockQueue private async OnServerUpdateNodeStatus(): Promise<void> { ... } @LockDeferred(300) private async OnSearchFieldChange(): Promise<void> { ... } }
Nous allons maintenant analyser quelques scénarios typiques d'erreurs possibles et leur élimination par les décorateurs:
- L'utilisateur lance une action:
OnClickRemoveNode
, OnClickRemoveLink
. Pour un traitement correct, il est nécessaire qu'il n'y ait aucun autre gestionnaire en cours d'exécution dans la file d'attente (client ou serveur). Sinon, par exemple, une telle erreur est possible:
- Le modèle sur le client est toujours mis à jour vers l'état actuel du serveur
- Nous initialisons la suppression de l'objet avant la fin de la mise Ă jour (la file d'attente a un gestionnaire
OnServerSynchronize
). Mais cet objet n'est plus là - la synchronisation complète n'est pas encore terminée et elle est toujours affichée sur le client.
Par conséquent, toutes les actions initiées par l'utilisateur, le décorateur de Lock
doit rejeter s'il existe d'autres gestionnaires dans la file d'attente avec le même contexte de file d'attente. Étant donné que le serveur est asynchrone, cela est particulièrement important. Oui, Websocket envoie les demandes de manière séquentielle, mais si le client rompt la séquence, nous obtenons une erreur sur le serveur.
- Nous
OnClickAddNewNode
l'ajout d'un nœud: OnClickAddNewNode
. OnServerSynchronize
, les événements OnServerAddNode
proviennent du serveur.
OnClickAddNewNode
pris la file d'attente (s'il y avait quelque chose, le décorateur de Lock
de cette méthode rejetterait l'appel)OnServerSynchronize
, OnServerAddNode
en OnServerAddNode
d' OnServerAddNode
, exécuté séquentiellement après OnClickAddNewNode
, sans concurrence avec lui.
- La file d'attente a des
OnServerSynchronize
et OnServerUpdateNode
. Supposons que lors de l'exécution du premier, l'utilisateur ferme le GraphController
. Ensuite, le deuxième appel à OnServerUpdateNode
ne doit pas être effectué automatiquement afin de ne pas prendre de mesures sur le contrôleur détruit, ce qui garantit une erreur. Pour cela, l'interface ILockTarget
a IsDestroyed
- le décorateur vérifie l'indicateur sans exécuter le prochain gestionnaire de la file d'attente.
Bénéfice: pas besoin d'écrire if (!this.IsDestroyed())
après chaque await
. - Les modifications apportées à plusieurs nœuds sont déclenchées.
OnServerSynchronize
, les événements OnServerUpdateNode
proviennent du serveur. Leur exécution compétitive entraînera des erreurs irréprochables. Mais depuis LockQueue
sont marqués par les LockBetween
LockQueue
et LockBetween
, ils seront exécutés séquentiellement. - Imaginez que les nœuds puissent contenir des graphiques de nœuds imbriqués.
GraphController #1
, — GraphController #2
. , GraphController
- , ( — ), .. . :
OnSearchFieldChange
, . - . @LockDeferred(300)
300 : , , 300 . , . :
- , 500 , . —
OnSearchFieldChange
, . OnSearchFieldChange
— , .
- Deadlock:
Handler1
, , await
Handler2
, LockQueue
, Handler2
— Handler1
. - , View . : , — .
, , . :
- -
<Class>
. <Method>
=> <Time>
( ). - .
- .
, , , . ? ? :
class GraphController implements ILockTarget { private View: IView; public GetControllerView(): IView { return this.View; } @Lock private async RunBigDataCalculations(): Promise<void> { await Start(); await UpdateSmth(); await End(); await CleanUp(); } @LockQueue private async OnChangeNodeState(node: INode): Promise<void> { await GetNodeData(node); await UpdateNode(node); } }
:
RunBigDataCalculations
.await Start();
- / ( )
await Start();
, await UpdateSmth();
.
:
RunBigDataCalculations
.OnChangeNodeState
, (.. ).await GetNodeData(node);
- / ( )
await GetNodeData(node);
, await UpdateNode(node);
.
- . :
export interface IQueuedDisposableLockTarget extends ILockTarget { IsDisposing(): boolean; SetDisposing(): void; }
function QueuedDispose(controller: IQueuedDisposableLockTarget): void {
, . QueuedDispose
:
- . .
QueuedDispose
controller
. — ExtJS .
, , .. . , ? , .
, :
vk.com
Telegram