-----------------------------------

Acquista i software ArcGIS tramite Studio A&T srl, rivenditore autorizzato dei prodotti Esri.

I migliori software GIS, il miglior supporto tecnico!

I migliori software GIS, il miglior supporto tecnico!
Azienda operante nel settore GIS dal 2001, specializzata nell’utilizzo della tecnologia ArcGIS e aderente ai programmi Esri Italia Business Network ed Esri Partner Network

-----------------------------------



giovedì 8 ottobre 2026

SqlClient Pool V2: internals, benchmark e ThreadPool starvation con .NET 10

Deep dive • C# / .NET 10 • SQL Server / Azure SQL
Verifica delle fonti: 8 ottobre 2026. Implementazione analizzata: tag v7.1.0 di dotnet/SqlClient.

Il connection pool è uno dei componenti più sottovalutati di un'applicazione .NET. Finché le connessioni sono disponibili, sembra una cache quasi invisibile. Quando un processo parte, una nuova replica riceve traffico o molte richieste arrivano insieme, diventa invece un sistema di coordinamento: decide chi può riutilizzare una sessione, chi può crearne una e chi deve aspettare.

SqlClient Pool V2 cambia soprattutto il modo in cui il pool cresce e gestisce l'attesa. Per valutarlo seriamente dobbiamo separare quattro fenomeni: apertura fisica, checkout di una connessione già esistente, saturazione delle sessioni disponibili e disponibilità dei worker del runtime. Un unico numero di throughput non descrive questi quattro comportamenti.

Dalla cache di connessioni al sistema di ammissione

Una SqlConnection è l'oggetto logico usato dall'applicazione; la sessione fisica sottostante può sopravvivere a quel singolo oggetto e servire molte operazioni successive. L'apertura fisica comporta rete, negoziazione e autenticazione. Il checkout tenta invece di assegnare una risorsa già disponibile. Chiamare OpenAsync() non significa necessariamente aprire una nuova sessione sul server.

Il pool appartiene al processo. La chiave dipende dalla stringa di connessione e può includere identità, credenziali e configurazione dei token. Stringhe con ordine diverso delle keyword possono produrre pool distinti. Perciò Max Pool Size=100 non è un limite complessivo dell'applicazione distribuita. Dieci processi con tre pool ciascuno hanno un budget teorico di 3.000 sessioni. [3]

Questo suggerisce una prima scelta architetturale: centralizzare la costruzione delle stringhe e mantenere stabile la configurazione di autenticazione. Inserire un identificatore di richiesta in Application Name per migliorare il tracing può distruggere il riuso. L'identificatore della singola richiesta appartiene alla telemetria, non alla configurazione del pool.

Che cosa introduce davvero Pool V2

Microsoft ha presentato Pool V2 il 6 ottobre 2026. È disponibile da Microsoft.Data.SqlClient 7.1.0 e va attivato esplicitamente. La creazione di nuove connessioni può procedere in parallelo; il coordinamento usa Channels. Resta però una limitazione decisiva: alcuni passaggi di rete dell'apertura fisica sono sincroni e, nel percorso asincrono, vengono eseguiti da worker del ThreadPool. [1]

La firma asincrona dell'API e l'asincronia completa della sua implementazione sono proprietà diverse. La prima permette al chiamante di attendere senza bloccare il proprio flusso; la seconda determina quante risorse occupa il driver mentre lavora.

Attivazione e rollback

Impostare lo switch prima di utilizzare SqlClient: i valori vengono memorizzati internamente. In ASP.NET Core posizionarlo prima della creazione del builder. Per un confronto A/B usare processi separati; per il rollback modificare il valore e riavviare. Lo switch non è una keyword della connection string. [2]

AppContext.SetSwitch(
    "Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", true);

var builder = WebApplication.CreateBuilder(args);
// Registrazione DbContext, servizi e applicazione...

Non serve riscrivere le query. Con EF Core o Dapper, verificare però il pacchetto SqlClient effettivamente risolto: una dipendenza transitiva precedente alla 7.1 non acquisisce questa funzionalità perché il progetto compila per .NET 10. DbContext pooling e connection pooling sono inoltre meccanismi diversi: riutilizzare un contesto non equivale a conservare permanentemente la sua connessione SQL.

Dentro WaitHandleDbConnectionPool

Nel tag analizzato, WaitHandleDbConnectionPool coordina disponibilità, errore e creazione con handle distinti. PoolWaitHandles costruisce il semaforo delle risorse e un CreationSemaphore con capacità uno. TryGetConnection usa WaitHandle.WaitAny: una connessione restituita può sbloccare il chiamante; in alternativa il permesso di creazione consente un nuovo login.

