En este artículo quisiera mostrarles cual es el consumo de memoria de algunas técnicas de acceso a datos. En artículos anteriores hemos estudiado y optimizado performance mejorando el tiempo de procesamiento. Como colorario veremos algunos gráficos que siempre ayudan a la comparación.
Este artículo está relacionado con:
Presentación del escenario
Este es el contexto en el que estoy haciendo las mediciones:
Una aplicación Windows Forms, que utiliza 4 mecanismos para recuperar datos “de solo lectura” de la base de datos AdvertureWorks alojada en SQL Server 2005:
- DataReader cargado en una lista genérica de objetos de entidad
- DataSet
- DataTable
- DataSet tipificado creado con el asistente de Visual Studio 2005
Aquí subrayo “solo lectura” porque, justamente solo quiero recuperar los datos, y no hacer ninguna operación sobre ellos.
Memoria y Garbage Collector
Si bien sabemos que la administración de la memoria en .NET es un trabajo que le compete al Garbage Collector y que no es terreno en el que debamos hurgar, a no ser que sea por administración de memoria no manejada, siempre es bueno saber que uso hacemos de él. Si bien el Garbage Collector es un mecanismo muy optimizado, y hace un muy buen trabajo de recolección de basura (memoria no utilizada), tiene sus limitaciones y su costo. Sería una buena actitud de parte nuestra considerar al Garbage Collector como un recurso más, así como lo es la memoria. Teniendo en cuenta esto lograríamos minimizar su trabajo, lo cual redundaría en un mejor rendimiento de nuestra aplicación.
El Código
La versión completa del código podrás bajarla de aquí. De todas formas démosle un vistazo:
Esta es la sentencia sql a ejecutar en la base de datos AdventureWorks:
Select HumanResources.Employee.EmployeeID, Person.Contact.FirstName,
Person.Contact.MiddleName, Person.Contact.LastName,
HumanResources.Employee.Title, HumanResources.Employee.BirthDate,
Person.Address.AddressLine1, Person.Address.AddressLine2,
Person.Address.City, Person.Address.PostalCode, Person.Contact.EmailAddress,
Person.Contact.Phone, HumanResources.Employee.MaritalStatus, HumanResources.Employee.Gender
FROM HumanResources.Employee
INNER JOIN Person.Contact ON HumanResources.Employee.ContactID = Person.Contact.ContactID
INNER JOIN HumanResources.EmployeeAddress ON HumanResources.Employee.EmployeeID = HumanResources.EmployeeAddress.EmployeeID
INNER JOIN Person.Address ON HumanResources.EmployeeAddress.AddressID = Person.Address.AddressID
AND HumanResources.EmployeeAddress.AddressID = Person.Address.AddressID
La clase DataAccess
public class DataAccess
{
static readonly string _connString;
static readonly string _sqlCmd;
static DataAccess()
{
_connString = "Password=;User ID=sa;Initial Catalog=AdventureWorks;Data Source=WALZER3";
//Obtengo la sentencia SQL que está en el archivo de texto Consulta.sql
StreamReader sr = new StreamReader(Assembly.GetExecutingAssembly().GetManifestResourceStream("Walzer.Antipracticas.Consulta.sql"));
_sqlCmd = sr.ReadToEnd();
}
static public DataSet TraerDataSet()
{
DataSet ds = null;
try
{
using (SqlConnection conn = new SqlConnection(_connString))
{
conn.Open();
SqlCommand cmd = new SqlCommand();
cmd.CommandText = "GetEmployees";
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
SqlDataAdapter da = new SqlDataAdapter(cmd);
ds = new DataSet();
da.Fill(ds);
}
}
catch
{
}
return ds;
}
static public List<Employee> TraerEmployeesOptimizado()
{
List<Employee> employees = new List<Employee>();
try
{
using (SqlConnection conn = new SqlConnection(_connString))
{
SqlCommand cmd = new SqlCommand();
cmd.CommandText = "GetEmployees";
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
conn.Open();
using (SqlDataReader dr = cmd.ExecuteReader())
{
int colEmployeeId = dr.GetOrdinal("EmployeeId");
int colFirstName = dr.GetOrdinal("FirstName");
// Omito las líneas similares por cuestión de lectura
int colCount = dr.FieldCount;
object[] values = new object[colCount];
while (dr.Read())
{
Employee employee = new Employee();
dr.GetValues(values);
employee.EmployeeID = Convert.ToInt32(values[colEmployeeId]);
employee.FirstName = Convert.ToString(values[colFirstName]);
employees.Add(employee);
}
}
}
}
catch
{
}
return employees;
}
static public DataTable TraerDataTableOptimizado()
{
//Este método está optimizado para cargar un DataTable con datos de SOLO LECTURA
DataTable dt = null;
try
{
using (SqlConnection conn = new SqlConnection(_connString))
{
conn.Open();
SqlCommand cmd = new SqlCommand();cmd.CommandText = "GetEmployees";
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
SqlDataAdapter da = new SqlDataAdapter(cmd);dt = new DataTable();
da.Fill(dt);
}
}
catch
{
}
return dt;
}
}
El DataSet tipificado fue creado arrastrando la consulta SQL sobre la superficie de diseño del DataSet, lo único que escribí fue las siguientes líneas para cargar el DataSet tipificado:
DsEmployeesTableAdapters.GetEmployeesTableAdapter da = new Walzer.Antipracticas.DsEmployeesTableAdapters.GetEmployeesTableAdapter();
_dsEmployees = da.GetData();
Lectura del uso de Memoria
Vamos medir el uso de memoria de cada una de estas técnicas de acceso a datos utilizando 3 herramientas:
El CLR Profiler nos revela en una primera lectura de 290 registros representados en memoria por cada una de las técnicas.


