Home > Articles > Programming > C/C++

  • Print
  • + Share This
This chapter is from the book

This chapter is from the book

13.14 Field Typing in synchronized classes

The transitivity rule for shared objects dictates that a shared class object propagates the shared qualifier down to its fields. Clearly synchronized brings some additional law and order to the table, which is reflected in relaxed typechecking of fields inside the methods of synchronized classes. In order to provide strong guarantees, synchronized affects semantic checking of fields in a slightly peculiar manner, which tracks the correspondingly peculiar semantics of synchronized.

Synchronized methods' protection against races is temporary and local. The temporary aspect is caused by the fact that as soon as the method returns, fields are not protected against races anymore. The local aspect concerns the fact that synchronized ensures protection of data directly embedded inside the object, but not data indirectly referred by the object (i.e., through class references, pointers, or arrays). Let's look at each in turn.

13.14.1 Temporary Protection == No Escape

Maybe not very intuitively, the temporary nature of synchronized entails the rule that no address of a field can escape a synchronized address. If that happened, some other portion of the code could access some data beyond the temporary protection conferred by method-level synchronization.

The compiler will reject any attempt to return a ref or a pointer to a field out of a method, or to pass a field by ref or by pointer to some function. To illustrate why that rule is sensible, consider the following example:

double * nyukNyuk; // N.B.: not shared

void sneaky(ref double r) { nyukNyuk = &r; }

synchronized class BankAccount {
   private double _balance;
   void fun() {
      nyukNyuk = &_balance;     // Error!  (as there should be)
      sneaky(_balance);         // Error!  (as there should be)
   }
}

The first line of fun attempts to take the address of _balance and assign it to a global. If that operation were to succeed, the type system's guarantee would have failed—henceforth, the program would have shared access to data through a non-shared value. The assignment fails to typecheck. The second operation is a tad more subtle in that it attempts to do the aliasing via a function call that takes a ref parameter. That also fails; practically, passing a value by means of ref entails taking the address prior to the call. Taking the address is forbidden, so the call fails.

13.14.2 Local Protection == Tail Sharing

The protection offered by synchronized is also local in the sense that it doesn't necessarily protect data beyond the direct fields of the object. As soon as indirection enters into play, the guarantee that only one thread has access to data is lost. If you think of data as consisting of a "head" (the part sitting in the physical memory occupied by the BankAccount object) and possibly a "tail" (memory accessed indirectly), then a synchronized class is able to protect the "head" of the data, whereas the "tail" remains shared. In light of that reality, typing of fields of a synchronized class inside a method goes as follows:

  • All numeric types are not shared (they have no tail) so they can be manipulated normally.
  • Array fields declared with type T[] receive type shared(T)[]; that is, the head (the slice limits) is not shared and the tail (the contents of the array) remains shared.
  • Pointer fields declared with type T* receive type shared(T)*; that is, the head (the pointer itself) is not shared and the tail (the pointed-to data) remains shared.
  • Class fields declared with type T receive type shared(T). Classes are automatically by-reference, so they're "all tail."

These rules apply on top of the no-escape rule described in the previous section. One direct consequence is that operations affecting direct fields of the object can be freely reordered and optimized inside the method, as if sharing has been temporarily suspended for them—which is exactly what synchronized does.

There are cases in which an object completely owns another. Consider, for example, that the BankAccount stores all of its past transactions in a list of double:

// Not synchronized and generally thread-agnostic
class List(T) {
   ...
   void append(T value) {
      ...
   }
}

// Keeps a List of transactions
synchronized class BankAccount {
   private double _balance;
   private List!double _transactions;
   void deposit(double amount) {
      _balance += amount;
      _transactions.append(amount);
   }
   void withdraw(double amount) {
      enforce(_balance >= amount);
      _balance -= amount;
      _transactions.append(-amount);
   }
   double balance() {
      return _balance;
   }
}