La capacità uno serializza la creazione all'interno del pool. Non serializza tutte le query dell'applicazione e non impedisce di usare contemporaneamente sessioni già assegnate.

Gli open asincroni non risolti subito vengono rappresentati da PendingGetConnection, con owner, scadenza e completion. WaitForPendingOpen drena la coda; un guard con Interlocked evita più consumatori attivi. Il driver avvia un Thread di background per questo lavoro: il commento del metodo specifica che può bloccare a lungo e non deve essere un thread del pool gestito. Questa soluzione separa il chiamante dall'attesa, ma conserva un coordinamento seriale. [5]

Dentro ChannelDbConnectionPool

ChannelDbConnectionPool ha un fast path, TryGetPooledConnectionInline, che può restituire una connessione senza dispatch sul ThreadPool. Nel percorso lento, GetInternalConnection cerca prima una risorsa transazionale compatibile, poi una idle; se necessario tenta OpenNewInternalConnection. Se non ottiene una risorsa, attende sul channel.

Il percorso async usa ReadAsync; quello sync usa ReadChannelSyncOverAsync e blocca il chiamante. La fairness descritta dal codice riguarda i lettori in attesa, non una garanzia che tutte le chiamate applicative terminino nell'ordine di arrivo: fast path, nuove aperture, cancellazioni e scheduling interferiscono con quell'ordine.

Per gli open async non risolti inline, TryGetConnection avvia un Task.Run. Nel percorso di creazione, ConnectionFactory.CreatePooledConnection resta sincrono. Il codice controlla il timeout prima della chiamata, ma la factory non riceve quel cancellation token. Una cancellazione del chiamante non dimostra quindi che ogni lavoro fisico sia già terminato. [4]

Le invarianti che rendono possibile la concorrenza

La lettura ingegneristica del percorso è questa: prima di avviare un login bisogna riservare capacità; in caso di fallimento bisogna liberarla; dopo un clear bisogna evitare che risorse obsolete tornino disponibili; una risorsa restituita deve svegliare chi può usarla. Queste condizioni contano più del tipo di coda scelto.

Nel codice osservato il tracking passa da ConnectionPoolSlots, mentre IdleConnectionChannel incapsula il flusso delle risorse disponibili. La generazione del clear permette di riconoscere connessioni precedenti al cambio di generazione. Il channel può ricevere anche segnali null per far ritentare un'acquisizione: un risveglio non implica necessariamente una connessione pronta. L'eventuale ConcurrencyLimiter interno viene iniettato nel costruttore; il suo valore predefinito è assente. Non è un'impostazione pubblica da aggiungere arbitrariamente alla connection string. Queste sono osservazioni sul tag analizzato, non un contratto delle API pubbliche. [4]

Conseguenza pratica. Un'applicazione può usare un proprio limite di concorrenza anche quando il pool ha capacità residua. Il numero di sessioni consentite, il numero di login contemporanei e il numero di operazioni SQL ammesse sono tre budget diversi.

Un modello quantitativo: crescita, occupazione e code

I modelli seguenti sono strumenti di ragionamento, non misure del driver. Se servono N sessioni nuove e ogni apertura fisica dura mediamente L, una crescita completamente seriale ha un costo indicativo N × L. Con C aperture concorrenti, il termine ideale diventa circa ceil(N/C) × L. Questo limite ideale trascura autenticazione, scheduling, contesa sul server e distribuzione delle latenze.

Parallelizzare sposta il collo di bottiglia: il sistema può passare dall'attesa del permesso di creazione all'attesa di worker, autenticazione o risorse SQL. La presenza di 100 slot liberi nel pool non garantisce che 100 login simultanei siano la scelta migliore.

Per il regime stabile, la legge di Little suggerisce un'altra relazione: sessioni occupate medie ≈ operazioni al secondo × durata media del possesso. A 800 operazioni/s e 40 ms di possesso servono in media circa 32 sessioni; a 250 ms diventano 200. Sono esempi matematici, non risultati di benchmark. La coda richiede ulteriore margine e dipende da burst, variabilità e distribuzione delle durate.

Il possesso include tutto ciò che accade tra checkout e restituzione: lettura, mapping, transazione, eventuali chiamate HTTP e attese applicative. Ridurre una chiamata esterna fatta mentre si tiene aperta una connessione può migliorare la capacità più di un aumento del pool.

I benchmark Microsoft: che cosa possiamo concludere

