Image loading looks inexpensive when considered one control at a time. In an enterprise WinForms application, however, thumbnails, previews, grids, attachment browsers, and reopened forms can repeatedly read and decode the same file. On an application server backed by remote storage, that repetition becomes visible in I/O, CPU usage, and interface latency.

CacheImg addresses that operating problem as a small adapter over the ITS Framework cache infrastructure. It is not a universal image service or a distributed cache. It is a deliberate in-process design for the ITS WinForms runtime on .NET 10 for Windows, where each module instance runs on its own STA thread with its own message loop while remaining inside the same application process and managed runtime.

One Declaration, Five Decisions

public class CacheImg
{
    protected static CacheITS<string, CacheImg, Image> _CacheITS;

    static CacheImg()
    {
        _CacheITS = new CacheITS<string, CacheImg, Image>(
            co => new CacheImg(co),
            s  => ExtImg.Img(s),
            Const.CacheImg_MaintenanceIntervalBase_ss,
            Const.CacheImg_Capacity,
            Const.CacheImgObject_MaintenanceCyclesToLive);
    }
}

Those lines settle five architectural decisions:

  1. One cache per application process. Module instances are not simple MDI children running on the shell thread. Each instance has a dedicated STA thread and message loop, but all module threads in that application process share the same managed runtime, address space, and static _CacheITS entries.
  2. The file path is the key. The generic key type is string, following the same path-based convention used elsewhere in the framework.
  3. CacheImg is the lightweight wrapper. Consumers retain a framework-aware object rather than owning the decoded image lifecycle directly.
  4. Image is the heavy object. Decoded pixel data is what the cache protects from unnecessary reads and decodes.
  5. Policy stays in constants. Capacity, maintenance rhythm, and lifetime can be tuned without changing the class.

This execution model is why synchronization inside the cache matters. Requests can arrive concurrently from several module UI threads, even though no process boundary is crossed. The concurrent collections and locks coordinate access between those threads; they are not an IPC mechanism.

The cache policy, the wrapper factory, and the expensive loader are declared together. The rest of CacheImg is the consumption and content-mutation API.

The Asymmetric Dual Reference

Each cache entry is a CacheObject<string, CacheImg, Image> that deliberately treats the decoded image and its wrapper differently.

RoleReferenceRemains alive while
HObj, the decoded ImageStrongThe cache entry has not expired by maintenance cycles
_WK, the CacheImg wrapperWeakA consumer retains the wrapper in a field or local reference

The strong reference keeps decoded pixels warm even after no control retains the wrapper. The weak reference allows the wrapper itself to be collected. If a later request finds the entry but not the wrapper, the cache creates a new CacheImg that points to the existing HObj. Rebuilding the wrapper is cheap; reading and decoding the file is not.

The hot path reads the weak reference and returns its target without locking. A reader-writer lock is needed only when the wrapper must be rebuilt, using a double-check around creation. That distinction matters when image access occurs inside frequently executed paint and navigation paths.

The Full Access Flow

public static CacheImg G(string path) => _CacheITS.G(path);

public Image Image
{
    get => _CO.HObj;
    private set => _CO.HObj = value;
}

A consumer uses one stable call:

Image img = CacheImg.G(path).Image;

On the first request for a path, _CacheITS.G(path) creates a cache entry, invokes ExtImg.Img(path), stores the decoded image in HObj, and creates the wrapper. That is the only disk read and decode until the entry expires or is explicitly invalidated.

On subsequent requests, the dictionary returns the same cache entry. If the weak wrapper remains alive, the same wrapper is returned. If it was collected, a new wrapper is built around the same decoded image. In both cases, .Image resolves to the in-memory HObj with zero file I/O.

First request
Path → cache miss → disk read + decode → HObj → CacheImg

Later request
Path → cache hit  → existing HObj         → CacheImg

Usage-Driven Expiration

CacheImg does not use a conventional wall-clock time-to-live. Entries expire after a configured number of maintenance cycles without an active wrapper.

  • While any consumer retains a CacheImg, each maintenance pass resets the entry counter and leaves HObj untouched.
  • After the last consumer releases the wrapper and the garbage collector reclaims it, the idle-cycle countdown begins.
  • When the entry reaches Const.CacheImgObject_MaintenanceCyclesToLive, it is removed. The decoded image becomes unreachable and can be reclaimed.

This policy matches the interaction pattern the class serves: a user opens a message with attachments, closes it, and may reopen it shortly afterward. The images remain warm during brief inactivity, then leave memory after the user has moved on.

Adaptive Maintenance

The interval between cleanup passes changes with cache pressure. The maintenance factor is based on Count / Capacity.

