La relación de muchos desarrolladores con las expresiones regulares es de amor-odio. Aunque indudablemente son una herramienta muy potente, su uso puede ser complicado y a menudo se convierten en un dolor de cabeza.
Pero hoy no vamos a hablar de su (oscura) sintaxis, ni de lo difícil que es depurarlas, ni de cómo utilizarlas en .NET, sino de distintas técnicas que pueden ayudarnos a disparar su velocidad de proceso, algo bastante importante si las utilizamos en los procesos críticos o hot paths de nuestra aplicación.
En este artículo vamos comparar el rendimiento de distintos escenarios de uso de expresiones regulares, y cómo podemos optimizar su uso en .NET.
Chequear direcciones de email usando expresiones regulares
Como punto de partida, echemos un vistazo al siguiente código, un ejemplo donde definimos la clase estática EmailValidator, con un método IsValid() que utiliza la clase RegEx para validar el email que recibe como parámetro:
Console.WriteLine(EmailValidator.IsValid("john@server.com")); // true
Console.WriteLine(EmailValidator.IsValid("john@smith@server.com")); // false
public static class EmailValidator
{
public static bool IsValid(string email)
{
string emailPattern = @"^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|""(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*"")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$";
var regex = new Regex(emailPattern, RegexOptions.IgnoreCase);
return regex.IsMatch(email);
}
}
No vamos a entrar en el debate de si la expresión regular que hemos utilizado es la mejor para validar un email. Simplemente es la recomendación de un LLM asegurando que cumple la RFC 5322, y, para la prueba que queremos hacer es totalmente válida porque tiene una cierta complejidad.
Si ejecutamos el código anterior, veremos que la expresión regular funciona correctamente y el método IsMatch() nos devuelve true o false dependiendo de si el email es válido o no. Y además, aparentemente la ejecución es bastante rápida, suficiente si no es algo que se vaya a ejecutar con mucha frecuencia.
Sin embargo, internamente, cada vez que llamamos a ese método estático IsValid(), estamos instanciando la clase Regex suministrándole el patrón de la expresión regular, que es parseado, verificado, optimizado, compilado y posteriormente ejecutado por un intérprete para realizar la validación que le estamos solicitando. Todo este proceso puede ser costoso en términos de rendimiento, sobre todo si esa parte del código se ejecuta con mucha frecuencia.
Seguro que podemos mejorar esto...
Primera mejora: reutilización de Regex
La primera optimización que podemos aplicar en este punto es reutilizar la instancia de Regex. De esta forma, evitaremos la sobrecarga de crear una nueva instancia cada vez que llamamos al método IsValid() y evitaremos el proceso de verificación y compilación de la expresión regular.
Esto podríamos conseguirlo fácilmente insertando en la clase anterior el siguiente código:
public static class EmailValidator
{
private const string EmailPattern = @"^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|""(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*"")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$";
private static Regex SharedInstance = new Regex(EmailPattern, RegexOptions.IgnoreCase);
public static bool IsValid_Shared(string email)
{
return SharedInstance.IsMatch(email);
}
}
Si ejecutamos de nuevo la aplicación, veremos que el funcionamiento es exactamente el mismo, y que aparentemente sigue siendo igual de rápido. Pero si usamos BenchmarkDotNet para medir el rendimiento de las dos implementaciones, nos llevaremos una sorpresa:
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|-----------------|-----------:|----------:|----------:|--------:|----------:|
| IsValid | 206.716 us | 1.5089 us | 1.2600 us | 27.3438 | 233969 B |
| IsValid_Shared | 2.742 us | 0.0312 us | 0.0276 us | - | - |
Esta segunda implementación se ejecuta casi 80 veces más rápido que la primera, sin consumo de memoria adicional. Impresionante, ¿verdad? Realmente se trata de una mejora brutal a cambio de muy poco esfuerzo de implementación.
Hay que tener en cuenta que las cifras no son siempre tan espectaculares, y que el rendimiento de la primera implementación puede variar dependiendo de la complejidad del patrón de la expresión regular. En expresiones más simples, la diferencia de rendimiento puede ser mucho menor, pero en cualquier caso habrá mejoras.
Pero... ¿aún podemos hacerlo mejor?
Segunda mejora: compilación de la expresión regular
Por defecto, las expresiones regulares se compilan a una serie de instrucciones de alto nivel que indican las operaciones que deben realizarse para comprobar si la cadena de texto suministrada coincide con el patrón de la expresión regular. Luego, en cada llamada a IsMatch() o métodos similares, un intérprete ejecuta esas instrucciones para realizar la validación.
Sin embargo, la clase Regex también permite compilar la expresión regular a código IL, por lo que el runtime de .NET puede ejecutarlo directamente e incluso, gracias al JIT, generar y ejecutar el código máquina nativo para la plataforma donde corre la aplicación, a cambio, eso sí, de consumir un poco más de memoria y tiempo durante su inicialización.
Esto lo conseguimos de nuevo con muy poco esfuerzo, simplemente añadiendo el RegexOptions.Compiled a la llamada al constructor de la clase Regex:
private static Regex SharedCompiledInstance
= new Regex(EmailPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
Si volvemos a llevarnos las tres opciones a BenchmarkDotNet, y medimos su rendimiento, veremos que en este último caso hemos mejorado algo más el rendimiento:
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|------------------ |-----------:|----------:|----------:|--------:|----------:|
| IsValid | 202.007 us | 2.4068 us | 2.2513 us | 27.3438 | 233969 B |
| IsValid_Shared | 2.606 us | 0.0276 us | 0.0258 us | - | - |
| IsValid_Compiled | 2.570 us | 0.0141 us | 0.0132 us | - | - |
En este caso la diferencia es mínima, pero es algo que también depende de la complejidad de las operaciones que hay que realizar para validar los valores contra la expresión regular. Por ejemplo, si en lugar de usar la expresión regular que hemos visto anteriormente para detectar emails, ejecutamos el mismo benchmark para un patrón aparentemente simple como "(\d+)*\1" y hacemos que se compruebe un string numérico muy largo (unos 100.000 dígitos), la diferencia de rendimiento es mucho más notable:
| Method | Mean | Error | StdDev | Allocated |
|----------------- |---------:|---------:|---------:|----------:|
| IsValid | 65.70 ms | 1.107 ms | 1.088 ms | 5401 B |
| IsValid_Shared | 63.74 ms | 0.925 ms | 0.772 ms | 57 B |
| IsValid_compiled | 19.52 ms | 0.147 ms | 0.130 ms | 12 B |
La expresión regular
"(\d+)*\1"permite buscar cadenas que contengan un número seguido de un número repetido, como por ejemplo123123,456456,789789, etc. Esta expresión regular es ejemplo conocido por dar lugar al llamado catastrophic backtracking, un problema que puede dar lugar a un rendimiento muy bajo en ciertas expresiones regulares, que incluso puede ser explotado en ataques de denegación de servicio (DoS) en aplicaciones web.
Estos resultados son fácilmente explicables: la diferencia de rendimiento entre la primera y segunda opción es pequeña, porque la expresión regular es muy simple y, por tanto, el coste de su compilación es bajo. Pero el rendimiento se multiplica por tres en la tercera opción porque la ejecución de la expresión regular se beneficia de la compilación a código IL.
Hasta aquí, hemos comprobado cómo realizando un par de modificaciones simples en el código podemos lograr mejorar considerablemente el rendimiento de las expresiones regulares en .NET. Pero aún hay más...
Tercera mejora: compilar la expresión regular en tiempo de diseño
La compilación de la expresión regular a código IL es una mejora muy interesante, pero tiene un coste adicional el términos de memoria y proceso, que se produce en el momento de la inicialización de la expresión regular, es decir, en tiempo de ejecución.
De hecho, también podemos realizar un benchmark del tiempo de creación de la instancia de Regex con y sin compilación, y veremos que la diferencia es prácticamente del triple, tanto en tiempo de proceso como en consumo de memoria:
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
|-------------------- |---------:|---------:|---------:|--------:|-------:|----------:|
| CreateRegex | 28.64 us | 0.204 us | 0.170 us | 3.4180 | - | 29.27 KB |
| CreateRegexCompiled | 99.51 us | 0.973 us | 0.863 us | 10.7422 | 1.4648 | 90 KB |
Si queremos evitar este sobrecoste, a partir de .NET 7 podemos compilar la expresión regular en tiempo de diseño usando source generators. De esta forma, el compilador de C# generará el código C# necesario para ejecutar la expresión regular, y lo incluirá en el ensamblado de la aplicación, por lo que no pagaremos ningún coste adicional en tiempo de ejecución. Pero además, como veremos algo más adelante, el código generado será mucho más eficiente que la versión compilada en tiempo de ejecución 🙂
Para conseguirlo, en una clase cualquiera debemos un método parcial de tipo Regex y asignarle el atributo [GeneratedRegex] especificando el patrón de la expresión regular y las opciones que queramos utilizar. Por ejemplo, en el siguiente código podemos ver el método al que hemos llamado GeneratedEmailRegex() sobre la clase estática parcial EmailRegex (ambos nombres son arbitrarios):
public static partial class EmailRegex
{
[GeneratedRegex(@"^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|""(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*"")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$", RegexOptions.IgnoreCase)]
public static partial Regex GeneratedEmailRegex();
}
Podéis ver fácilmente el código generado ejecutando la aplicación y, en el explorador de soluciones de Visual Studio, desplegando la carpeta "External Sources", el ensamblado de la aplicación, y abriendo el archivo
RegexGenerator.g.cs, o bien, siguiendo estos pasos.
Una vez tenemos este método disponible, para utilizar la expresión regular simplemente debemos usar la instancia de Regex retornada por el mismo, por ejemplo así:
public static class EmailValidator
{
public static bool IsValid(string email)
{
return EmailRegex.GeneratedEmailRegex().IsMatch(email);
}
}
Y si de nuevo nos llevamos estos cambios a BenchmarkDotNet, y medimos el rendimiento de las distintas implementaciones, de nuevo nos llevaremos una alegría:
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|-------------------------- |-------------:|------------:|------------:|--------:|----------:|
| IsValid_Initial | 213,564.0 ns | 1,532.91 ns | 1,280.05 ns | 27.3438 | 233969 B |
| IsValid_Shared_Instance | 2,667.7 ns | 39.86 ns | 35.33 ns | - | - |
| IsValid_Compiled_Instance | 2,745.5 ns | 37.81 ns | 35.37 ns | - | - |
| IsValid_UsingGenerators | 788.3 ns | 7.91 ns | 7.40 ns | - | - |
¡Uau! De nuevo hemos conseguido dividir por tres el tiempo de ejecución de la expresión regular respecto a la versión compilada en tiempo de ejecución. Y bueno, si lo comparamos con la versión inicial, la que implementamos sin pensar en ninguna optimización, es cerca de 300 veces más eficiente.
Conclusiones
A veces, el código que escribimos puede no ser el más óptimo: a veces por costumbre, a veces por comodidad, u otras simplemente porque no conocemos fórmulas mejores. En algunos casos no importará demasiado porque quizás nuestros requisitos de rendimiento no son excesivamente exigentes, pero en otros muchos escenarios sí debemos prestar atención a este tipo de detalles.
Lo que hemos visto en este post es un claro ejemplo de cómo las mejoras que se van introduciendo en el framework y el SDK de .NET pueden ayudarnos a mejorar el rendimiento de nuestras aplicaciones con muy poco de esfuerzo.
Publicado en: www.variablenotfound.com.

Aún no hay comentarios, ¡sé el primero!
Enviar un nuevo comentario