Package: ThinkGeo.Core 15.0.0-beta136 (also ThinkGeo.UI.Wpf / Gpu / Gdal / SqlServer / PostgreSql / FileGeoDatabase 15.0.0-beta136)
Platform: .NET 10, WPF, win-x64
API: LayerBase.GenerateTileCacheAsync(...) with TileCacheGenerationOptions.MaxConcurrency
Summary
TileCacheGenerationOptions.MaxConcurrency schedules N tile tasks concurrently, but the
actual layer drawing inside each task is guarded by a semaphore keyed on the layer instance.
Because every tile draws the same shared layer objects, all rendering serializes onto one
worker at a time. Raising MaxConcurrency therefore does not reduce wall-clock time in any
meaningful way, and cache builds do not get faster on machines with more cores.
Observed in production: identical cache builds take essentially the same wall-clock time on a
low-core workstation and on a much higher-core machine. Only single-thread clock speed moves
the number, and only slightly.
Where it happens
LayerBase.GenerateTileCacheAsync enumerates the intersecting cells, then dispatches one task
per tile through the MaxConcurrency limiter. Each task calls the private tile renderer, whose
layer loop is (decompiled from 15.0.0-beta136):
foreach (LayerBase item in layers)
{
if (!item.IsVisible) continue;
if (geoCanvas.CancellationToken.IsCancellationRequested) break;
if (item.ThreadSafe == ThreadSafetyLevel.Unsafe)
{
using (await AsyncLocker.LockAsync(item)) // <-- serializes across ALL tile tasks
await A(geoCanvas, item); // layer.Open(); layer.Draw(...)
}
else
{
await A(geoCanvas, item);
}
}
AsyncLocker hands out a single-slot semaphore per object:
private static readonly ConcurrentDictionary<object, SemaphoreSlim> kU = new();
SemaphoreSlim orAdd = kU.GetOrAdd(lockObject, _ => new SemaphoreSlim(1, 1));
And LayerBase's constructor opts every layer into the locked path by default:
protected LayerBase()
{
ThreadSafe = ThreadSafetyLevel.Unsafe;
}
ShapeFileFeatureLayer, FeatureLayer, Layer and InMemoryFeatureLayer do not override this,
so they all run under the lock. Only async/remote types opt into ThreadSafetyLevel.Safe
(ThinkGeoVectorMapsAsyncLayer, RasterXyzTileAsyncLayer, GroupLayer, AdornmentLayer,
ArcGisServerRest*, VectorMbTilesAsyncLayer, WfsV2AsyncLayer, and similar).
Net effect
Since the caller passes one shared collection of layer instances, and drawing dominates the cost
of a tile, the only work that actually runs in parallel is:
-
new SkiaGeoCanvas()/BeginDrawing/EndDrawing -
geoImage.GetImageBytes()(PNG encode) -
fileTileCache.SaveTileAsync(...)
Everything that reads geometry and rasterizes it is behind the per-layer mutex. Wall-clock time
is approximately the serialized sum of all tile draws, regardless of MaxConcurrency.
What we would like
The API accepts MaxConcurrency and a single layer collection, which strongly implies the
generator will parallelize rendering. Today the caller cannot get that, because the only escape
hatch is setting ThreadSafe = ThreadSafetyLevel.Safe on layer types whose feature sources are
not in fact thread-safe (shared file handles and cached state on ShapeFileFeatureSource and the
database sources), which would race rather than speed things up.
Options that would resolve it, in our order of preference:
-
Have the generator clone the layer stack per worker. When
MaxConcurrency > 1and a layerreports
Unsafe, produceMaxConcurrencyindependent layer sets (e.g. viaCloneDeep) andgive each worker its own, so the per-instance semaphores no longer collide.
-
Accept a layer factory. An overload taking
Func<IEnumerable<LayerBase>>that thegenerator invokes once per worker would let callers build the per-worker layer sets themselves,
which is the safest option for sources with credentials or connection state.
-
At minimum, document the current behavior on
TileCacheGenerationOptions.MaxConcurrency—that it only parallelizes encoding and I/O for
Unsafelayers, and that meaningful speeduprequires per-worker layer instances arranged by the caller.
Reproduction
-
Build a
ShapeFileFeatureLayer(or several) over a reasonably large dataset with a valid.idx. -
Call `Layer.GenerateTileCacheAsync(layers, fileTileCache, matrixSet, extent, mapUnit, zoom, zoom,
progress, scaleFactor, generationOptions: new TileCacheGenerationOptions { MaxConcurrency = 1 },
cancellationToken: token)` and record wall-clock time.
-
Repeat with
MaxConcurrency = Environment.ProcessorCounton a machine with many cores. -
The two runs take approximately the same time; CPU utilization stays near one core for the
duration of the draw-bound portion.