The List class was not designed to be shared across threads so it does not use any synchronization mechanism, but it is in fact never shared! All of its uses are entirely private to the BankAccount object and completely protected inside synchronized methods. Assuming List does not do senseless shenanigans such as saving some internal pointer into a global variable, the code should be good to go.

Unfortunately, it isn't. Code like the above would not work in D because append is not callable against a shared(List!double) object. One obvious reason for the compiler's refusal is that the honor system doesn't go well with compilers. List may be a well-behaved class and all, but the compiler would have to have somewhat harder evidence to know that there is no sneaky aliasing of shared data afoot. The compiler could, in theory, go ahead and inspect List's class definition, but in turn, List may be using some other components found in other modules, and before you can say "interprocedural analysis," things are getting out of hand.

Interprocedural analysis is a technique used by compilers and program analyzers to prove facts about a program by looking at more functions at once. Such analyses are typically slow, scale poorly with program size, and are sworn enemies of separate compilation. Although there exist systems that use interprocedural analysis, most of today's languages (including D) do all of their typechecking without requiring it.

An alternative solution to the owned subobject problem is to add new qualifiers that describe ownership relationships such as "BankAccount owns its _transactions member and therefore its mutex also serializes operations on _transactions." With the proper annotations in place, the compiler could verify that _transactions is entirely encapsulated inside BankAccount and therefore can be safely used without worrying about undue sharing. Systems and languages that do that have been proposed [25, 2, 11, 6] but for the time being they are not mainstream. Such ownership systems introduce significant complications in the language and its compiler. With lock-based synchronization as a whole coming under attack, D shunned beefing up support for an ailing programming technique. It is not impossible that the issue might be revisited later (ownership systems have been proposed for D [42]), but for the time being certain lock-based designs must step outside the confines of the type system, as discussed next.

13.14.3 Forcing Identical Mutexes

D allows dynamically what the type system is unable to guarantee statically: an owner-owned relationship in terms of locking. The following global primitive function is accessible:

// Inside object.d
setSameMutex(shared Object ownee, shared Object owner);

A class object obj may call obj.setMutex(owner) to effectively throw away its associated synchronization object and start using the same synchronization object as owner. That way you can be sure that locking owner really locks obj, too. Let's see how that would work with the BankAccount and the List.

// Thread-aware
synchronized class List(T) {
   ...
   void append(T value) {
   ...
  }
}

// Keeps a List of transactions
synchronized class BankAccount {
   private double _balance;
   private List!double _transactions;

   this() {
      // The account owns the list
     setSameMutex(_transactions, this);
   }
   ...
}

The way the scheme works requires that List (the owned object) be synchronized. Subsequent operations on _transactions would lock the _transactions field per the normal rules, but in fact they go ahead and acquire BankAccount object's mutex directly. That way the compiler is happy because it thinks every object is locked in separation. Also, the program is happy because in fact only one mutex controls the BankAccount and also the List subobject. Acquiring the mutex of _transactions is in reality acquiring the already locked mutex of this. Fortunately, such a recursive acquisition of an already owned, uncontested lock is relatively cheap, so the code is correct and not too locking-intensive.

13.14.4 The Unthinkable: casting Away shared

Continuing the preceding example, if you are absolutely positive that the _transactions list is completely private to the BankAccount object, you can cast away shared and use it without any regard to threads like this:

// Not synchronized and generally thread-agnostic
class List(T) {
   ...
   void append(T value) {
      ...
   }
}

synchronized class BankAccount {
   private double _balance;
   private List!double _transactions;
   void deposit(double amount) {
      _balance += amount;
      (cast(List!double) _transactions).append(amount);
   }
   void withdraw(double amount) {
      enforce(_balance >= amount);
      _balance -= amount;
      (cast(List!double) _transactions).append(-amount);
   }
   double balance() {
      return _balance;
   }
}

Now the code does compile and run. The only caveat is that now correctness of the lock-based discipline in the program is ensured by you, not by the language's type system, so you're not much better off than with languages that use default sharing. The advantage you are still enjoying is that casts are localized and can be searched for and carefully reviewed.

  • + Share This
  • 🔖 Save To Your Account