Improve perf and scalability of Regex's cache (#542)
authorStephen Toub <stoub@microsoft.com>
Fri, 6 Dec 2019 14:48:52 +0000 (09:48 -0500)
committerGitHub <noreply@github.com>
Fri, 6 Dec 2019 14:48:52 +0000 (09:48 -0500)
commitd49fc9e0e4c5e1c1d389940c01fc32196ebc0036
tree4a4d36f8b6c6a24e908a6442a29b69595f969a0e
parentb141e80de76b4f50c6318eeeb558a7f92475daf5
Improve perf and scalability of Regex's cache (#542)

Regex maintains a cache used for the static methods on Regex, e.g. Regex.IsMatch.  The cache is implemented as an LRU cache, which maintains a linked list and a dictionary of the cached instances.  The linked list maintains the order in which the cached instances were last accessed, making it cheap to expunge older items from the cache.  However, that comes at a significant cost: unless the item is the very first one in the linked list, all reads on the cache require taking a global lock, because the linked list needs to be mutated to move the found node to the beginning.  That lock has both throughput and scalability implications.

This PR changes the cache from using a `Dictionary<>` and a linked list to instead using a `ConcurrentDictionary<>` and a `List<>`.  Rather than making all accesses more expensive in order to make drops less expensive, it makes all reads much cheaper and more scalable, at the expense of making drops more expensive.  Since dropping from the cache means we're already paying the expensive cost of creating/parsing/compiling/etc. a new Regex instance, this is a better trade-off, especially since any frequent dropping suggests the consuming app or library needs to revisit its Regex strategy, either using Regex.CacheSize to increase the cache size appropriately, or doing its own caching (e.g. creating the Regex instance it needs and storing it into a field for all future use).

The new scheme uses a `ConcurrentDictionary<Key,Node>`, a `List<Node>`, and a fast-path field storing the most recently used Regex instance (just as the existing implementation did).  On lookups, if the fast-path field has the matching value, it's just returned.  Otherwise, the dictionary is consulted, and if the item is found, the fast-path field is updated.  No locking at all is employed, and only a few volatile read/writes are used to update a "last access stamp" that's used to indicate importance if/when items do need to be expunged.  On additions, we do still take a global lock and add to the cache.  If this puts us over our cache size, we pick an item from the list and remove it.  If the list is small, we just examine all of the items looking for the oldest.  If the list is larger, we examine a random subset of it; we may not get rid of the absolute oldest item, but it'll be old enough.
src/libraries/System.Text.RegularExpressions/src/System.Text.RegularExpressions.csproj
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/Reference.cs [deleted file]
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/Regex.Cache.cs
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/Regex.cs
src/libraries/System.Text.RegularExpressions/tests/Regex.Cache.Tests.cs