Cold start Linux, 100 chiamantiPool legacyPool V2Rapporto
Open sincrono630,1 ms117,5 ms5,4×
Open asincrono604,1 ms92,0 ms6,6×

Sono risultati pubblicati da Microsoft, non misure eseguite per questo articolo. Misurano il completamento dell'intera rampa, con connessioni trattenute fino alla conclusione di tutte le aperture. Il confronto usa runtime .NET 9.0.19, SQL Server locale alla VM e Max Pool Size=200. Il laboratorio seguente usa .NET 10 e un harness diverso: non è una replica numerica di quei risultati. [1] [6]

La suite ufficiale include anche riuso immediato, query in regime stabile, possesso variabile e open sincroni sul ThreadPool con pool saturo. Per confrontare le implementazioni usa processi distinti. Questi scenari aiutano a verificare regressioni oltre il cold start. [6]

La domanda corretta per la propria applicazione è: quale componente della latenza rappresenta l'apertura fisica? Se il 95% del tempo è query e trasferimento dati, accelerare il restante 5% di 6,6 volte limita il miglioramento ideale complessivo a circa 1,04 volte. Questa applicazione della legge di Amdahl impedisce di trasformare un benchmark di startup in una promessa sul throughput di tutte le query.

Laboratorio riproducibile con C# e .NET 10

Il laboratorio usa un progetto console, SqlClient 7.1.0 fissato e SQL Server raggiungibile. Non richiede uno schema: esegue SELECT 1. Si può usare un'istanza Developer locale o un database di test in Azure SQL. Usare lo stesso endpoint e la stessa autenticazione nelle due varianti.

Stato della verifica. Il codice è fornito integralmente per riprodurre l'esperimento. È stato controllato staticamente, ma non compilato né eseguito nell'ambiente di preparazione dell'articolo, che non dispone di SDK .NET né di un endpoint SQL configurato. I risultati del laboratorio devono essere prodotti sul proprio ambiente; nessuna tabella seguente contiene misure locali inventate.

1. Progetto

dotnet new console -n PoolV2Lab -f net10.0
cd PoolV2Lab

Sostituire PoolV2Lab.csproj con:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.0" />
  </ItemGroup>
</Project>

Eseguire dotnet restore, conservare packages.lock.json e usare dotnet restore --locked-mode nei successivi ambienti. Registrare dotnet --info e dotnet list package --include-transitive. Per fissare anche l'SDK, creare un global.json con la versione realmente installata tramite dotnet new globaljson --sdk-version VERSIONE_INSTALLATA --roll-forward disable.

2. Program.cs completo

Parametri: implementazione v1|v2, scenario cold|hot|steady, chiamanti, dimensione massima pool, iterazioni, possesso simulato in millisecondi, API async|sync, minimo worker opzionale, attesa iniziale opzionale per collegare gli strumenti.

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using Microsoft.Data.SqlClient;

if (args.Length < 7)
    throw new ArgumentException(
        "v1|v2 cold|hot|steady callers pool iterations holdMs async|sync [minWorkers] [attachSeconds]");
bool v2 = args[0] switch {
    "v1" => false, "v2" => true, _ => throw new ArgumentException("pool version")
};
AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", v2);
string scenario = args[1];
int n = int.Parse(args[2]), max = int.Parse(args[3]);
int iterations = int.Parse(args[4]), holdMs = int.Parse(args[5]);
bool sync = args[6] switch {
    "sync" => true, "async" => false, _ => throw new ArgumentException("API mode")
};
if (scenario is not ("cold" or "hot" or "steady") ||
    n < 1 || max < 1 || iterations < 1 || holdMs < 0)
    throw new ArgumentException("Invalid scenario or numeric parameters");
if (scenario == "cold" && n > max)
    throw new ArgumentException("Cold ramp holds every connection: callers must be <= pool");
if (args.Length > 7) {
    ThreadPool.GetMinThreads(out _, out int ioMin);
    if (!ThreadPool.SetMinThreads(int.Parse(args[7]), ioMin))
        throw new ArgumentException("Invalid minWorkers");
}
string input = Environment.GetEnvironmentVariable("POOLLAB_CS")
    ?? throw new InvalidOperationException("Set POOLLAB_CS");
