Accueil Blog Améliorer les performances - Deuxième partie : Opérations multithreading en C# dans Acumatica

Améliorer les performances - Deuxième partie : Opérations multithreading en C# dans Acumatica

Yuriy Zaletskyy | 30 juin 2023

Mise à jour : Le contenu de cet article ne s'applique qu'aux versions 2021 R1 et antérieures d'Acumatica.

Améliorer les performances - Deuxième partie : Opérations multithreading en C# dans Acumatica

Introduction

Dans mon dernier article de blog, j'ai partagé avec vous comment lesopérations asynchrones/psychroniques fonctionnent dans le cadre d'Acumatica en utilisant C#. Aujourd'hui, je poursuis la discussion sur les performances en me concentrant sur l'optimisation du multithreading dans votre code.

Multithreading dans Acumatica

L'un de mes clients souhaitait disposer d'un outil qui fonctionne plus rapidement que les appels à l'API WEB. Une telle optimisation peut être réalisée grâce à l'utilisation du multithreading.

Pour ce faire, j'ai considéré un cas synthétique d'importation de 18 249 enregistrements dans Acumatica. Les enregistrements ont été prélevés ici :

https://www.kaggle.com/neuromusic/avocado-prices/kernels

. Imaginez que pour chaque ligne de cet ensemble de données, vous devez générer une commande client. Du point de vue du code C#, vous disposez de deux approches : l'une à un seul fil et l'autre à plusieurs fils. L'approche monotâche est assez simple. Il vous suffit de lire la source et de persister, une à une, dans la commande client.

Pour commencer, j'ai créé trois articles d'inventaire dans Acumaitca : A4770, A4225, A4046. J'ai également créé un reçu d'achat pour 1 000 000 d'articles commandés pour chacun des articles en stock.

Avant de poursuivre, j'aimerais vous montrer mon Gestionnaire des tâches, onglet Performances, afin de servir de référence :

 

Gestionnaire des tâches et onglet Performances.

 

Et maintenant, je vais exécuter des insertions de commandes clients dans Acumatica avec un seul thread. Voici le code source :

 


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MultiThreadingAsyncDemo.DAC;
using PX.Data;
using PX.Objects.SO;

namespace MultiThreadingAsyncDemo
{
public class AvocadosImporter : PXGraph<AvocadosImporter>
{
public PXCancel<ImportAvocado> Cancel;

[PXFilterable]
public PXProcessing<ImportAvocado> NotImportedAvocados;

public override bool IsDirty => false;

private Object thisLock = new Object();

private const string AVOCADOS = "Avocados";

public AvocadosImporter()
{
NotImportedAvocados.SetProcessDelegate(ProcessImportAvocados);
}

public static void ProcessImportAvocados(List<ImportAvocado> importSettings)
{
var avocadosImporter = PXGraph.CreateInstance<AvocadosImporter>();

var avocadosRecords = PXSelect<Avocado, Where<Avocado.imported, Equal<False>>>.Select(avocadosImporter).Select(a => a.GetItem<Avocado>()).ToList();

 

var initGraph = PXGraph.CreateInstance<SOOrderEntry>();
var branchId = initGraph.Document.Insert().BranchID;

Object thisLck = new Object();

var soEntry = PXGraph.CreateInstance<SOOrderEntry>();
for (int i = 0; i < avocadosRecords.Count; i++)
{
var avocadosRecord = avocadosRecords[i];
CreateSalesOrder(soEntry, avocadosRecord, thisLck, branchId);
}

}

private static void CreateSalesOrder(SOOrderEntry sOEntry, Avocado avocadosRecord, Object thisLock, int? branchId)
{
try
{
sOEntry.Clear();

var newSOrder = new SOOrder();
newSOrder.OrderType = "SO";
newSOrder = sOEntry.Document.Insert(newSOrder);
newSOrder.BranchID = branchId;
newSOrder.OrderDate = avocadosRecord.Date;
newSOrder.CustomerID = 7016;
var newSOOrderExt = newSOrder.GetExtension<SOOrderExt>();
newSOOrderExt.Region = avocadosRecord.Region;
newSOOrderExt.Type = avocadosRecord.Type;

sOEntry.Document.Update(newSOrder);

var ln = sOEntry.Transactions.Insert();
ln.BranchID = branchId;
sOEntry.Transactions.SetValueExt<SOLine.inventoryID>(ln, "A4046");
ln.SubItemID = 123;
ln.OrderQty = avocadosRecord.A4046;
ln.CuryUnitPrice = avocadosRecord.AveragePrice;
sOEntry.Transactions.Update(ln);

ln = sOEntry.Transactions.Insert();
ln.BranchID = branchId;
ln.SubItemID = 123;
sOEntry.Transactions.SetValueExt<SOLine.inventoryID>(ln, "A4225");
ln.OrderQty = avocadosRecord.A4225;
ln.CuryUnitPrice = avocadosRecord.AveragePrice;
sOEntry.Transactions.Update(ln);

ln = sOEntry.Transactions.Insert();
ln.BranchID = branchId;
sOEntry.Transactions.SetValueExt<SOLine.inventoryID>(ln, "A4770");
ln.SubItemID = 123;
ln.OrderQty = avocadosRecord.A4770;
ln.CuryUnitPrice = avocadosRecord.AveragePrice;
sOEntry.Transactions.Update(ln);
newSOrder.OrderDesc = avocadosRecord.Date + avocadosRecord.AveragePrice.ToString();
sOEntry.Document.Update(newSOrder);

//lock (thisLock)
{
sOEntry.Actions.PressSave();
}

PXDatabase.Update<Avocado>(
new PXDataFieldAssign<Avocado.imported>(true),
new PXDataFieldRestrict<Avocado.id>(avocadosRecord.Id));
}
catch (Exception exception)
{
PXTrace.WriteError(exception);
}

}
}
}

 

