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:
- 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
_CacheITSentries. - The file path is the key. The generic key type is
string, following the same path-based convention used elsewhere in the framework. CacheImgis the lightweight wrapper. Consumers retain a framework-aware object rather than owning the decoded image lifecycle directly.Imageis the heavy object. Decoded pixel data is what the cache protects from unnecessary reads and decodes.- 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
CacheImgis 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.
| Role | Reference | Remains alive while |
|---|---|---|
HObj, the decoded Image | Strong | The cache entry has not expired by maintenance cycles |
_WK, the CacheImg wrapper | Weak | A 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 leavesHObjuntouched. - 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 pressure | Maintenance interval | Behavior |
|---|---|---|
| Factor ≤ 2 | Base × 6 | Quiet cache stays out of the way |
| Around 2× capacity | Base × 4 | Cleanup becomes more frequent |
| Above 10× capacity | Base × 1 | Pressure 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.
| Action | Without CacheImg | With CacheImg |
|---|---|---|
| Open thumbnails and previews | 12 reads and 12 decodes | 6 reads and 6 decodes |
| Scroll and reenter the viewport | Read and decode again | Zero I/O |
| Edit and save one image | Other views reread | Save and EvUpdate; no reread |
| Reopen after a short idle period | Read again | Zero I/O while cycles remain |
| Reopen after expiration | Read again | One 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 repeatednew Bitmap(path)calls. - Retain the
CacheImgwrapper when a control needs update notifications. Subscribe during the control lifecycle and unsubscribe inDispose. - Do not keep the returned
Imagein a consumer-level static field. Long-lived ownership can defeat the intended expiration behavior. - Route file mutations through
Save,Delete, andRestore. If an external process writes the file, callCacheImg.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.
CacheImgfits 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.