string cs = new SqlConnectionStringBuilder(input) {
    Pooling = true, MinPoolSize = 0, MaxPoolSize = max,
    ConnectTimeout = 30, ApplicationName = "NicoGIS.PoolV2Lab"
}.ConnectionString;
var rows = new ConcurrentQueue<Sample>();
string runId = Guid.NewGuid().ToString("N");
ThreadPool.GetMinThreads(out int workers, out _);
Console.WriteLine($"PID={Environment.ProcessId} run={runId} poolV2={v2} " +
    $"scenario={scenario} api={(sync ? "sync" : "async")} callers={n} pool={max} " +
    $"iterations={iterations} holdMs={holdMs} minWorkers={workers}");
Console.WriteLine($"runtime={RuntimeInformation.FrameworkDescription} " +
    $"os={RuntimeInformation.OSDescription} cpu={Environment.ProcessorCount} " +
    $"driver={typeof(SqlConnection).Assembly.GetName().Version}");
if (args.Length > 8) await Task.Delay(TimeSpan.FromSeconds(int.Parse(args[8])));

// Preflight includes JIT/authentication setup; then cold tests clear the pool.
await using (var probe = new SqlConnection(cs)) {
    await probe.OpenAsync();
    await using var cmd = new SqlCommand("SELECT 1", probe);
    await cmd.ExecuteScalarAsync();
}
SqlConnection.ClearAllPools();

// Open and hold all requested connections. Dispose only after all attempts settle.
async Task Ramp(int count, int iteration, bool record) {
    var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
    long releaseTicks = 0;
    async Task<SqlConnection> OpenOne() {
        await gate.Task;
        return await OpenAfterGate();
    }
    async Task<SqlConnection> OpenAfterGate() {
        var c = new SqlConnection(cs);
        long begin = Stopwatch.GetTimestamp();
        try {
            if (sync) c.Open(); else await c.OpenAsync();
            if (record) rows.Enqueue(new(iteration, true,
                Ms(begin), Ms(releaseTicks), ""));
            return c;
        } catch (Exception ex) {
            c.Dispose();
            if (record) rows.Enqueue(new(iteration, false,
                Ms(begin), Ms(releaseTicks), ex.GetType().Name));
            throw;
        }
    }
    Task<SqlConnection>[] tasks = Enumerable.Range(0, count).Select(_ => sync
        ? Task.Factory.StartNew(() => {
            gate.Task.GetAwaiter().GetResult();
            return OpenAfterGate().GetAwaiter().GetResult();
        }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)
        : OpenOne()).ToArray();
    releaseTicks = Stopwatch.GetTimestamp();
    gate.SetResult(true);
    try { await Task.WhenAll(tasks); }
    finally {
        foreach (var t in tasks)
            if (t.IsCompletedSuccessfully) t.Result.Dispose();
    }
}

bool rampFailed = false;
if (scenario != "cold") await Ramp(max, -1, false);
long start = Stopwatch.GetTimestamp();
if (scenario == "cold") {
    for (int i = 0; i < iterations; i++) {
        // Exclude clear from the per-ramp clock; total wall time still includes it.
        SqlConnection.ClearAllPools();
        long t = Stopwatch.GetTimestamp();
        try { await Ramp(n, i, true); }
        catch (Exception ex) { rampFailed = true; Console.WriteLine($"rampError={ex.GetType().Name}"); }
        Console.WriteLine(FormattableString.Invariant($"ramp={i} batchMs={Ms(t):F3}"));
        if (rampFailed) break; // Avoid benchmarking cached authentication failures.
    }
} else {
    var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
    Task[] tasks = Enumerable.Range(0, n).Select(_ => Task.Run(async () => {
        await gate.Task;
        for (int i = 0; i < iterations; i++) {
            long t = Stopwatch.GetTimestamp();
            double openMs = double.NaN;
            bool ok = false;
            string error = "";
            try {
                // Includes disposal in totalMs because scope ends before finally.
                using var c = new SqlConnection(cs);
                if (sync) c.Open(); else await c.OpenAsync();
                openMs = Ms(t);
                if (scenario == "steady") {
                    using var cmd = new SqlCommand("SELECT 1", c) { CommandTimeout = 30 };
                    if (sync) cmd.ExecuteScalar(); else await cmd.ExecuteScalarAsync();
                }
                if (holdMs > 0) {
                    if (sync) Thread.Sleep(holdMs); else await Task.Delay(holdMs);
                }
                ok = true;
            } catch (Exception ex) { error = ex.GetType().Name; }
            finally { rows.Enqueue(new(i, ok, openMs, Ms(t), error)); }
        }
    })).ToArray();
    gate.SetResult(true);
    await Task.WhenAll(tasks);
}
double wallMs = Ms(start);
Sample[] samples = rows.ToArray();
double[] opens = samples.Where(x => x.Ok && double.IsFinite(x.OpenMs))
    .Select(x => x.OpenMs).Order().ToArray();
