Nouveau Lock (C# 13/.NET 9)

Cet article fait partie d’une série d’articles sur les nouveautés fonctionnelles de C# 13.

A partir de C# 13/.NET 9, un nouvel objet System.Threading.Lock est disponible pour simplifier la gestion des sections critiques dans les applications multithread. Cet article présente les différentes syntaxes possibles avec ce nouvel objet, compare ses performances avec le mot-clé lock traditionnel, et explique les raisons qui ont motivé cette nouvelle implémentation.

Pour gérer la synchronisation, éviter les problèmes d’accès concurrent dans les applications multithread, et utiliser un verrou (i.e. lock) de manière simple, on utilise traditionnellement le mot-clé lock en C#.

Syntaxe classique:

private object lockObject = new object();
//
lock (lockObject)
{
  // section critique
  ...
}

Cette solution de synchronisation est la plus simple. Elle permet de protéger une section critique en n’autorisant son exécution que par un seul thread à la fois (lock exclusif) :

  • Le 1er thread arrive au niveau du lock et vérifie si l’objet “verrou” est déjà utilisé par un autre thread.
  • Si le verrou est utilisé, il attend qu’il se libère.
  • Si le verrou n’est pas utilisé, il le verrouille et entre dans la section critique. Tous les autres threads restent bloqués tant que le thread courant n’est pas sorti de la section critique et n’a pas libéré le verrou.

L’utilisation du mot-clé lock implique:

  • Il faut un objet “verrou” dédié (comme lockObject), souvent de type object, mais n’importe quel objet de type référence convient.
  • Les performances de cette solution pour protéger une section critique sont correctes par rapport à d’autres solutions (cf. le célèbre site de Joe Albahari sur la programmation concurrente: www.albahari.com/threading/). Un ordre de grandeur souvent mentionné dans les entretiens d’embauche était 20ns (ce chiffre a probablement évolué depuis).
  • La solution technique derrière cette syntaxe repose sur Monitor.Enter/Monitor.Exit, qui utilise des éléments du système d’exploitation peu interopérables. Maintenant que .NET est multiplateforme, il était nécessaire d’améliorer la solution pour protéger simplement une section critique.

Au fil des versions de .NET, plusieurs objets permettant d’appliquer un verrou fonctionnel ont été ajoutés:

  • .NET 2.0: ReaderWriterLock permet de différencier les threads en lecture seule de ceux en écriture.
  • .NET 3.5: ReaderWriterLockSlim améliore ReaderWriterLock avec de meilleures performances.
  • .NET 4.0: introduction d’autres objets comme SemaphoreSlim (version améliorée de Semaphore, plus rapide mais ne permettant pas les locks entre processus); ManualResetEventSlim (version plus performante de ManualResetEvent) qui offre une solution pour contrôler l’attente d’un thread ; SpinLock qui permet d’appliquer un lock sans changement de contexte, utile dans les cas avec beaucoup de contention entre threads et pour minimiser la durée des locks.
    .NET 4.0 correspond également à l’introduction de TPL (Task Parallel Library) avec les objets Task dans le namespace System.Threading.Tasks, qui ont permis d’implémenter facilement des tâches asynchrones.
  • .NET 4.5: bien qu’il ne s’agisse pas de lock à proprement parler, async/await propose une solution pour attendre la fin d’un traitement de façon asynchrone.
  • .NET 5/.NET Core 1.0: pour faciliter l’utilisation d’objets partagés entre threads sans devoir implémenter explicitement des locks, des objets comme ConcurrentDictionary, ConcurrentQueue, ConcurrentBag et ConcurrentStack ont été introduits.
  • C# 7: introduction de l’objet ValueTask, un objet de type valeur plus léger et équivalent à Task.
  • C# 8: introduction des flux asynchrones (async streams) et des objets dans le namespace System.Threading.Channels pour permettre l’implémentation de cas d’utilisation plus avancés d’accès concurrents.

Avec C# 13 apparaît un nouvel objet System.Threading.Lock pour simplifier certains scénarios de synchronisation tout en conservant la sécurité et en améliorant les performances.

Syntaxe

Mot-clé lock traditionnel

Le mot-clé lock est un sucre syntaxique qui encapsule de manière concise Monitor.Enter/Monitor.Exit. Si on examine le code MSIL correspondant au code ci-dessus, la syntaxe utilisée sera équivalente à:

Monitor.Enter(lockObject);
try
{
  // Section critique
  ...
}
finally
{
  Monitor.Exit(lockObject);
}

Syntaxes possibles avec System.Threading.Lock (C# 13)

Plusieurs syntaxes sont possibles avec le nouveau type System.Threading.Lock. Lorsqu’on utilise le mot-clé lock avec le nouveau type System.Threading.Lock, c’est ce nouveau type qui sera utilisé dans le code MSIL, et non Monitor.

Mot-clé lock + System.Threading.Lock

Exemple de syntaxe avec le lock traditionnel:

private readonly Lock lockObject = new();
//
lock (lockObject)
{
  // section critique
  ...
}

Le code MSIL n’est pas équivalent à celui du mot-clé lock utilisé avec un objet de type référence classique:

// using (lockObject.EnterScope())
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
IL_0006: callvirt instance valuetype [System.Runtime]System.Threading.Lock/Scope [System.Runtime]System.Threading.Lock::EnterScope()
IL_000b: stloc.0
.try
{
  // Section critique
   // ...
  // }
  IL_0019: leave.s IL_0023
} // end .try
finally
{
  // (no C# code)
  IL_001b: ldloca.s 0
  IL_001d: call instance void [System.Runtime]System.Threading.Lock/Scope::Dispose()
  IL_0022: endfinally
} // end handler

IL_0023: ret

Comme on peut le voir, ce n’est pas Monitor.Enter() qui est utilisé mais bien la classe Lock.EnterScope().
Lock.EnterScope() permet d’obtenir un objet de type valeur Lock.Scope correspondant à une portée dans laquelle le lock sera exclusif.

Le code MSIL est équivalent à:

private readonly Lock lockObject = new();
//
var scope = lockObject.EnterScope();
try
{
  // Section critique
}
finally
{
  scope.Dispose();
}

Lock.EnterScope()

Une syntaxe équivalente à la précédente consiste à utiliser directement Lock.EnterScope() avec using :

private readonly Lock lockObject = new();
//
using (lockObject.EnterScope())
{
  // section critique
  ...
}

lockObject.EnterScope() retourne un objet de type Lock.Scope, de type valeur, qui implémente IDisposable.
Le code MSIL généré est identique à celui de lock + System.Threading.Lock (voir ci-dessus).

Lock.Enter()

lock.Enter() doit être utilisé avec try...finally:

private readonly Lock lockObject = new();
//
lockObject.Enter();
try
{
  // Section critique 
  // ...
}
finally
{
  lockObject.Exit();
}

Le code MSIL est identique au code C#:

// lockObject.Enter();
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
IL_0006: callvirt instance void [System.Runtime]System.Threading.Lock::Enter()
.try
{
  // Section critique
   // ...
  // }
  IL_0018: leave.s IL_0026
} // end .try
finally
{
  // lockObject.Exit();
  IL_001a: ldarg.0
  IL_001b: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
  IL_0020: callvirt instance void [System.Runtime]System.Threading.Lock::Exit()
  // }
  IL_0025: endfinally
} // end handler

// (no C# code)
IL_0026: ret

Lock.TryEnter()

Lock.TryEnter() retourne un booléen à true s’il a été possible d’acquérir le lock exclusif, sinon false. Cette fonction ne bloque pas lors de son appel : elle renvoie immédiatement le résultat. D’autres surcharges existent pour différents cas d’utilisation, comme Lock.TryEnter(Int32) et Lock.TryEnter(TimeSpan) pour attendre un temps donné avant d’obtenir l’accès à la section critique.

La syntaxe est:

private readonly Lock lockObject = new();
//
if (lockObject.TryEnter())
{
  try
  {
    // Section critique
    // ...
  }
  finally
  {
    lockObject.Exit();
  }
}

Le MSIL ne révèle pas de surprise, il est proche du code C#:

// if (lockObject.TryEnter())
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
IL_0006: callvirt instance bool [System.Runtime]System.Threading.Lock::TryEnter()
IL_000b: brfalse.s IL_0028
.try
{
  // Section critique
  //
  // }
  IL_001a: leave.s IL_0028
} // end .try
finally
{
  // lockObject.Exit();
  IL_001c: ldarg.0
  IL_001d: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
  IL_0022: callvirt instance void [System.Runtime]System.Threading.Lock::Exit()
  // }
  IL_0027: endfinally
} // end handler

// }
IL_0028: ret

Comparaison des performances entre lock et System.Threading.Lock

Pour comparer les performances entre le lock traditionnel et System.Threading.Lock, nous allons exécuter le même code avec différentes méthodes de protection pour accéder à la section critique.

Le code de cette partie est disponible sur github.com/msoft/cs13/blob/master/CS13/NewLockObject/NewLockWithContention.cs.

Le code à exécuter correspond à la suite de Fibonacci, exécutée plusieurs fois:

internal class FibonacciRunner: IRunner
{
  private readonly int runCount;
  private readonly List<long> fibonacciRunResults;
  private readonly Random random = new();
  private int runIndex = 0;
  private readonly ILogger logger;

  public FibonacciRunner(ILogger logger, int runCount)
  {
    this.logger = logger;
    this.runCount = runCount;
    fibonacciRunResults = new List<long>();
  }

  public bool Run()
  {
    if (runIndex >= runCount)
      return false;

    int n = random.Next(1, 100);
    long result = RunFibonacciSequence(n);
    logger.Log($"Fibonacci({n}) = {result} for run #{runIndex}");

    fibonacciRunResults.Add(result);
    runIndex++;
        
    return runIndex < runCount;
  }

  private static long RunFibonacciSequence(int n)
  {
    long n1 = 0;
    long n2 = 1;
    for (int i = 0; i < n; i++)
    {
      long oldN2 = n2;
      n2 = n1 + n2;
      n1 = oldN2;
    }
    return n1;
  }
}

Nous implémentons plusieurs objets de type IRunner correspondant aux différentes méthodes pour protéger la section critique (IRunner.Run() permet d’exécuter la suite de Fibonacci) :

internal interface IRunner
{
  bool Run();
}

Par exemple pour un lock traditionnel, l’implémentation est:

internal class RunnerWithOldLock: IRunner
{
  private readonly FibonacciRunner runner;
  private object oldLock = new object();

  public RunnerWithOldLock(ILogger logger, int runCount)
  {
    runner = new FibonacciRunner(logger, runCount);
  }

  public bool Run()
  {
    lock (oldLock)
    {
      return runner.Run();
    }
  }
}

Pour provoquer de la contention lors de l’exécution du code, nous utilisons un code qui lance plusieurs tasks en parallèle :

internal class TestRunner
{
  private readonly IRunner runner;
  private readonly ILogger logger;

  public TestRunner(ILogger logger, IRunner runner)
  {
    this.logger = logger;
    this.runner = runner;
  }

  public void RunTests(int taskCount)
  {
    var tasks = new Task[taskCount];
    for (int i = 0; i < taskCount; i++)
    {
      logger.Log($"Launching task #{i}");
      Task task = new Task(() => RunJob(i));
      tasks[i] = task;
      task.Start();
    }

    Task.WaitAll(tasks);
  }

  private void RunJob(int taskId)
  {
    bool canRun = true;
    while (canRun)
    {
      logger.Log($"Task #{taskId} job starts...");
      canRun = runner.Run();
      logger.Log($"Task #{taskId} job ends (canRun: {canRun}).");
    }
  }
}

Enfin, nous implémentons les différents cas d’utilisation pour effectuer la comparaison entre:

  • UsingOldLock(): utilisation du lock traditionnel,
  • UsingLockEnterScope(): utilisation de System.Threading.Lock.EnterScope(),
  • UsingLockEnter(): utilisation de System.Threading.Lock.Enter(),
  • UsingLockTryEnter(): utilisation de System.Threading.Lock.TryEnter().

Le code est:

[Benchmark]
public void UsingOldLock()
{
  var runner = new TestRunner(logger, new RunnerWithOldLock(logger, runCount));
  runner.RunTests(threadCount);
}

[Benchmark]
public void UsingLockEnterScope()
{
  var runner = new TestRunner(logger, new RunnerUsingLockEnterScope(logger, runCount));
  runner.RunTests(threadCount);
}

[Benchmark]
public void UsingLockEnter()
{
  var runner = new TestRunner(logger, new RunnerUsingLockEnter(logger, runCount));
  runner.RunTests(threadCount);
}

[Benchmark]
public void UsingLockTryEnter()
{
  var runner = new TestRunner(logger, new RunnerUsingLockTryEnter(logger, runCount));
  runner.RunTests(threadCount);
}

En répétant l’exécution de ce code plusieurs millions de fois, nous obtenons des temps qui permettent de comparer les différentes méthodes.
Résultats :

//| Method              | Mean    | Error    | StdDev   |
//|-------------------- |--------:|---------:|---------:|
//| UsingOldLock        | 1.785 s | 0.0361 s | 0.1041 s |
//| UsingLockEnterScope | 1.540 s | 0.0216 s | 0.0192 s |
//| UsingLockEnter      | 1.576 s | 0.0312 s | 0.0334 s |
//| UsingLockTryEnter   | 1.453 s | 0.0278 s | 0.0352 s |

Ainsi, on peut constater que le lock traditionnel est légèrement moins performant que les autres méthodes utilisant System.Threading.Lock (environ 16% plus lent).

Implémentation des “locks”

Pour comprendre les différences entre le lock traditionnel et System.Threading.Lock(), examinons le code source de ces deux méthodes de protection d’une section critique.

Lock traditionnel

Comme indiqué précédemment, le code généré par l’utilisation du mot-clé lock correspond à Monitor.Enter()/Monitor.Exit().

Le code de Monitor.Enter() se trouve dans Monitor.CoreCLR.cs. Cette méthode renvoie à:

[LibraryImport(RuntimeHelpers.QCall, EntryPoint = "Monitor_Enter_Slowpath")]
private static partial void Enter_Slowpath(ObjectHandleOnStack obj);

Cette méthode fait référence à du code natif:
extern "C" void QCALLTYPE Monitor_Enter_Slowpath(QCall::ObjectHandleOnStack objHandle) dans objectnative.cpp#L191.

En suivant la chaîne d’appels:

On peut remarquer plusieurs choses:

  • Le code correspondant à la gestion du lock est en code natif comme une grande partie du code du CLR,
  • Le lock utilise le SyncBlock.

SyncBlock (bloc de synchronisation)

Le mécanisme de locks en .NET utilise traditionnellement deux concepts: SyncBlock et ThinLock. Le ThinLock est utilisé lorsque l’accès à une section critique n’est autorisé qu’à un seul thread de façon incontestable. Le ThinLock est une protection plus légère et plus performante pour accéder à la section critique. Ce terme ThinLock n’apparaît pas clairement dans le code, mais on peut constater que l’utilisation d’un SyncBlock n’est pas systématique lors de l’utilisation d’un lock.

Pour appliquer un lock, le CLR utilise l’en-tête des objets en .NET pour stocker des informations dans le champ de l’index SyncBlock. Cet en-tête n’est pas fixe et peut changer selon le comportement de l’objet. De manière générale, l’en-tête contient des informations relatives à la gestion des verrous, au hashcode et à d’autres métadonnées.

En cas de lock d’un objet, le CLR stocke dans l’en-tête de l’objet des informations relatives au lock (comme l’ID du thread, l’état du lock, le nombre de boucles récursives et des informations sur les threads en attente). En l’absence de concurrence dans l’application du lock, le mécanisme de ThinLock suffit. Dans le cas contraire, c’est le mécanisme de SyncBlock qui est appliqué. Selon la concurrence entre les threads, le CLR peut décider de convertir un ThinLock en SyncBlock pour gérer la synchronisation.

Justifications à la nouvelle implémentation de locks

Comme nous l’avons vu, même si l’implémentation traditionnelle comporte des optimisations, elle utilise n’importe quel objet de type référence comme objet contenant les informations concernant le thread qui accède à la section critique. Ces informations sont stockées dans la partie SyncBlock de l’objet. Bien que l’objectif initial était de permettre une certaine flexibilité, cette approche rend plus difficile l’implémentation d’optimisations selon les différents cas d’utilisation, car elle empêche d’utiliser un objet précis et spécialisé pour assurer le verrou et stocker les informations du lock.

Dans cette logique, l’utilisation de la classe System.Threading.Lock permet, par exemple, d’appliquer le pattern Dispose pour délimiter une section critique. De la même façon, la classe System.Threading.Lock permet d’utiliser directement des fonctions comme TryEnter() pour éviter d’attendre si le lock est déjà pris par un autre thread. Ce cas d’utilisation existait déjà avant C# 13 avec Monitor.TryEnter(), mais il nécessitait d’utiliser un autre objet.

Dans le code du CLR, la classe System.Threading.Lock existait avant C# 13, à partir de .NET Core 5.0, dans Microsoft.Internal :

internal sealed class Lock: IDisposable
{
  // ...
}

La justification de cette classe reposait sur la volonté de permettre des optimisations pour éviter d’utiliser des mécanismes trop coûteux en performance pour effectuer des locks.
Maintenant, la classe System.Threading.Lock se trouve dans System.Threading. Elle permet de stocker les informations liées au thread ayant pris le lock plutôt que d’utiliser la partie SyncBlock.
Si on examine la méthode Lock.Enter(), on peut voir qu’il existe plusieurs possibilités d’optimisation dans Lock.TryEnter_Inlined() puis Lock.State.TryLock().

L’implémentation à proprement parler du lock se trouve dans Lock.TryEnterSlow().

Conclusion

L’introduction de System.Threading.Lock en C# 13 marque une évolution importante dans la gestion de la synchronisation en .NET. Avec un gain de performance non négligeable par rapport au mot-clé lock traditionnel et une API plus riche offrant notamment TryEnter().

Pour les nouveaux projets, il est recommandé d’utiliser System.Threading.Lock plutôt que le lock traditionnel, tout en gardant à l’esprit que la migration du code existant n’est pas urgente. Le mot-clé lock classique reste parfaitement fonctionnel et continuera d’être maintenu. Cette évolution s’inscrit dans la continuité des améliorations apportées aux mécanismes de synchronisation depuis .NET 2.0, avec une implémentation plus moderne qui facilite les optimisations futures du runtime.

Leave a Reply