En este gráfico podemos observar que el objeto del tipo AntiPracticas.frmMemoria, que es nuestra ventana, y sus referenciados consumen 836 Kb. Aunque la variable que apunta a esta estructura es de solo 368 bytes.
AntiPracticas.frmMemoria tiene cuatro campos privados que apuntan a:
- un DataSet Tipado (DsEmployees.GetEmployeesDataTable): 313 Kb
- un DataSet (Data.DataSet): 185 Kb
- un DataTable (Data.DataTable ): 184 Kb
- una colección genérica de objetos del tipo Employee (Generic.List<T>): 138 Kb
Aquí mismo podemos apreciar que el DataSetTipado es la estructura más costosa en cuanto a consumo de memoria. Que no hay casi diferencia entre un DataSet y un DataTable, y que la colección de objetos es la más barata. No está demás destacar que todas las estructuras contienen “los mismos datos”.
La misma información podemos verla en JetBrains DotTrace.


Observen la columna “Held Memory, bytes”, que es la memoria referenciada por cada instancia:
- _dsEmployees (Walzer.Antipracticas.DsEmployees.GetEmployeesDataTable)
- _ds (System.Data.DataSet)
- _dt (System.Data.DataTable)
- _employees (System.Collection.Generic.List<Employee>)
Estructura de Objetos en memoria
Comparemos en las siguientes dos capturas la complejidad de una y otra estructura, las cuales almacenan los mismos datos, de solo lectura en nuestro caso.
La primera figura nos muestra la lista genérica _employees, la cual está implementada internamente por un vector de _items, que contiene un conjunto de objetos Employee, la cual contiene finalmente los datos.
Observemos ahora la estructura de un DataSet tipificado, y el camino para llegar al dato final.
La estructura es mucho más compleja, pero no perdamos de vista que un DataSet fue diseñado con la premisa de propósito general, y mucho de su funcionalidad es útil. Debemos usar nuestro criterio a la hora de decidir que es mejor para nuestro sistema.
Inspeccionado contenido de las variables
Usemos ahora la herramienta .NET Memory Profiler para ver el contenido de un objeto del tipo Employee. Esta figura nos muestra las referencias a la que hace este objeto, que son System.String.
Pero, ¿dónde está el campo _idEmployee que es del tipo int o _birthDate que es de tipo DateTime? Bien, estos están contenidos en el mismo espacio de memoria que el objeto del tipo Employee ya que son tipos básicos, int y ulong respectivamente. En cambio System.String es una referencia al espacio de memoria donde está guardada la cadena de caracteres. La solapa Field Values nos muestra el contenido de la instancia #12,729 del objeto del tipo Employee. Además de esta información podemos apreciar, cuales son los caminos al Root de este objeto, y cuál fue el Call Stack que instanció este objeto en memoria.
Cantidad de Objetos referenciados
Un dato que no es menor aquí es el que nos muestra la columna “Held Objects”. Esta nos dice cuantos objetos son referenciados en toda la estructura en memoria.
En este caso la cantidad de filas en memoria para cada estructura es de 10, valor que se asemeja más a la realidad, ya que no es buena práctica pasar todas las filas del resultado entre capas, sino usar técnicas de paginación.
- _dsEmployees (Walzer.Antipracticas.DsEmployees.GetEmployeesDataTable): 320
- _ds (System.Data.DataSet): 213
- _dt (System.Data.DataTable): 206
- _employees (System.Collection.Generic.List<Employee>): 122
Más allá de la cantidad de memoria en bytes, la cantidad de objetos referenciados nos da una idea del trabajó que tendrá el Garbage Collector al momento de deshacerse de estos objetos. Cuantas más referencias en memoria, más recursos consumidos por este algoritmo.
Comparación de resultados
Veamos una serie de gráficos que resumen las lecturas realizadas. Tomé lecturas de 290 registros, 10 registros (que es el típico caso del tamaño de una página cuando se realiza paginación) y 1 registro.
| Bytes en Memoria |
290 reg |
10 reg |
1 reg |
| DataSet |
189112 |
19028 |
14856 |
| DataTable |
188848 |
18764 |
14588 |
| List<> |
141774 |
4790 |
514 |
| DataSet Tipado (wizard) |
319478 |
33114 |
28902 |
Fig1: Tabla Comparativa de Bytes en Memoria
Fig2: Bytes en memoria para 290 registros
Fig3: Bytes en memoria de 10 y 1 registro.
| Referencias |
290 reg |
10 reg |
1 reg |
| DataSet |
3581 |
213 |
105 |
| DataTable |
3574 |
206 |
98 |
| List<> |
3477 |
122 |
13 |
| DataSet Tipado (wizard) |
3788 |
300 |
192 |
Fig4: Tabla Comparativa de Objetos Referenciados
Fig5: Instancias referenciadas para 290 registros
Fig6: Instancias referenciadas para 10 y 1 registro
Estos gráficos muestran claramente que la técnica más económica es pasar entre capas una lista genérica de un tipo específico. Y que la ferretería utilizada por las estructuras del tipo DataSet se puede despreciar cuanto mayor es el volumen que contienen.
Conclusión
Hemos comprobado que el uso correcto de las técnicas de acceso a datos en ADO.NET nos permite lograr un mayor rendimiento en nuestras aplicaciones. También hemos aprendido algo de cómo funciona internamente ADO.NET, y como son las estructuras en memoria y el uso que se hace de ellas.
Siempre es bueno conocer cómo funcionan internamente los frameworks que utilizamos para construir nuestras aplicaciones para poner en la balanza, facilidad y agilidad de uso contra rendimiento y consumo de recursos.