double[] totals = samples.Where(x => x.Ok).Select(x => x.TotalMs).Order().ToArray();
double P(double[] values, double p) => values.Length == 0 ? double.NaN :
    values[Math.Clamp((int)Math.Ceiling(p * values.Length) - 1, 0, values.Length - 1)];
Console.WriteLine(FormattableString.Invariant(
    $"ok={samples.Count(x => x.Ok)} errors={samples.Count(x => !x.Ok)} wallMs={wallMs:F3} successPerSecond={samples.Count(x => x.Ok) * 1000 / wallMs:F2} openP50={P(opens,.50):F3} openP95={P(opens,.95):F3} openP99={P(opens,.99):F3} totalP95={P(totals,.95):F3}"));
string file = $"samples-{args[0]}-{scenario}-{args[6]}-{runId}.csv";
using (var writer = new StreamWriter(file)) {
    writer.WriteLine("iteration,ok,open_ms,total_ms,error");
    foreach (var s in samples)
        writer.WriteLine(string.Join(",", s.Iteration, s.Ok ? 1 : 0,
            s.OpenMs.ToString("F6", CultureInfo.InvariantCulture),
            s.TotalMs.ToString("F6", CultureInfo.InvariantCulture), s.Error));
}
Console.WriteLine($"csv={file}");
if (samples.Any(x => !x.Ok) || rampFailed) Environment.ExitCode = 2;
double Ms(long ticks) => Stopwatch.GetElapsedTime(ticks).TotalMilliseconds;
record Sample(int Iteration, bool Ok, double OpenMs, double TotalMs, string Error);

3. Configurazione ed esecuzione

PowerShell, con credenziali di un database di laboratorio:

$env:POOLLAB_CS = 'Server=localhost;Database=master;Integrated Security=true;Encrypt=true;TrustServerCertificate=true'
dotnet restore
dotnet build -c Release

# Rampa fredda async: 100 sessioni trattenute, limite pool 200.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 cold 100 200 10 0 async
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 cold 100 200 10 0 async

# Rampa fredda sync: thread dedicati, separata dall'esperimento starvation.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 cold 100 200 10 0 sync
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 cold 100 200 10 0 sync

# Riuso senza query e senza possesso artificiale.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 hot 50 100 1000 0 async
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 hot 50 100 1000 0 async

# Regime stabile: più chiamanti che sessioni, SELECT 1 e possesso di 20 ms.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 steady 100 20 100 20 async
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 20 100 20 async

TrustServerCertificate=true è qui una concessione al certificato del laboratorio locale. Con Azure SQL usare il nome completo dell'endpoint e la verifica del certificato, quindi TrustServerCertificate=false. Con SQL authentication sostituire l'identità integrata con le credenziali di test. Non stampare la stringa nei risultati.

4. Che cosa misuriamo, esattamente

  • Cold: dopo il preflight si svuota il pool prima di ogni rampa. Ogni successo trattiene la connessione fino a quando tutti i tentativi sono conclusi. Ciò forza N aperture fisiche invece del riuso accidentale di poche sessioni. callers deve essere minore o uguale a pool.
  • Hot: il warmup apre e trattiene contemporaneamente Max Pool Size sessioni, poi le restituisce. Aprire e chiudere serialmente cento volte non garantirebbe cento sessioni fisiche. La fase misurata non esegue query.
  • Steady: il pool è caldo; ogni worker ripete checkout, SELECT 1, possesso simulato e dispose. Il numero dei chiamanti può superare gli slot.

open_ms è il tempo osservato attorno all'apertura logica; può includere attesa del pool, lavoro del driver e scheduling. Non isola il costo puro del channel. Nel cold, total_ms arriva dal rilascio del gate al completamento dell'open del singolo chiamante; non include il dispose collettivo. batchMs misura invece la rampa fino alla restituzione di tutte le sessioni. Negli scenari hot e steady, total_ms va dall'inizio dell'operazione al termine del dispose.

successPerSecond è utile in hot e steady. Nel cold il wall time globale include i clear tra rampe: confrontare soprattutto la distribuzione dei batchMs, che li esclude. Il preflight rende freddo il pool, non DNS, JIT, provider di autenticazione o processo. Per misurare la readiness reale dell'applicazione occorre anche un esperimento senza preflight, da processo nuovo.

I percentili mostrati sono nearest-rank sui soli successi. Gli errori restano nel CSV e nel conteggio: un run con errori non va dichiarato più veloce perché ha meno campioni lenti. Con 100 osservazioni p99 è una statistica molto fragile. Per le rampe raccogliere più processi e riportare mediana e variabilità tra processi.

