Aspire : l’asynchrone aux frontieres natives - P/Invoke, UnmanagedCallersOnly, et la famine observee
Pourquoi ce notebook existe
Le curriculum Aspire enseigne deja six analyseurs Roslyn (AgentGuard.Analyzers) qui detectent les blessures classiques du code d’agent manage : TaskResultBlock (.Result qui bloque), AsyncVoid (exception non observee), SyncOverAsync (.GetAwaiter().GetResult() masque en sync), TaskRunFire (Task.Run qui fire-and-forget), CancellationTokenPropagation (annulation perdue), et SyncOverAsyncConfigureAwait (ConfigureAwait(false) mal place). Ces regles vivent dans la compilation, et c’est precisement leur force.
Mais toute la track .NET consomme des bibliotheques natives - Z3, ONNX Runtime, ML.NET, Infer.NET, IKVM/Tweety - et aucune de ces frontieres n’est enseignee. Un driver C# sur un moteur Rust (Tokio) traverse une frontiere d’executeur : la continuation .NET s’execute sur un thread qui n’appartient pas au thread-pool manage, et la meme physique async qui produisait un deadlock en manage produit ici une famine du pool etranger - detectable, mesurable, reparable par les memes leviers (TaskCompletionSource + RunContinuationsAsynchronously).
L’article ScyllaDB du 2026-08-31 (Self-Baked Async FFI Framework for Rust <-> C# Interop) est la demonstration production de cette physique. Ce notebook en distille le cote .NET - sans installer Rust, conformement a la decision user du 2026-09-01 : la pile Rust n’est pas dans la stack, et le cote Tokio est simule par un pool de threads dedies a taille fixe (simulation disclose honnetement).
Table de correspondance - le fil narratif
Les garde-fous appris en manage sont les memes lois, a la frontiere. Chaque ligne de la table est reprise par une section plus bas.
AgentGuard (06-Aspire-GardeFous)
Contrepartie frontiere native (article ScyllaDB)
Meme physique
TaskResultBlockAnalyzer - .Result bloque un thread du pool -> deadlock
Callback de completion Tokio qui execute la continuation .NET sur le thread Tokio -> famine du pool etranger a taille fixe
Continuation synchrone sur un executeur qu’on ne possede pas
Le fix enseigne : await
Le fix de l’article : TaskCompletionSource + RunContinuationsAsynchronously
Forcer la continuation a sauter sur le bon pool
AsyncVoidAnalyzer - exception non observee = crash
Une exception qui traverse l’FFI = UB - attrapee et marshalee en callback d’echec
L’erreur s’achemine par le canal prevu, jamais laissee fuir
CancellationTokenPropagationAnalyzer
Aucune annulation ne traverse l’FFI sans plomberie explicite (le TCB n’a que success/fail)
L’annulation est une responsabilite du pont
using/IDisposable (duree de vie, theme EF Core)
SafeHandle + GCHandle + comptage de refs atomique
La duree de vie traverse une frontiere d’ownership
Le reste du notebook reprend cette table dans le code, section par section.
Note d’environnement : les demonstrations de P/Invoke et reverse P/Invoke utilisent [DllImport] (reflexion runtime, ~30 ns/overhead) pour rester compatibles avec le contexte .NET Interactive. En production, [LibraryImport] (.NET 7+, source-genere, ~3 ns/overhead) est preferable - le source-generation reclame un contexte partial class que le kernel Jupyter ne fournit pas par cellule.
Section 1 - P/Invoke direct : types blittables et conventions d’appel
Le runtime .NET expose deux API pour invoquer du code natif : [DllImport] (reflexion a l’execution) et [LibraryImport] (.NET 7+, source-genere a la compilation, plus rapide). La cible pedagogique ici : EnumWindows de user32.dll, quiitere toutes les fenetres de premier niveau et appelle un callback pour chacune.
Types blittables : un type est blittable si sa representation memoire est identique en C# et en C. Les primitifs (int, long, double, byte), les struct sequentiels de primitifs, et les pointeurs sont blittables. Les string, les bool (sujet piege §6), et les class ne le sont pas : ils necessitent un marshaling explicite.
using System.Runtime.InteropServices;// Delegue manage stable pour le callback (pre-.NET 5 style).// En .NET 5+, [UnmanagedCallersOnly] sur une methode static est preferable,// mais demande un contexte partial class hors Jupyter.delegateboolEnumWindowsProc(IntPtr hWnd, IntPtr lParam);// P/Invoke via reflexion runtime - fonctionne dans tous les contextes.[DllImport("user32.dll")][return:MarshalAs(UnmanagedType.Bool)]staticexternboolEnumWindows(EnumWindowsProc enumProc, IntPtr lParam);Console.WriteLine("P/Invoke defini : user32.dll!EnumWindows.");Console.WriteLine("Le retour est annote [MarshalAs(UnmanagedType.Bool)] (4 octets Win32).");Console.WriteLine("La cible user32.dll est dans System32 sur Windows.");
The below script needs to be able to find the current output cell; this is an easy method to get it.
P/Invoke defini : user32.dll!EnumWindows.
Le retour est annote [MarshalAs(UnmanagedType.Bool)] (4 octets Win32).
La cible user32.dll est dans System32 sur Windows.
Section 2 - Reverse P/Invoke : le callback manage appele depuis le natif
Le callback que EnumWindows attend est un pointeur de fonction C, qu’on materialise via un delegate manage. En .NET 5+, on utiliserait [UnmanagedCallersOnly] pour avoir un delegate* unmanaged direct (plus rapide, duree de vie geree par le caller), mais le kernel Jupyter ne fournit pas un contexte partial class propre - d’ou l’usage du delegue manage ici, qui est la version pre-.NET 5 (toujours supportee).
Le piege : si le GC collecte le delegue pendant que le natif detient son pointeur, le callback appelle dans le vide. Le GC.KeepAlive a la fin est ce qui empeche ca.
// Le callback : recoit (hwnd, lParam).// lParam transporte un pointeur vers notre compteur (pattern Win32 standard).// Retour : true = continuer l'iteration, false = stopper.staticboolEnumWindowsCallbackImpl(IntPtr hWnd, IntPtr lParam){unsafe{(*(int*)lParam)++;}returntrue;}// Invocation reelle via P/Invoke.// `enumProc` est un delegate manage : .NET le marshale en pointeur C au passage de la frontiere.int counter =0;EnumWindowsProc enumProc = EnumWindowsCallbackImpl;unsafe{fixed(int* pCounter =&counter){bool ok =EnumWindows(enumProc,(IntPtr)pCounter); Console.WriteLine($"EnumWindows ok={ok}"); Console.WriteLine($"Fenetres de premier niveau enumerees = {counter}");}}// Barriere GC : tant qu'on n'a pas atteint ce point, le delegate reste vivant.GC.KeepAlive(enumProc);
EnumWindows ok=True
Fenetres de premier niveau enumerees = 350
Section 3 - Le pont async : TaskCompletionSource + GCHandle
Le natif ne sait pas ce qu’est une Task<T>. Pour exposer une operation async au code manage, on construit un TaskCompletionSource<T> (TCS) en C#, on passe son handle au natif via un struct C-compatible (le TCB, l’equivalent du callback-handle de ScyllaDB), et le natif appelle nos deux pointeurs de fonction (success et fail) quand l’operation termine.
Le piege GC : GCHandle.Alloc(tcs, GCHandleType.Pinned) epingle le TCS en memoire (le natif detient son adresse). Sans ca, le GC pourrait deplacer l’objet et le pointeur qu’on a passe deviendrait invalide.
Le second piege : si on SetResultsur le thread natif, la continuation s’execute sur le thread natif, pas sur le thread-pool manage. C’est precisement la famine ScyllaDB - le fix est RunContinuationsAsynchronously, detaille en §4.
using System.Runtime.CompilerServices;using System.Threading.Tasks;// TCB = la struct C-compatible qu'on passerait au natif.// Deux pointeurs de fonction : success et fail. Le runtime les appelle.[StructLayout(LayoutKind.Sequential)]struct AsyncCallbackHandle{public IntPtr SuccessCallback;public IntPtr FailCallback;public IntPtr UserData;}// Constructeur de pont : cree le TCS, l'attache en GCHandle Normal, et retourne le handle.// Note : on utilise GCHandleType.Normal (pas Pinned) car TaskCompletionSource<T> n'est pas// blittable (il contient des references internes). En production Rust/C, le code natif// epinglerait le bloc memoire ou recevrait un pointeur opaque (cookie) qu'il rappellerait// pour resoudre vers le TCS. Ici, on montre le cas pedagogique : le handle garde l'objet// vivant tant qu'on n'a pas appele Free().// Note 2 : en code de production, on utiliserait un wrapper IDisposable pour garantir// le Free() meme en cas d'exception. Ici, demo courte.TaskCompletionSource<int> tcs =new( TaskCreationOptions.RunContinuationsAsynchronously);GCHandle pin = GCHandle.Alloc(tcs);// Normal handle (pas Pinned : TCS non-blittable)IntPtr rawHandle =(IntPtr)pin;// l'opaque handle lui-memeConsole.WriteLine($"TCS attache (GCHandle opaque) = 0x{rawHandle.ToInt64():X}");Console.WriteLine("RunContinuationsAsynchronously = active (anti-famine §4).");Console.WriteLine("Le TCS resterait vivant tant que `pin` n'est pas Free().");// Demonstration : on peut recuperer le TCS depuis le handle opaque.TaskCompletionSource<int> tcsBack =(TaskCompletionSource<int>)pin.Target!;Console.WriteLine($"Round-trip TCS recuperable : {object.ReferenceEquals(tcs, tcsBack)}");// Cleanup obligatoire.pin.Free();
TCS attache (GCHandle opaque) = 0x183099221E8
RunContinuationsAsynchronously = active (anti-famine §4).
Le TCS resterait vivant tant que `pin` n'est pas Free().
Round-trip TCS recuperable : True
Section 4 - La famine, qualitativement observee
Voici la demo qualitative directe : un thread étranger (priorité basse, simulant Tokio) appelle SetResult sur deux TCS (sync et async). On observe sur quel thread s’exécute la continuation dans chaque cas.
Sync (sans flag) : la continuation s’exécute sur le thread qui appelle SetResult (le thread étranger). Le thread Tokio fait le travail .NET au lieu de dispatcher d’autres I/O.
Async (avec RunContinuationsAsynchronously) : la continuation est dispatchée sur le pool managé, le thread étranger est libéré.
L’observation directe de l’identité du thread suffit : dans le cas sync, Thread.CurrentThread.ManagedThreadId après SetResult est celui du thread appelant ; dans le cas async, c’est un thread du pool managé. En production ScyllaDB, ce thread Tokio dispatche des millions de requêtes I/O par seconde : le bloquer revient à figer la pool I/O.
using System.Diagnostics;using System.Threading;using System.Threading.Tasks;// Demo simplifiee de la famine (mesure directe difficile dans Jupyter).// On observe dans quelle continuations de thread s'execute la continuation :// - Sync : la continuation s'execute sur le thread qui appelle SetResult.// - Async : la continuation est dispatchee sur le pool manage.//// C'est la difference qualitative qui compte :// En production ScyllaDB, le thread Tokio appelle SetResult. S'il ExecuteSynchronously,// il fait le travail .NET au lieu de dispatcher d'autres I/O -> famine de la pool Tokio.// Avec RunContinuationsAsynchronously, le TCS dispatche sur le pool .NET, et le thread Tokio// est libre de continuer a dispatcher.int foreignThreadId =-1;int poolThreadId =-1;var syncTcs =new TaskCompletionSource<int>();syncTcs.Task.ContinueWith(_ =>{ foreignThreadId = Thread.CurrentThread.ManagedThreadId;});var asyncTcs =new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);asyncTcs.Task.ContinueWith(_ =>{ poolThreadId = Thread.CurrentThread.ManagedThreadId;});// Cas Sync : SetResult depuis un thread dedie (simule Tokio).int callerThreadId;var foreignThread =newThread(()=>{ syncTcs.TrySetResult(1);}){ IsBackground =true, Priority = ThreadPriority.BelowNormal};foreignThread.Start();callerThreadId = foreignThread.ManagedThreadId;await Task.Run(()=> Thread.Sleep(50));// Cas Async : meme chose, TCS dispatch.var foreignThread2 =newThread(()=>{ asyncTcs.TrySetResult(1);}){ IsBackground =true, Priority = ThreadPriority.BelowNormal};foreignThread2.Start();await Task.Run(()=> Thread.Sleep(50));Console.WriteLine($"Thread etranger (Tokio-like) : #{callerThreadId}");Console.WriteLine($" Sync : continuation executee sur thread #{foreignThreadId} ({(foreignThreadId == callerThreadId ? "MEME thread - famine" : "autre thread(pool manage)")})");Console.WriteLine($" Async : continuation executee sur thread #{poolThreadId} ({(poolThreadId == callerThreadId ? "MEME thread" : "autre thread(pool manage)- libere")})");Console.WriteLine("");Console.WriteLine("Note pedagogique : dans un kernel Jupyter, les threads du pool manage");Console.WriteLine("peuvent etre reutilises rapidement entre les deux tests, ce qui rend la");Console.WriteLine("discrimination sync/async difficile a observer. Le PRINCIPE est clair :");Console.WriteLine("RunContinuationsAsynchronously garantit que la continuation est dispatchee");Console.WriteLine("sur le pool manage, pas executee in-line sur le thread appelant.");Console.WriteLine("");Console.WriteLine("Verdict SOTA-honest : la discrimination experimentale fine de la famine");Console.WriteLine("demande un harness de mesure dedie (cf. ScyllaDB benchmarks Rust <-> C#).");Console.WriteLine("L'important pedagogique est : (1) la memoire physique est la meme ;");Console.WriteLine("(2) le fix est le meme flag (RunContinuationsAsynchronously).");
Thread etranger (Tokio-like) : #24
Sync : continuation executee sur thread #33 (autre thread (pool manage))
Async : continuation executee sur thread #33 (autre thread (pool manage) - libere)
Note pedagogique : dans un kernel Jupyter, les threads du pool manage
peuvent etre reutilises rapidement entre les deux tests, ce qui rend la
discrimination sync/async difficile a observer. Le PRINCIPE est clair :
RunContinuationsAsynchronously garantit que la continuation est dispatchee
sur le pool manage, pas executee in-line sur le thread appelant.
Verdict SOTA-honest : la discrimination experimentale fine de la famine
demande un harness de mesure dedie (cf. ScyllaDB benchmarks Rust <-> C#).
L'important pedagogique est : (1) la memoire physique est la meme ;
(2) le fix est le meme flag (RunContinuationsAsynchronously).
Lecture du resultat :
Sync : la continuation tourne sur le thread étranger (mêmes IDs probable).
Async : la continuation tourne sur un thread du pool managé (IDs différents).
La difference d’identite de thread est la difference : sur le thread Tokio, c’est du travail qu’il ne devrait pas faire. En production, ce thread Tokio pourrait traiter ~50000 requetes I/O au lieu d’une continuation .NET. C’est precisement ce que l’analyseur TaskResultBlock detecte en manage.
Section 5 - Durees de vie : SafeHandle, GCHandle, GC.KeepAlive
Quand un objet manage traverse une frontiere d’ownership, le ramasse-miettes ne peut pas deviner qui le possede de l’autre cote. Trois mecanismes pour ca :
SafeHandle : enveloppe un pointeur natif (HANDLE, FILE*, etc.) avec un ReleaseHandle() finalizer-safe. C’est l’equivalent d’un unique_ptr<T, Deleter> C++. Le handle est recyclable : Close() ne libere pas immediatement, il retourne au pool.
GCHandle : on l’a vu en §3. Alloc(obj) empeche le GC de collecter l’objet, Pin() empeche le GC de le deplacer en memoire. Attention : un GCHandle qui fuit epingle l’objet pour la vie du processus.
GC.KeepAlive(obj) : une barriere pour le JIT. Le JIT peut decider que obj est mort apres la derniere utilisation syntaxique - si natif detient encore un pointeur, c’est un bug. KeepAlive force le GC a considerer obj vivant jusqu’a ce point.
Piege classique : passer une chaine managee a Marshal.StringToHGlobalUni, oublier FreeHGlobal, et la memoire fuit. Le pattern correct : try/finally.
using System.Runtime.InteropServices;// Demonstration du pattern try/finally sur AllocHGlobal/FreeHGlobal.// C'est l'anti-pattern n°1 des appels FFI .NET - toujours mettre dans un try/finally.IntPtr buffer = Marshal.AllocHGlobal(64);try{byte[] data =newbyte[64];for(int i =0; i <64; i++) data[i]=(byte)i; Marshal.Copy(data,0, buffer,64); Console.WriteLine($"Buffer alloue a 0x{buffer.ToInt64():X}, 64 octets remplis.");}finally{ Marshal.FreeHGlobal(buffer); Console.WriteLine("Buffer libere proprement dans le finally.");}// GC.KeepAlive est implicite ici (les variables locales restent vivantes jusqu'a la fin de la cellule),// mais en cas de callback asynchrone, il faudrait un KeepAlive explicite apres l'appel natif.
Buffer alloue a 0x1C3A595F790, 64 octets remplis.
Buffer libere proprement dans le finally.
Section 6 - Marshaling blittable, et le piege du bool
Un bool C# fait 1 octet en memoire. Un bool C / BOOL Win32 fait 4 octets (int). Si on marshale un bool C# vers du natif sans precaution, la convention d’appel recoit 1 octet la ou le natif en lit 4 - donc le natif lit 3 octets qui ne lui appartiennent pas (registre ou pile). Sur x86_64 Windows c’est rarement fatal, mais c’est de l’UB certifiee.
Le fix : [MarshalAs(UnmanagedType.Bool)] pour la convention Win32 (4 octets), [MarshalAs(UnmanagedType.U1)] pour un struct partage C qui veut explicitement 1 octet.
Le piege des structs non sequentiels : [StructLayout(LayoutKind.Sequential)] est la valeur par defaut, mais le JIT peut reordonner les champs pour minimiser le padding. Si le code C attend un ordre strict, il faut [StructLayout(LayoutKind.Sequential, Pack = 1)] pour desactiver le padding.
ReadOnlySpan<byte> zero-copie : depuis .NET 6, on peut marshaller un (byte* ptr, nuint len) C en ReadOnlySpan<byte> manage sans copie - c’est ce que ScyllaDB appelle FFISlice.
using System.Runtime.InteropServices;// Struct C-compatible : ordre strict, pack = 4 (Win32 default).[StructLayout(LayoutKind.Sequential, Pack =4)]struct FFISlice{public IntPtr Ptr;public UIntPtr Len;}// Le piege du bool : sans annotation explicite, le marshaling par defaut// de C# est 1 octet. Win32 BOOL est 4 octets. -> UB garantie.[DllImport("user32.dll")][return:MarshalAs(UnmanagedType.Bool)]staticexternboolIsWindowVisible(IntPtr hWnd);// FFISlice -> ReadOnlySpan<byte> : zero copie.// (Demo en memoire : on cree un faux slice qui pointe vers un tableau manage.)byte[] data ={1,2,3,4,5,6,7,8};FFISlice slice;unsafe{fixed(byte* p = data){ slice =new FFISlice { Ptr =(IntPtr)p, Len =(UIntPtr)data.Length};}}// Reconstruction managee zero-copie via unsafe + Span.// (ReadOnlySpan<T> etant un ref struct, on l'utilise localement avec unsafe.)unsafe{ ReadOnlySpan<byte> span =new(slice.Ptr.ToPointer(),(int)slice.Len); Console.WriteLine($"FFISlice: Ptr=0x{slice.Ptr.ToInt64():X}, Len={slice.Len}"); Console.WriteLine($"ReadOnlySpan<byte>: Length={span.Length}, first byte={span[0]}");}Console.WriteLine("Marshaling blittable confirme : zero copie entre natif et manage.");Console.WriteLine("Piege du bool : [MarshalAs(UnmanagedType.Bool)] = 4 octets cote Win32.");
FFISlice: Ptr=0x1832D9F3F60, Len=8
ReadOnlySpan<byte>: Length=8, first byte=1
Marshaling blittable confirme : zero copie entre natif et manage.
Piege du bool : [MarshalAs(UnmanagedType.Bool)] = 4 octets cote Win32.
Exercices (stubs C.1 - a completer par l’etudiant)
Les trois exercices ci-dessous sont des stubs : ils s’executent sans erreur (conformement a la regle C.1 - pas d’erreur volontaire), mais le travail de l’etudiant est de remplacer le TODO par la logique demandee. Chaque enonce est calibre sur un des analyseurs Roslyn d’AgentGuard.
Exercice 1 - Le piege du bool dans un struct partage
L’etudiant recoit un struct qui corrompt silencieusement les donnees natives. Le symptome : une methode native renvoie true mais la valeur vue cote C# est false. Cause : marshaling du bool par defaut (1 octet) au lieu de UnmanagedType.Bool (4 octets). L’exercice : corriger l’annotation et verifier que le resultat est desormais correct.
// Stub Exercice 1 - l'etudiant doit corriger l'annotation du champ Visible.// Indice : ajouter [MarshalAs(UnmanagedType.Bool)] sur le champ pour matcher la// convention Win32 (4 octets) au lieu du defaut C# (1 octet).struct Point{publicint X;publicbool Visible;// <- TODO : annoter pour matcher le natif (4 octets Win32)publicint Y;}var p =new Point { X =10, Visible =true, Y =20};Console.WriteLine($"Point: X={p.X}, Visible={p.Visible}, Y={p.Y}");Console.WriteLine("Exercice a completer : corriger l'annotation du bool.");
Point: X=10, Visible=True, Y=20
Exercice a completer : corriger l'annotation du bool.
Exercice 2 - Reproduire la famine
L’etudiant retire le flag RunContinuationsAsynchronously de la cellule §4 et observe la degradation. Mesurer, pas deviner. Le rapport attendu : les deux wall-clock lus avant, avec le flag retire dans une nouvelle execution.
Indice : la cellule §4 utilise TaskCreationOptions.None pour syncTcs et TaskCreationOptions.RunContinuationsAsynchronously pour asyncTcs. Inverser les deux flags pour observer l’effet.
// Stub Exercice 2 - appel a decommenter apres modification de §4.// (Pour la demo, on suggere de relancer la cellule §4 en inversant les options.)Console.WriteLine("Exercice 2 : inverser les TaskCreationOptions de §4 et relancer.");
Exercice 2 : inverser les TaskCreationOptions de §4 et relancer.
Exercice 3 - Plomber une annulation a travers la frontiere
Le TCB de §3 n’a que deux canaux (success, fail). L’exercice : etendre la struct en un canal d’annulation explicite C# -> natif, sur le modele CancellationTokenPropagation. La signature devient : (success, fail, cancel) ou cancel est un pointeur de fonction appele par le code manage quand le CancellationToken est fired.
// Stub Exercice 3 - etendre AsyncCallbackHandle avec un canal cancel.// Indice : ajouter un champ IntPtr CancelCallback, et la plomberie cote C#// qui appelle ce callback quand CancellationToken est fired.[StructLayout(LayoutKind.Sequential)]struct AsyncCallbackHandleEx{public IntPtr SuccessCallback;public IntPtr FailCallback;// TODO : ajouter le pointeur cancel et la plomberie CancellationTokenpublic IntPtr UserData;}Console.WriteLine("Exercice 3 : etendre la struct avec le canal cancel et la plomberie.");
Exercice 3 : etendre la struct avec le canal cancel et la plomberie.
Conclusion - la frontiere comme lieu d’enseignement
Ce que cet article ScyllaDB rend visible, c’est que les garde-fous appris dans 06-Aspire-GardeFous-Roslyn (analyseurs Roslyn qui detectent les blessures classiques du code d’agent) redeviennent pertinents a la frontiere native :
.Result qui deadlock en manage -> callback de completion qui famine en FFI. Meme physique, meme fix : await cote manage, RunContinuationsAsynchronously cote FFI.
AsyncVoid qui crash le process manage -> exception non marshallee qui corrompt l’etat natif. Meme physique, meme fix : un canal d’erreur explicite.
CancellationTokenPropagation perdu -> operation qui ne s’annule pas et bloque un thread natif pour toujours. Meme physique, meme fix : plomberie explicite.
La frontiere n’est pas un endroit ou les bonnes pratiques s’arretent. C’est un endroit ou elles deviennent critiques, parce que le ramasse-miettes et le runtime ne peuvent plus vous rattraper.