Mise à jour : Le contenu de cet article ne s'applique qu'aux versions 2021 R1 et antérieures d'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 :
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 :
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 :
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.