Un protocollo di benchmark che resiste alle conclusioni facili

Questo harness è un esperimento di carico controllato, non un microbenchmark isolato con BenchmarkDotNet. Usa allocazioni, timestamp e raccolta di campioni; tali costi fanno parte della misura. Sono presenti in entrambe le varianti, ma possono pesare molto nel test hot. Per differenze di pochi microsecondi affiancare la suite ufficiale e strumenti di profiling.

  1. Fissare driver, SDK, runtime, architettura, GC, endpoint, autenticazione e SNI. Non modificare altri switch mentre si confronta V1 con V2.
  2. Eseguire almeno cinque processi per variante, alternando l'ordine V1/V2 e poi V2/V1. Evitare che la seconda variante benefici sempre dello stato del server lasciato dalla prima.
  3. Separare chiamanti 10, 25, 50 e 100; poi pool inferiori, uguali e superiori alla domanda. Un run diverso per ciascuna combinazione.
  4. Registrare CPU e memoria di client e server, runtime counters, errori e latenza. Conservare i CSV e l'output con tutti i parametri.
  5. Ripetere con rete locale e con l'endpoint reale. Se si introduce ritardo con un proxy o traffic shaping, documentare direzione, jitter e perdita: un RTT aggiunto non è un ritardo uniforme di autenticazione.
  6. Separare sessioni appena create da riuso caldo. Non usare ClearAllPools a intervalli nel test steady: si trasformerebbe il workload in churn.
ScenarioIndicatore principaleDomanda
Cold rampMediana e dispersione batchMsQuanto rapidamente otteniamo N sessioni?
HotOperazioni/s e open p95Il nuovo coordinamento introduce overhead nel riuso?
Steady saturoTotal p95/p99, errori, throughputCome cresce la coda quando la capacità non basta?
Rete lenta e pool freddoBatchMs, worker e queue lengthLa crescita parallela aumenta pressione sui thread?
Sync sul ThreadPoolStack, worker, latenza del probeI chiamanti bloccanti ritardano lavoro non SQL?

Il limite dei worker a ciclo chiuso

In steady ogni worker avvia l'operazione successiva dopo la precedente. Il modello è a ciclo chiuso: quando il sistema rallenta, rallenta anche l'offerta di nuove operazioni. Non misura quindi la latenza di una coda alimentata da un tasso esterno costante e non elimina il problema della coordinated omission.

Per la verifica di uno SLO HTTP aggiungere un generatore a tasso di arrivo controllato su un endpoint reale, in un processo separato. Registrare il momento di arrivo previsto, l'arrivo effettivo e la risposta. Sotto overload, stabilire prima una policy di rifiuto o scadenza: nessun pool può rendere stabile una coda con domanda permanentemente superiore alla capacità di servizio.

ThreadPool starvation: il rischio da misurare

La starvation si verifica quando il runtime non ha worker disponibili per eseguire lavoro pronto. La CPU può restare bassa perché molti thread aspettano. La crescita del numero di thread, un backlog persistente e stack bloccati sono indizi da leggere insieme; un singolo contatore elevato non è una diagnosi. Le euristiche moderne riducono alcuni effetti del blocking, ma non rendono gratuito quel blocking. [7]

Per Pool V2 formuliamo un'ipotesi sperimentale precisa: durante la crescita del pool con rete lenta, molti login fisici possono occupare worker simultaneamente; le continuation applicative competono per gli stessi worker. Il completamento della rampa può migliorare rispetto alla serializzazione e, contemporaneamente, la latenza di lavoro estraneo a SQL può peggiorare. È una possibilità da verificare, non un esito universale.

Due esperimenti diversi, due cause diverse

Esperimento A — limite del driver. Usare cold ... async con endpoint ad alta latenza e minimo worker invariato. Confrontare worker, coda e batch time delle due implementazioni. Per renderlo osservabile raccogliere la trace dall'avvio o aumentare le rampe; il burst locale potrebbe finire prima del primo campionamento.

Esperimento B — blocking applicativo. Usare steady ... sync. Qui Task.Run esegue Open(), query sincrona e Thread.Sleep: il blocking viene introdotto intenzionalmente dal laboratorio. Non attribuire al driver tutti i thread bloccati osservati in questo scenario. Il test è utile proprio per riconoscere la differenza negli stack.

# 30 secondi prima del test per collegare i tool al PID stampato.
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 10 100 50 sync 4 30