Voyons maintenant comment le gestionnaire des tâches est affecté après l'exécution du code à un seul fil d'exécution par défaut :

 

Améliorer les performances - Deuxième partie : Opérations multithreading en C# dans Acumatica

 

Observez qu'après le chargement de l'importation, le processeur n'a pas changé du tout. En fait, il est même devenu plus petit, ce qui signifie que les 40 cœurs ne seront pas utilisés à leur plein potentiel. Après deux heures et 45 minutes, j'avais créé 3 746 commandes clients. Pas mal, mais pas de quoi être particulièrement fier.

Ensuite, j'ai créé un code multithreading :

 


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MultiThreadingAsyncDemo.DAC;
using PX.Data;
using PX.Objects.SO;
 
namespace MultiThreadingAsyncDemo
{
	public class AvocadosImporter : PXGraph<AvocadosImporter>
	{
		public PXCancel<ImportAvocado> Cancel;
 
		[PXFilterable]
		public PXProcessing<ImportAvocado> NotImportedAvocados;
 
		public override bool IsDirty => false;
 
		private Object thisLock = new Object();
 
		private const string AVOCADOS = "Avocados";
 
		public AvocadosImporter()
		{
			NotImportedAvocados.SetProcessDelegate(ProcessImportAvocados);
		}
 
		public static void ProcessImportAvocados(List<ImportAvocado> importSettings)
		{
			var avocadosImporter = PXGraph.CreateInstance<AvocadosImporter>();
 
			var avocadosRecords = PXSelect<Avocado, Where<Avocado.imported, Equal<False>>>.Select(avocadosImporter).Select(a => a.GetItem<Avocado>()).ToList();
 
			int numberOfLogicalCores = Environment.ProcessorCount;
			List<Task> tasks = new List<Task>(numberOfLogicalCores);
 
			int sizeOfOneChunk = (avocadosRecords.Count / numberOfLogicalCores) + 1;
 
			var initGraph = PXGraph.CreateInstance<SOOrderEntry>();
			var branchId = initGraph.Document.Insert().BranchID;
 
 
			Object thisLck = new Object();
 
			for (int i = 0; i < numberOfLogicalCores; i++)
			{
				int a = i;
 
				var tsk = new Task(
					() =>
					{
						try
						{
							using (new PXImpersonationContext(PX.Data.Update.PXInstanceHelper.ScopeUser))
							{
								using (new PXReadBranchRestrictedScope())
								{
									var portionsGroups = avocadosRecords.Skip(a * sizeOfOneChunk).Take(sizeOfOneChunk)
										.ToList();
 
									if (portionsGroups.Count != 0)
									{
										var sOEntry = PXGraph.CreateInstance<SOOrderEntry>();
										foreach (var avocadosRecord in portionsGroups)
										{
											CreateSalesOrder(sOEntry, avocadosRecord, thisLck, branchId);
										}
									}
								}
							}
						}
						catch (Exception ex)
						{
							PXTrace.WriteInformation(ex);
						}
 
					});
				tasks.Add(tsk);
			}
 
			foreach (var task in tasks)
			{
				task.Start();
			}
 
			Task.WaitAll(tasks.ToArray());
		}
 
