No Static Write From Instance Member
ID |
csharp.no_static_write_from_instance_member |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
low |
Remediation Effort |
medium |
Resource |
Code Smell |
Language |
CSharp |
Tags |
code_smell, concurrency, field, shared-state |
Description
Reports an instance method, property accessor or event accessor that writes to a static field of its own type. One shared storage location is being mutated from code that runs once per object, and in most processes on several threads at once.
Rationale
A static field has exactly one value for the whole program. An instance member has one execution per object, and in a web request pipeline, a background worker or a test run, several of those executions overlap in time. Writing the static field from there mixes the two lifetimes, and the field’s value ends up determined by which caller happened to run last.
The first consequence is lost updates. count++, total += amount, and any "read it, decide, assign
it" sequence compile to a read followed by a write with a gap in between. Two instances running
concurrently both read the old value and both write back their own result, so one update disappears.
Nothing throws, and the shortfall is proportional to load — which is why it is usually noticed in
production rather than in tests.
The second is cross-instance interference. A field that reads like per-object state — the last processed item, a cached lookup, a current user — is in fact one slot, so object A silently overwrites what object B is still working with. In a test suite this shows up as tests that pass alone and fail together, or that depend on execution order.
A write from a static method, from a static constructor or from a field initializer is not reported:
those run once, under the runtime’s initialization guarantees. Constructors, destructors and operators
are out of scope, so counting instances in a constructor is left alone.
A write held inside a lock block is reported. The check is deliberately syntactic: a lock makes
one update atomic, but it does not make the field per-object, so the cross-instance interference
above survives it. Where the shared state is intended and the lock covers the whole
read-modify-write, the finding is one to accept at triage rather than a defect to fix.
using System;
public class Tracker
{
private static readonly object SyncRoot = new object();
private static int processed;
private int instanceCount;
public void Record()
{
processed++; // FLAW — two instances can lose an update
}
public int Total
{
get { return processed; } // OK — a read is not a write
}
public void RecordSafely()
{
lock (SyncRoot)
{
processed++; // FLAW — atomic, but still one slot for every instance
}
}
public void RecordAtomically()
{
System.Threading.Interlocked.Increment(ref processed); // OK — single atomic operation
}
public void Count()
{
instanceCount++; // OK — per-object state
}
public static void Reset()
{
processed = 0; // OK — static member, shared state on purpose
}
}
Remediation
Decide whether the state is per-object or global, then make the code say so.
-
Per-object state: make the field an instance field. This is the fix in the majority of cases — the
staticwas a mistake or a leftover. -
A genuinely global counter or aggregate: keep it static and make the update atomic.
Interlocked.Increment/Interlocked.Add/Interlocked.Exchangecover counters and single-slot values without a lock; alockaround the whole read-modify-write covers anything more involved. For a shared collection, use a type fromSystem.Collections.Concurrent. A write already synchronised this way is still reported — accept the finding at triage rather than rewriting it. -
State that is per-caller rather than per-object or global — an ambient context — belongs in
AsyncLocal<T>or in the request scope of the dependency-injection container, not in a static field.