# Controlli: stessa configurazione, minimo worker diverso o API async.
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 10 100 50 sync 32 30
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 10 100 50 async 4 30

# Ripetere ogni configurazione anche con v1.

SetMinThreads(4, ...) non impone quattro thread massimi e non precrea necessariamente tutti i thread richiesti. Modifica una soglia del runtime. Conservare anche un run senza override; ripetere in un processo nuovo per evitare che un esperimento precedente abbia già fatto crescere il ThreadPool.

Strumenti e segnali

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-stack
dotnet tool install --global dotnet-trace

# Sostituire 12345 con il PID stampato dal laboratorio.
dotnet-counters monitor --process-id 12345 --counters System.Runtime
dotnet-stack report --process-id 12345
dotnet-trace collect --process-id 12345 --duration 00:00:30

Rilevare anche le versioni dei tool. La trace generica è un punto di partenza: per attribuire precisamente il tempo di attesa usare la raccolta degli eventi di wait documentata nel tutorial Microsoft e correlarla agli stack. Alcuni frame nativi potrebbero richiedere simboli e un profiler adatto alla piattaforma. [7]

Metrica System.RuntimeLettura operativa
dotnet.thread_pool.thread.countQuantità di worker; osservare la traiettoria nel tempo.
dotnet.thread_pool.queue.lengthLavoro in coda; correlarlo con completamenti e latenza.
dotnet.thread_pool.work_item.countContatore cumulativo del lavoro completato: calcolare il tasso su un intervallo.
dotnet.process.cpu.timeTempo CPU cumulativo: usare una derivata temporale per stimare utilizzo.
dotnet.gc.heap.total_allocatedAllocazioni cumulative: osservare il tasso, non soltanto il valore assoluto.

Le metriche del Meter System.Runtime non vanno confuse con i nomi dei vecchi EventCounters. La versione dello strumento può presentare etichette e visualizzazioni differenti. [8]

Un probe opzionale utile è un timer che misura il ritardo della propria continuation, oppure un endpoint che restituisce immediatamente senza accedere a SQL. Se cresce anche la sua latenza durante le rampe, il problema ha una dimensione di scheduling condiviso. Separare comunque la CPU del generatore da quella dell'applicazione, altrimenti il probe misura anche il proprio ambiente di carico.

Backpressure applicativa: limitare prima del checkout

Un limite asincrono può impedire che un burst ammetta contemporaneamente troppe operazioni SQL. Deve coprire tutto il periodo di possesso e precedere l'apertura: acquisire il gate dopo OpenAsync consumerebbe già una sessione. Ecco un pattern indipendente dagli interni del driver:

// Un'unica istanza condivisa per il budget che si vuole applicare.
private static readonly SemaphoreSlim DbGate = new(32, 32);

public static async Task<object?> QueryAsync(string cs, CancellationToken ct)
{
    // Budget per l'ammissione, separato dal timeout SQL.
    if (!await DbGate.WaitAsync(TimeSpan.FromSeconds(2), ct))
        throw new TimeoutException("Budget di ammissione SQL esaurito");
    try
    {
        await using var connection = new SqlConnection(cs);
        await connection.OpenAsync(ct);
        await using var command = new SqlCommand("SELECT 1", connection)
        {
            CommandTimeout = 10
        };
        return await command.ExecuteScalarAsync(ct);
    }
    finally
    {
        DbGate.Release();
    }
}

Trentadue è un valore illustrativo, da scegliere con misure. Il semaforo limita le operazioni ammesse ma non limita da solo il numero dei chiamanti in attesa. Il timeout ne limita la permanenza; una policy con queue limit e rifiuto esplicito controlla anche la cardinalità della coda. Su un servizio multi-tenant decidere se il budget debba essere globale, per tenant o gerarchico, per evitare che un cliente monopolizzi l'ammissione.

Lo stesso criterio vale per l'aumento dei minimi del ThreadPool: testare valori diversi e misurare il beneficio. Più worker possono ridurre il ritardo di iniezione e aumentare pressione su memoria, scheduler e database. Un risultato migliore sul cold start deve essere confermato anche su p99 e throughput stabile.

Timeout, cancellazione e resilienza non sono sinonimi

Distinguere timeout di ammissione, tempo dell'open, timeout del comando e deadline dell'intera richiesta. In SqlClient 7.1 lo switch UseOverallConnectTimeoutForPoolWait permette di condividere il budget tra attesa del pool e connessione di rete; il default resta false. Non attivarlo solo nella variante V2 di un benchmark A/B, altrimenti si cambiano due fattori. [2]

