(Post 12/10/2007) Locking enforces exclusive
access, and is used to ensure only one thread can enter particular sections
of code at a time.
Locking enforces exclusive access, and is used to ensure
only one thread can enter particular sections of code at a time. For example,
consider following class:
class ThreadUnsafe {
static int val1, val2;
static void Go() {
if (val2 != 0) Console.WriteLine (val1 / val2);
val2 = 0;
}
}
This is not thread-safe: if Go was called by two threads
simultaneously it would be possible to get a division by zero error –
because val2 could be set to zero in one thread right as the other thread
was in between executing the if statement and Console.WriteLine.
Here’s how lock can fix the problem:
class ThreadSafe {
static object locker = new object();
static int val1, val2;
static void Go() {
lock (locker) {
if (val2 != 0) Console.WriteLine (val1 / val2);
val2 = 0;
}
}
}
Only one thread can lock the synchronizing object (in
this case locker) at a time, and any contending threads are blocked until
the lock is released. If more than one thread contends the lock, they
are queued – on a “ready queue” and granted the lock on a first-come,
first-served basis as it becomes available. Exclusive locks are sometimes
said to enforce serialized access to whatever's protected by the lock,
because one thread's access cannot overlap with that of another. In this
case, we're protecting the logic inside the Go method, as well as the
fields val1 and val2.
A thread blocked while awaiting a contended lock has
a ThreadState of WaitSleepJoin. Later we discuss how a thread blocked
in this state can be forcibly released via another thread calling its
Interrupt or Abort method. This is a fairly heavy-duty technique that
might typically be used in ending a worker thread.
C#’s lock statement is in fact a syntactic shortcut for
a call to the methods Monitor.Enter and Monitor.Exit, within a try-finally
block. Here’s what’s actually happening within the Go method of the previous
example:
Monitor.Enter (locker);
try {
if (val2 != 0) Console.WriteLine (val1 / val2);
val2 = 0;
}
finally { Monitor.Exit (locker); }
Calling Monitor.Exit without first calling Monitor.Enter on the same object
throws an exception.
Monitor also provides a TryEnter method allows a timeout
to be specified – either in milliseconds or as a TimeSpan. The method
then returns true – if a lock was obtained – or false – if no lock was
obtained because the method timed out. TryEnter can also be called with
no argument, which "tests" the lock, timing out immediately
if the lock can’t be obtained right away.
Choosing the Synchronization Object
Any object visible to each of the partaking threads can
be used as a synchronizing object, subject to one hard rule: it must be
a reference type. It’s also highly recommended that the synchronizing
object be privately scoped to the class (i.e. a private instance field)
to prevent an unintentional interaction from external code locking the
same object. Subject to these rules, the synchronizing object can double
as the object it's protecting, such as with the list field below:
class ThreadSafe {
List <string> list = new List <string>();
void Test() {
lock (list) {
list.Add ("Item 1");
...
A dedicated field is commonly used (such as locker, in
the example prior), because it allows precise control over the scope and
granularity of the lock. Using the object or type itself as a synchronization
object, i.e.:
lock (this) { ... }
or:
lock (typeof (Widget)) { ...
} // For protecting access to statics
is discouraged because it potentially offers public scope
to the synchronization object.
Locking
doesn't restrict access to the synchronizing object itself in any
way. In other words, x.ToString() will not block because another
thread has called lock(x) – both threads must call lock(x) in order
for blocking to occur. |
Nested Locking
A thread can repeatedly lock the same object, either
via multiple calls to Monitor.Enter, or via nested lock statements. The
object is then unlocked when a corresponding number of Monitor.Exit statements
have executed, or the outermost lock statement has exited. This allows
for the most natural semantics when one method calls another as follows:
static object x = new object();
static void Main() {
lock (x) {
Console.WriteLine ("I have the lock");
Nest();
Console.WriteLine ("I still have the lock");
}
Here the lock is released.
}
static void Nest() {
lock (x) {
...
}
Released the lock? Not quite!
}
A thread can block only on the first, or outermost lock.
When to Lock
As a basic rule, any field accessible to multiple threads
should be read and written within a lock. Even in the simplest case –
an assignment operation on a single field – one must consider synchronization.
In the following class, neither the Increment nor the Assign method is
thread-safe:
class ThreadUnsafe {
static int x;
static void Increment() { x++; }
static void Assign() { x = 123; }
}
Here are thread-safe versions of Increment and Assign:
class ThreadUnsafe {
static object locker = new object();
static int x;
static void Increment() { lock (locker) x++; }
static void Assign() { lock (locker) x = 123; }
}
As an alternative to locking, one can use a non-blocking
synchronization construct in these simple situations. This is discussed
in Part 4 (along with the reasons that such statements require synchronization).
Locking and Atomicity
If a group of variables are always read and written within
the same lock, then one can say the variables are read and written atomically.
Let's suppose fields x and y are only ever read or assigned within a lock
on object locker:
lock (locker) { if (x != 0)
y /= x; }
One can say x and y are accessed atomically, because
the code block cannot be divided or preempted by the actions of another
thread in such a way that will change x or y and invalidate its outcome.
You'll never get a division-by-zero error, providing x and y are always
accessed within this same exclusive lock.
Performance Considerations
Locking itself is very fast: a lock is typically obtained
in tens of nanoseconds assuming no blocking. If blocking occurs, the consequential
task-switching moves the overhead closer to the microseconds-region, although
it may be milliseconds before the thread's actually rescheduled. This,
in turn, is dwarfed by the hours of overhead – or overtime – that can
result from not locking when you should have!
Locking can have adverse effects if improperly used –
impoverished concurrency, deadlocks and lock races. Impoverished concurrency
occurs when too much code is placed in a lock statement, causing other
threads to block unnecessarily. A deadlock is when two threads each wait
for a lock held by the other, and so neither can proceed. A lock race
happens when it’s possible for either of two threads to obtain a lock
first, the program breaking if the “wrong” thread wins.
Deadlocks are most commonly a syndrome of too many synchronizing
objects. A good rule is to start on the side of having fewer objects on
which to lock, increasing the locking granularity when a plausible scenario
involving excessive blocking arises.
Thread Safety
Thread-safe code is code which has no indeterminacy in
the face of any multithreading scenario. Thread-safety is achieved primarily
with locking, and by reducing the possibilities for interaction between
threads.
A method which is thread-safe in any scenario is called
reentrant. General-purpose types are rarely thread-safe in their entirety,
for the following reasons:
-
the development burden in full thread-safety can
be significant, particularly if a type has many fields (each field
is a potential for interaction in an arbitrarily multi-threaded context)
-
thread-safety can entail a performance cost (payable,
in part, whether or not the type is actually used by multiple threads)
-
a thread-safe type does not necessarily make the
program using it thread-safe – and sometimes the work involved in
the latter can make the former redundant.
Thread-safety is hence usually implemented just where
it needs to be, in order to handle a specific multithreading scenario.
There are, however, a
few ways to "cheat" and have large and complex classes
run safely in a multi-threaded environment. One is to sacrifice
granularity by wrapping large sections of code – even access to
an entire object – around an exclusive lock – enforcing serialized
access at a high level. This tactic is also crucial in allowing
a thread-unsafe object to be used within thread-safe code – and
is valid providing the same exclusive lock is used to protect access
to all properties, methods and fields on the thread-unsafe object. |
Primitive types aside, very few .NET framework types
when instantiated are thread-safe for anything more than concurrent read-only
access. The onus is on the developer to superimpose thread-safety – typically
using exclusive locks.
Another way to cheat is to minimize thread interaction
by minimizing shared data. This is an excellent approach and is used implicitly
in "stateless" middle-tier application and web page servers.
Since multiple client requests can arrive simultaneously, each request
comes in on its own thread (by virtue of the ASP.NET, Web Services or
Remoting architectures), and this means the methods they call must be
thread-safe. A stateless design (popular for reasons of scalability) intrinsically
limits the possibility of interaction, since classes are unable to persist
data between each request. Thread interaction is then limited just to
static fields one may choose to create – perhaps for the purposes of caching
commonly used data in memory – and in providing infrastructure services
such as authentication and auditing.
Thread-Safety and .NET Framework Types
Locking can be used to convert thread-unsafe code into
thread-safe code. A good example is with the .NET framework – nearly all
of its non-primitive types are not thread safe when instantiated, and
yet they can be used in multi-threaded code if all access to any given
object is protected via a lock. Here's an example, where two threads simultaneously
add items to the same List collection, then enumerate the list:
class ThreadSafe {
static List <string> list = new List <string>();
static void Main() {
new Thread (AddItems).Start();
new Thread (AddItems).Start();
}
static void AddItems() {
for (int i = 0; i < 100; i++)
lock (list)
list.Add ("Item " + list.Count);
string[] items;
lock (list) items = list.ToArray();
foreach (string s in items) Console.WriteLine (s);
}
}
In this case, we're locking on the list object itself,
which is fine in this simple scenario. If we had two interrelated lists,
however, we would need to lock upon a common object – perhaps a separate
field, if neither list presented itself as the obvious candidate.
Enumerating .NET collections is also thread-unsafe in the sense that an
exception is thrown if another thread alters the list during enumeration.
Rather than locking for the duration of enumeration, in this example,
we first copy the items to an array. This avoids holding the lock excessively
if what we're doing during enumeration is potentially time-consuming.
Here's an interesting supposition: imagine if the List
class was, indeed, thread-safe. What would it solve? Potentially, very
little! To illustrate, let's say we wanted to add an item to our hypothetical
thread-safe list, as follows:
if (!myList.Contains (newItem))
myList.Add (newItem);
Whether or not the list was thread-safe, this statement
is certainly not! The whole if statement would have to be wrapped in a
lock – to prevent preemption in between testing for containership and
adding the new item. This same lock would then need to be used everywhere
we modified that list. For instance, the following statement would also
need to be wrapped – in the identical lock:
myList.Clear();
to ensure it did not preempt the former statement. In
other words, we would have to lock almost exactly as with our thread-unsafe
collection classes. Built-in thread safety, then, can actually be a waste
of time!
One could argue this point when writing custom components
– why build in thread-safety when it can easily end up being redundant?
There is a counter-argument: wrapping an object around
a custom lock works only if all concurrent threads are aware of, and use,
the lock – which may not be the case if the object is widely scoped. The
worst scenario crops up with static members in a public type. For instance,
imagine the static property on the DateTime struct, DateTime.Now, was
not thread-safe, and that two concurrent calls could result in garbled
output or an exception. The only way to remedy this with external locking
might be to lock the type itself – lock(typeof(DateTime)) – around calls
to DateTime.Now – which would work only if all programmers agreed to do
this. And this is unlikely, given that locking a type is considered by
many, a Bad Thing!
For this reason, static members on the DateTime struct
are guaranteed to be thread-safe. This is a common pattern throughout
the .NET framework – static members are thread-safe, while instance members
are not. Following this pattern also makes sensewhen writing custom types,
so as not to create impossible thread-safety conundrums!
When writing components
for public consumption, a good policy is to program at least such
as not to preclude thread-safety. This means being particularly
careful with static members – whether used internally or exposed
publicly. |
(Sưu tầm) |