Deep C# - Threading,Tasks and Locking |
Written by Mike James | ||||||||
Wednesday, 22 October 2025 | ||||||||
Page 3 of 7
Ensuring Thread SafetyThere are three general approaches to making code thread safe, the first of which is to write reentrant code. If you only use local variables stored on the stack then restarting the function with another thread automatically saves the state of any original invocation. Restricting storage to the stack isn’t always easy, so .NET also provides thread-local storage which ensures that each thread has its own private copy of any variables it used. Reentrant code is a good approach if you need a function that can be used by multiple threads to do a task, but it forbids any sharing of resources which would render the code non-reentrant. A more general approach is to use access control which works by allowing only one thread to access a resource at a time. There are lots of facilities provided within .NET to allow you to implement mutual exclusion and more complicated access methods. The big problem with access control is knowing when a resource is in use by a thread and when it is free. Access control also brings with it problems connected with what happens when a thread wants to use a resource which is locked. Finally we can resort to atomic operations - ones that cannot be interrupted by a change of thread. That is, once started an atomic operation will complete without yielding to another thread. In general, which operations are atomic is defined by the hardware, so this isn’t a particularly stable multi-platform solution. However, it is one of the easiest of approaches to thread safety. Atomic Operations and VolatilityBefore moving onto more sophisticated ideas, let’s consider some of the issues of low-level data access. The .NET CLR guarantees that all native integer operations are atomic in the sense that they cannot be interrupted by a task swap in mid operation. This avoids the problem of a variable being changed by another thread in the middle of an operation so resulting in invalid or inconsistent data. The problem with this definition is that the native integer size varies according to machine type. So, is count++ an atomic operation? It all depends on the machine it is run on. If you want to be sure an operation is atomic then use the operations provided by the Interlocked class. For example: Interlocked.Increment(ref count); will add one to count without any risk that the process will be interrupted by another thread using a similar interlocked operation. Notice that it can potentially be interrupted by standard non-interlocked operations. For this approach to work, all of the threads have to use nothing but interlocked operations to access shared resources. The advantage of Interlocked is simplicity and its disadvantage is that it is limited to the methods provided. In most cases, it is much better to use a lock based on a Monitor, as described later. If you do change the increment in function A and B to use: Interlocked.Increment(ref count); in the previous example you will find that the result is always 20000000 as no increments are ever interrupted. There is another strange problem associated with the way a variable changes its state or not, referred to as volatility. During a non-atomic operation a variable might change its state due to another thread. However, it is possible that the compiler and/or machine architecture may make the assumption that a variable can’t change if the current thread doesn’t change it. This allows variables to be cached in fast memory, but the cached copy may not reflect the true value of the variable stored in main memory. In practice, this problem doesn’t happen at all often but you can declare a variable as volatile if you need the compiler to check that it hasn’t been changed by another thread. In practice it usually isn’t necessary to use volatile as, once again, if you use a lock it performs a volatile read at the start and a volatile write at the end making sure everything is up-to-date. Using Reentrant CodeReentrant code can be executed concurrently because each time it is run essentially a new copy of everything it uses is automatically created. Reentrancy is the key idea in functional programming where it is the norm, but in most languages you have to put in some work to achieve it. In C# you can create a reentrant function in a number of ways, but essentially you have to avoid using global non-constant data. To convert our two example functions to be reentrant all we have to do is remove the use of the global variable count. Once defined as local to each function, the variables are allocated on the stack and hence local to each thread as each thread has its own stack. Of course, this means that the two functions cannot interact with each other, but this is in the nature of re-entrant functions. Consider now the possibility that a class wants to be reentrant, i.e. have nothing but reentrant methods. This is fine as long as it doesn’t make use of global or static variables. A static variable is just a special case of a global variable, i.e. it’s global to all instances of the class. If you still want your class to be reentrant then you have no choice but to use thread-local storage. The simplest way of doing this is to use the [ThreadStatic] attribute. For example, to make the public count variable in the previous example thread-local, we simply have to change its declaration to: [ThreadStatic] static public int count; Notice that now it’s also a static variable shared by all instances of the class, but it isn’t shared by different threads executing methods in the same or different instances of the class. Now if you run this program you will see 0 as each thread increments its own copy of count and the main UI thread has its own copy which remains at 0 as it doesn’t increment it. Thread-local storage isn’t shared, which is the whole point of using it. The [ThreadStatic] attribute is the most efficient way of creating thread-local storage. You can also do it the hard way using named or unnamed data slots, but you need to keep in mind that these are less efficient and only slightly more useful. In this case you have to allocate the storage yourself and use special storage and retrieval functions. The details are straightforward and documented under Thread.AllocateDataSlot and associated methods. Now it’s time to move on to a much bigger and more important topic, exclusion. |
||||||||
Last Updated ( Wednesday, 22 October 2025 ) |