Actualización: El contenido de este artículo sólo es aplicable a las versiones 2021 R1 y anteriores de Acumatica.
Introducción
En mi última entrada del blog, compartí con ustedes cómo funcionan lasoperaciones asíncronas/sicrónicas dentro del marco de Acumatica utilizando C#. Hoy, continuaré la discusión sobre el rendimiento centrándome en las optimizaciones multihilo en tu código.
Multiproceso en Acumatica
Para uno de mis clientes, querían tener algo que funciona más rápido que las llamadas a la API WEB. Tal optimización se puede lograr con el uso de multithreading.
Para ello, consideré un caso sintético de importación de 18.249 registros dentro de Acumatica. Los registros se tomaron de aquí:
https://www.kaggle.com/neuromusic/avocado-prices/kernels
. Imagine que para cada fila de este conjunto de datos necesita generar un pedido de venta. Desde el punto de vista del código C#, tiene a su disposición un par de enfoques: monohilo y multihilo. Un enfoque monohilo es bastante sencillo. Simplemente se lee desde la fuente, y uno por uno, se persiste en la orden de venta.
Para empezar, he creado tres artículos de inventario en Acumaitca: A4770, A4225, A4046. Además, creé un recibo de compra para 1.000.000 de artículos pedidos para cada uno de los artículos de inventario.
Antes de continuar, quiero mostrarte mi Administrador de Tareas, pestaña Rendimiento para usarlo como línea de base:
Y ahora voy a ejecutar inserciones de un solo hilo de órdenes de venta en Acumatica. Aquí está el código fuente para ello:
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);
}
}
}
}
Ahora, vamos a echar un vistazo, cómo el administrador de tareas se ve afectado después de la ejecución del código de un solo hilo por defecto:
Observe que después de iniciar la carga de importación, el procesador no cambió en absoluto. En realidad, incluso se hizo más pequeño, lo que significa que los 40 núcleos no se utilizarán en todo su potencial. Después de dos horas y 45 minutos, tenía 3.746 pedidos de venta creados. No está mal, pero aún así no es algo de lo que estar especialmente orgulloso.
A continuación he creado un código multihilo:
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);
}
}
}
}
En el ejemplo de código, presta especial atención a la parte con candado:
lock (thisLock)
{
sOEntry.Actions.PressSave();
}
Esto es necesario para sincronizar la persistencia de las órdenes de venta en la base de datos. Sin este bloqueo, varios gráficos intentan simultáneamente crear registros en la base de datos, y como resultado esto bloquea el mecanismo de persistencia de Acumatica, que no es seguro para hilos. Creo que esto puede estar relacionado con el hecho de que los números de las órdenes de venta dependen de elementos generados previamente en la base de datos, y es por eso que consideré el bloqueo como necesario.
He restaurado la base de datos desde la copia de seguridad y he ejecutado el código multihilo o, para ser más precisos, el código multitarea. Echa un vistazo a lo diferente que se ve ahora nuestro Administrador de Tareas:
Y mira: ¡sólo un 7% más de carga! Pero, ¿y la velocidad de creación?
Cabe señalar que en 2 horas, 35 minutos y 26 segundos pude crear los 18.247 pedidos de ventas. Esto significa que nuestro enfoque de un único subproceso consiguió crear 22 pedidos de ventas por minuto. Y nuestro enfoque multihilo nos proporcionó 117 pedidos de ventas creados por minuto, es decir, ¡5 veces más rápido! Como otro punto de optimización, es posible tener dos máquinas, una en la que se instale y ejecute Acumatica y otra en la que se ejecute MS SQL Server. Y para MS SQL server, deberías considerar dividir el archivo de base de datos en un par de discos duros, así como colocar el archivo de registro en un tercer disco duro.
Resumen
En esta entrada del blog, he descrito una de las dos formas de acelerar el rendimiento utilizando multithreading. En la primera parte, hablé de enfoques asíncronos/síncronos. Ambos enfoques pueden mejorar el rendimiento de forma significativa, pero no en el 100% de los casos. Los aumentos reales de rendimiento sólo se obtendrán si tiene que importar, manipular o masajear cantidades significativamente grandes de datos. Estamos hablando de millones de registros.