Cache pressureMaintenance intervalBehavior
Factor ≤ 2Base × 6Quiet cache stays out of the way
Around 2× capacityBase × 4Cleanup becomes more frequent
Above 10× capacityBase × 1Pressure triggers the fastest maintenance rhythm

This is useful on an application server with variable load. At rest, maintenance consumes little attention. During a burst of large images, the cache reviews idle entries more aggressively. Capacity is therefore a nominal policy input, not a hard item ceiling.

Content Operations and Coherency

CacheImg is also the coordinated mutation point for the underlying file. Saving, deleting, and restoring through the wrapper keeps the cached object and every subscriber consistent.

Save

public void Save(Image img, bool bk)
{
    if (img == null) return;
    if (bk) { /* preserve the original as bk_<name> */ HasBkImg = true; }
    Image = img;
    img.Save(Path);
    OnEvUpdate();
}

Save replaces HObj in memory before persisting the file. Other views receive the new image from the same cache entry and do not need to reread it.

Delete

public void Delete(bool bk)
{
    if (bk) { /* move the file to its backup path */ HasBkImg = true; }
    else System.IO.File.Delete(Path);

    Release(Path);
    Image = null;
    OnEvUpdate();
}

Deleting a file also evicts its cache entry. A future request cannot accidentally receive stale decoded content.

Restore

public void Restore()
{
    string bkPath = BkPath;
    if (!System.IO.File.Exists(bkPath)) return;
    System.IO.File.Delete(Path);
    System.IO.File.Move(bkPath, Path);
    Image = ExtImg.Img(Path);
    HasBkImg = false;
    OnEvUpdate();
}

Restoring rehydrates HObj from the backup and then notifies consumers.

EvUpdate: notification without polling

public event EventHandler<EvCacheUpdatedArg> EvUpdate;

public void OnEvUpdate() =>
    EvUpdate?.Invoke(this, new EvCacheUpdatedArg(Path));

A control displaying the image can subscribe to EvUpdate and refresh only after a coordinated mutation. The source of truth and the notification source are the same object, avoiding polling and defensive rereads.

Operational Impact

Consider a message containing six image attachments displayed as both thumbnails and a preview.

ActionWithout CacheImgWith CacheImg
Open thumbnails and previews12 reads and 12 decodes6 reads and 6 decodes
Scroll and reenter the viewportRead and decode againZero I/O
Edit and save one imageOther views rereadSave and EvUpdate; no reread
Reopen after a short idle periodRead againZero I/O while cycles remain
Reopen after expirationRead againOne fresh read per image

In the intended RDS deployment, one application process can host many simultaneous module instances, each running on its own STA thread with its own message loop. Because those module threads remain in the same process, they all see one static CacheImg cache without IPC, shared memory, or a central cache service. That is where the design has the greatest operational value: repeated reads from different modules disappear before they reach remote storage.

Boundaries and Best Practices

  • Load reusable files through CacheImg.G(path).Image, not through repeated new Bitmap(path) calls.
  • Retain the CacheImg wrapper when a control needs update notifications. Subscribe during the control lifecycle and unsubscribe in Dispose.
  • Do not keep the returned Image in a consumer-level static field. Long-lived ownership can defeat the intended expiration behavior.
  • Route file mutations through Save, Delete, and Restore. If an external process writes the file, call CacheImg.Release(path) to invalidate the entry.
  • Tune capacity, base maintenance interval, and cycles-to-live through Const, based on measured file sizes and workload.
  • Use CacheImg.GetInfo() for diagnostics: entry count, pending expiration, live wrappers, pressure factor, and the next cleanup pass.

The design also has clear boundaries. The static cache is shared by module threads inside one application process; it does not cross into another application process, another RDS user process, a web server, or an independently scaled service. A path key assumes stable path normalization and a trusted mutation route. The cached Image type also requires consumers to respect its thread-safety and disposal expectations.

CacheImg fits the ITS Framework operating environment because the application process is the shared runtime boundary. One message loop per module creates real thread concurrency inside that boundary, and the cache coordinates that concurrency without pretending to be a distributed cache.

Final Principle

CacheImg combines three ideas: a strong reference to the expensive decoded image, a weak reference to the lightweight wrapper, and maintenance that reacts to use and cache pressure.

Together with Save, Delete, Restore, and EvUpdate, the wrapper becomes the reader, coordinated writer, and notification point for images touched by the framework.

The result remains behind one stable call:

Image img = CacheImg.G(path).Image;

Opening the same image five hundred times can cost one disk read—for as long as the cache entry remains relevant.