La cancellazione è anche un protocollo di pulizia. Testare che una richiesta annullata non perda capacità, che una risorsa arrivata dopo l'annullamento venga restituita e che il sistema recuperi dopo un timeout. Un errore rapido non è necessariamente un timeout reale: clear e shutdown richiedono diagnosi distinta.

Il repository contiene segnalazioni separate per la classificazione degli errori durante ClearPool/ClearAllPools. L'issue #4737 riguarda il pool legacy nella 7.1.0; #4719 richiede una verifica specifica per il channel pool senza un repro confermato al momento della descrizione. Non generalizzare una segnalazione all'altra implementazione. Questi collegamenti servono a orientare i test, non a certificare lo stato di ogni patch successiva. [9]

Per la prova di resilienza trattenere tutti gli slot, avviare un waiter, poi eseguire separatamente cancellazione, scadenza e clear. Ripetere con entrambe le API. Infine eseguire una nuova operazione e controllare il recupero. Tenere queste prove fuori dal benchmark prestazionale.

Azure SQL e scale-out: il budget è distribuito

Questa è una deduzione architetturale dal modello: ogni processo che scala parte con un proprio pool. Se otto repliche cercano contemporaneamente cento sessioni, la domanda aggregata è fino a ottocento aperture. Parallelizzare la crescita del singolo processo può comprimere quel lavoro in una finestra più breve e aumentare il picco visto da autenticazione e server.

Perciò bisogna misurare startup e scale-out insieme. Un warmup moderato può ridurre la latenza delle prime richieste, ma uno coordinato male può sincronizzare i login. Jitter, limiti di ammissione e retry con budget complessivo rendono la domanda più controllabile. Queste sono scelte progettuali da verificare sulla propria topologia.

Un pool rapido non corregge una transazione mantenuta aperta mentre un agente LLM chiama un servizio esterno. In un orchestratore C#/Azure, leggere i dati, materializzare il risultato e restituire la connessione prima dell'inferenza è spesso la separazione più efficace. Se serve consistenza tra più fasi, progettare esplicitamente versioni, idempotenza o compensazioni: trattenere una sessione durante un'attesa non la crea gratuitamente.

Una decisione basata sui percorsi reali dell'applicazione

Il valore di Pool V2 si valuta osservando quali code scompaiono e quali emergono. Per una dashboard Blazor che apre molte sessioni al primo accesso, la rampa può pesare. Per un worker con transazioni lunghe, può dominare la durata del possesso. Per un'applicazione multi-tenant, può dominare la frammentazione dei pool. Il laboratorio deve riflettere il percorso che produce la latenza dell'utente.

Una sperimentazione utile conserva gli stessi binari e abilita soltanto lo switch su una quota di istanze. Confronta startup, errori, open p95/p99, occupazione e scheduling, mantenendo pronto il rollback con riavvio. Il criterio di accettazione deve includere la latenza di operazioni non SQL e la stabilità del server, oltre al tempo della rampa.

Il connection pool è parte del sistema di controllo della concorrenza. Con Pool V2 abbiamo un nuovo modo di coordinare le risorse e creare sessioni; il lavoro dell'architetto è mantenere coerenti budget di connessioni, login, worker e operazioni ammesse. È in questa coerenza che un miglioramento del driver diventa un miglioramento dell'applicazione.

Fonti e codice verificabile

  1. Microsoft — Try SqlClient’s new connection pool for faster parallel connections, 6 ottobre 2026. Annuncio, benchmark cold start e limite del percorso di rete async.
  2. Microsoft Learn — AppContext switches in SqlClient. Attivazione, caching degli switch e budget complessivo del connect timeout.
  3. Microsoft Learn — SQL Server connection pooling. Identità dei pool e controlli pubblici.
  4. ChannelDbConnectionPool.cs — tag v7.1.0. Analisi effettuata sul file raw dello stesso tag; il branch main può essere diverso.
  5. WaitHandleDbConnectionPool.cs — tag v7.1.0. Semafori, WaitAny e pending opens.
  6. SqlClient — Connection Pool V2 Performance Results. Metodologia e copertura dei benchmark ufficiali; pagina aggiornabile.
  7. Microsoft Learn — Debug ThreadPool starvation. Diagnosi, strumenti e stack.
  8. Microsoft Learn — .NET runtime metrics. Meter System.Runtime e significato delle metriche.
  9. SqlClient #4737 — segnalazione legacy pool e #4719 — verifica channel pool. Issue di tracking, da ricontrollare per la patch scelta.

Nessun commento: