# GenerateTileCacheAsync: MaxConcurrency has almost no effect — rendering is serialized by a per-layer lock

**URL:** <https://community.thinkgeo.com/t/generatetilecacheasync-maxconcurrency-has-almost-no-effect-rendering-is-serialized-by-a-per-layer-lock/12447>\
**Category:** WPF\
**Created:** [September 9, 2026, 12:28pm UTC](https://community.thinkgeo.com/t/generatetilecacheasync-maxconcurrency-has-almost-no-effect-rendering-is-serialized-by-a-per-layer-lock/12447 "2026-09-09T12:28:29Z")\
**Posts on this page:** 4\
**Page:** 1

<div class="post-metadata">

**Author:** ![Julian\_Thoms](https://community.thinkgeo.com/letter_avatar_proxy/v4/letter/j/b77776/32.png) [@Julian\_Thoms](https://community.thinkgeo.com/u/Julian_Thoms)\
**Post date:** [September 9, 2026, 12:28pm UTC](https://community.thinkgeo.com/t/generatetilecacheasync-maxconcurrency-has-almost-no-effect-rendering-is-serialized-by-a-per-layer-lock/12447/1 "2026-09-09T12:28:29Z")

</div>

**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):

```auto

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:

```auto

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:

```auto

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:

1. **Have the generator clone the layer stack per worker.** When `MaxConcurrency > 1` and a layer

2. **Accept a layer factory.** An overload taking `Func<IEnumerable<LayerBase>>` that the

3. **At minimum, document the current behavior** on `TileCacheGenerationOptions.MaxConcurrency` —

## Reproduction

1. Build a `ShapeFileFeatureLayer` (or several) over a reasonably large dataset with a valid `.idx`.

2. Call `Layer.GenerateTileCacheAsync(layers, fileTileCache, matrixSet, extent, mapUnit, zoom, zoom,

3. Repeat with `MaxConcurrency = Environment.ProcessorCount` on a machine with many cores.

4. The two runs take approximately the same time; CPU utilization stays near one core for the

---

<div class="post-metadata">

**Author:** ![Ben](https://community.thinkgeo.com/user_avatar/community.thinkgeo.com/ben/32/25720_2.png) [@Ben](https://community.thinkgeo.com/u/Ben)\
**Post date:** [September 12, 2026, 9:20pm UTC](https://community.thinkgeo.com/t/generatetilecacheasync-maxconcurrency-has-almost-no-effect-rendering-is-serialized-by-a-per-layer-lock/12447/2 "2026-09-12T21:20:45Z")

</div>

Hi Julian

You read it right. Every `FeatureLayer` is `ThreadSafetyLevel.Unsafe` by default, `DrawTileAsync2` locks on the instance, and every tile task shared your one layer collection, so they drew one at a time.

It’s fixed in **15.0.0-beta143** , along the lines of your first two options:

- **One layer set per worker.** `GenerateTileCacheAsync(layers, ...)` builds `MaxConcurrency` sets up front: the first worker draws with your instances, every further worker with a `CloneDeep()` of each layer that is not thread safe (thread-safe layers are shared). Each tile draws on the thread pool. The clones are closed when the generation ends; your layers are left as they were.

- **A factory overload.** `GenerateTileCacheAsync(Func<IEnumerable<LayerBase>> layersPerWorker, ...)` calls the factory once per worker, for database or service sources whose instances you want to build yourself. The generator opens those and closes them when done.

- `TileCacheGenerationOptions.MaxConcurrency` now documents all of this.

One rule: a layer can only be cloned while it is closed, so a layer passed in open stays shared under its lock, as before. Pass closed layers, or use the factory.

Measured on 12 cores, a street shapefile with lines and labels, 4,776 tiles over five zoom levels are as following. Past four workers the encode and the writes start to share the time, so the curve flattens - but it is no longer a no-op.

| MaxConcurrency | before | beta143 |
| --- | --- | --- |
| 1 | 33 s | 33 s |
| 4 | ~33 s | 14 s |
| 8 | ~33 s | 13 s |

And just FYI tilecache is not needed for the new GPU render, instead you can convert the shapefiles to pmtiles by using VectorPmTilesGenerator, to improve the performance. We’ll talk about it in the HowDoI sample but let me know if you have any questions.

Thanks,  
Ben

---

<div class="post-metadata">

**Author:** ![Julian\_Thoms](https://community.thinkgeo.com/letter_avatar_proxy/v4/letter/j/b77776/32.png) [@Julian\_Thoms](https://community.thinkgeo.com/u/Julian_Thoms)\
**Post date:** [September 14, 2026, 7:36am UTC](https://community.thinkgeo.com/t/generatetilecacheasync-maxconcurrency-has-almost-no-effect-rendering-is-serialized-by-a-per-layer-lock/12447/3 "2026-09-14T07:36:00Z")

</div>

Thanks Ben, that improved cache build time by a lot!

Regarding your FYI - interesting, how would the best GPU workflow look like for our usecase then (editable layer styles with PMTiles)?

Our map combines shapefiles, database feature layers and rasters. Users frequently change colors, symbols, thematic styles and labels. We retain classic sources for queries, selection and printing.

Currently we:

- Translate classic styles with FeatureLayerTranslator and compose them with MapStyle.AddFeatureLayer.
- Generate PMTiles for eligible shapefiles using VectorPmTilesGenerator, preserving attributes and matching the translated source-layer name.
- Register PmTilesVectorTileSource through GpuBasemap.TileSources, overriding the translated runtime source by ID.
- Reuse PMTiles files across style changes.

We encountered stale rendering when toggling labels or changing symbols. Switching GPU off and back on displayed the updated styles. As a workaround, we now replace the GPU host for style changes. During editing, we show CPU previews and rebuild GPU after a 10-second idle period.

What is the recommended way to update styles and labels while retaining the GPU host and existing vector tiles? Is our PMTiles source-override approach appropriate, or should we compose the sources and styles differently? Guidance on source ownership and invalidation with SetStyleAsync would be especially helpful.

A HowDoI example covering this workflow would be great - but quick guidance here would also be enough. Thanks!

---

<div class="post-metadata">

**Author:** ![Ben](https://community.thinkgeo.com/user_avatar/community.thinkgeo.com/ben/32/25720_2.png) [@Ben](https://community.thinkgeo.com/u/Ben)\
**Post date:** [September 19, 2026, 8:48pm UTC](https://community.thinkgeo.com/t/generatetilecacheasync-maxconcurrency-has-almost-no-effect-rendering-is-serialized-by-a-per-layer-lock/12447/4 "2026-09-19T20:48:04Z")

</div>

Hi Julian,

Same shape as the restyle answer in 12446: translate again over translation.Source, hand the map a new MapStyle with SetStyleAsync. It keeps the decoded tiles of every source it already holds and rebuilds only buckets and labels. For a label, SetLayerVisibility(id, …) on the attached style needs no rebuild at all.

Your stale rendering is one specific thing: AddFeatureLayer into a style that already holds that layer appends — it never replaces. Both draw, so a label you removed keeps showing. Build a new MapStyle per change rather than editing the live one, and the old layers go with it. That is why a new host looked like the cure.

PMTiles. GpuBasemap.TileSources is read when the map opens; a source registered there under the translated source id replaces the runtime cutter for that id and survives every SetStyleAsync. Keep its source-layer named exactly as the translation’s SourceLayer and on the same tile grid as your other sources. Reusing the files across style changes is right — styles apply at draw time.

One correction to what we told you before: a restyle no longer refuses when the source count changes — that is the 12455 item, in beta157 onward. Switching a layer on at runtime does not need a new host any more.

Thanks,  
Ben