		private static void CreateSalesOrder(SOOrderEntry sOEntry, Avocado avocadosRecord, Object thisLock, int? branchId)
		{
			try
			{
				sOEntry.Clear();
 
				var newSOrder = new SOOrder();
				newSOrder.OrderType = "SO";
				newSOrder = sOEntry.Document.Insert(newSOrder);
				newSOrder.BranchID = branchId;
				newSOrder.OrderDate = avocadosRecord.Date;
				newSOrder.CustomerID = 7016;
				var newSOOrderExt = newSOrder.GetExtension<SOOrderExt>();
				newSOOrderExt.Region = avocadosRecord.Region;
				newSOOrderExt.Type = avocadosRecord.Type;
 
				sOEntry.Document.Update(newSOrder);
 
				var ln = sOEntry.Transactions.Insert();
				ln.BranchID = branchId;
				sOEntry.Transactions.SetValueExt<SOLine.inventoryID>(ln, "A4046");
				ln.SubItemID = 123;
				ln.OrderQty = avocadosRecord.A4046;
				ln.CuryUnitPrice = avocadosRecord.AveragePrice;
				sOEntry.Transactions.Update(ln);
 
				ln = sOEntry.Transactions.Insert();
				ln.BranchID = branchId;
				ln.SubItemID = 123;
				sOEntry.Transactions.SetValueExt<SOLine.inventoryID>(ln, "A4225");
				ln.OrderQty = avocadosRecord.A4225;
				ln.CuryUnitPrice = avocadosRecord.AveragePrice;
				sOEntry.Transactions.Update(ln);
 
				ln = sOEntry.Transactions.Insert();
				ln.BranchID = branchId;
				sOEntry.Transactions.SetValueExt<SOLine.inventoryID>(ln, "A4770");
				ln.SubItemID = 123;
				ln.OrderQty = avocadosRecord.A4770;
				ln.CuryUnitPrice = avocadosRecord.AveragePrice;
				sOEntry.Transactions.Update(ln);
				newSOrder.OrderDesc = avocadosRecord.Date + avocadosRecord.AveragePrice.ToString();
				sOEntry.Document.Update(newSOrder);
 
				lock (thisLock)
				{
					sOEntry.Actions.PressSave();
				}
 
				PXDatabase.Update<Avocado>(
					new PXDataFieldAssign<Avocado.imported>(true),
					new PXDataFieldRestrict<Avocado.id>(avocadosRecord.Id));
			}
			catch (Exception exception)
			{
				PXTrace.WriteError(exception);
			}
 
		}
	}
}

Dans l'exemple de code, il convient d'accorder une attention particulière à la partie comportant un verrou :


lock (thisLock)
{
	sOEntry.Actions.PressSave();
}

 

Ce verrou est nécessaire pour synchroniser la persistance des commandes clients dans la base de données. Sans ce verrou, plusieurs graphes tentent simultanément de créer des enregistrements dans la base de données, ce qui bloque le mécanisme de persistance d'Acumatica, qui n'est pas sûr pour les threads. Je pense que cela peut être lié au fait que les numéros de commandes clients dépendent d'éléments précédemment générés dans la base de données, et c'est pourquoi j'ai considéré le verrou comme nécessaire.

J'ai restauré la base de données à partir de la sauvegarde, et j'ai exécuté le code multithreading, ou pour être plus précis - le code multitâche. Jetez un coup d'œil à la différence de notre gestionnaire de tâches :

 

Le gestionnaire des tâches de performance a un aspect différent.

 

Et regardez : la charge n'a augmenté que de 7 % ! Mais qu'en est-il de la vitesse de création ?

Il convient de noter qu'en 2 heures, 35 minutes et 26 secondes, j'ai pu créer les 18 247 commandes clients. Cela signifie que notre approche simple a permis de créer 22 commandes clients par minute. Et notre approche multithread nous a permis de créer 117 commandes clients par minute, soit 5 fois plus vite ! Autre point d'optimisation, il est possible d'avoir deux machines, l'une sur laquelle Acumatica est installé et fonctionne, et l'autre sur laquelle MS SQL Server fonctionne. Et pour MS SQL server, vous devriez envisager de diviser le fichier de base de données sur deux disques durs, ainsi que de placer le fichier journal sur un troisième disque dur.

Résumé

Dans ce billet de blog, j'ai décrit l'une des deux façons d'accélérer les performances en utilisant le multithreading. Dans la première partie, j'ai abordé les approches asynchrones/synchrones. Ces deux approches peuvent améliorer les performances de manière significative, mais pas dans 100 % des cas. Les véritables gains de performance ne seront obtenus que si vous avez de grandes quantités de données à importer, à manipuler ou à masser. Nous parlons ici de millions d'enregistrements.

Avant d'ajouter des fonctions asynchrones/multitâches/multi threads à votre code, envisagez d'ajouter la mise en cache (c'est-à-dire une simple énumération d'éléments avant le corps principal du cycle). Si la mise en cache n'améliore pas les performances, envisagez de déplacer vos calculs logiques vers le serveur SQL. Si cela n'apporte toujours pas de gains de performance significatifs, il est probable que vous n'avez pas de goulot d'étranglement dû à la quantité d'enregistrements de données dans votre processus de test. L'ajout de l'asynchronisme/multitâche/multithreading peut améliorer les performances, et ce de manière significative, mais cela nécessite souvent l'utilisation de sections critiques (pour C#, vous utilisez la fonction lock( )) - ce qui n'est pas toujours évident.
J'espère que ces deux articles vous permettront, en tant que développeur, de comprendre les techniques que vous pouvez appliquer pour améliorer les performances lorsque vous travaillez avec de grandes quantités d'enregistrements de données.

 

Auteur du blog

Yuriy a commencé à programmer en 2003 en utilisant C++ et FoxPro avant de passer à .Net en 2006. Depuis 2013, il développe activement des applications utilisant le cadre Acumatica xRP, développant des solutions pour de nombreux clients au fil des ans. Il a un blog personnel, bien nommé Yuriy Zaletskyy's Blog, où il documente les problèmes de programmation qu'il a rencontrés au cours des six dernières années - partageant ses observations et ses solutions librement avec d'autres développeurs Acumatica.

Recevez les mises à jour du blog dans votre boîte de réception.