ZThreads - Design Overview


Introduction
Motivation for ZThreads Creation
Motivation for Interruptind Threads
Motivation for Killing Threads
Motivation for Daemon Threads
Motivation for NonCopyable Synchronization Object
Motivation for Reference Counted Threads

Introduction

The purpose of this document is to explain some of the motivation for creating ZThreads. This document is long overdue, but hopefully it will help to give people a better understandsing of the core of the library and how to use it.

Other articles will disccuss porting the library, as well as using the higer level constructs it provides.

Motivation for Creation of ZThreads

I have a strong interest in object-oriented design and in design patterns. I also do alot of programming on different systems, some of that programming inevitably involves writting multi-threaded code. C++ doesn't have any built in support for threads. Various platforms provide various methods for creating and managing threads, none of which are very portable. For example, there are POSIX threads, Win32 threads and so on. All of these models of threading are really quite different, which makes writing portable that uses threads somewhat of a challange. My goal was to abstract away the need to rely on any one specific thread model. The primary goals are to create portable and to complete abstract the system specific details involved in writing multi-threaded code.

Once this kind of support is available, much more time can be spent on evolving design patterns for using multi threaded. Developing new patterns, such as the Executor and Future patterns shoudl be beneficial to everyone.

Should C++ have built in support for threads? I'm not sure that is completely necessary. What would go a long way towards improving support for code that threads would be providing a way to deal with related issues, such as a few standard atomic operations (TAS, CAS, DCAS, ...) and perhaps chaning volatile or mutable to ask the compiler to insert a memory barrier. Something like that, coupled with a way to handle the order certain objects are destroyed in (a more flexible atexit() function) would add alot of power to the language and to what you can do with threads without having to pick any one model for multithreading. This library demonstrates that flexible, portable support for threads, including a cancellation-like mechanism can be built on top of current thread models.

Motivation for Interrupting Threads

There are several reasons I choose to create an interruptible Thread model. Often, there is a need to abort a blocking operation. For example, a thread might be put to sleep on a condition variable only to have some event occur in a program that would make it desirable to wake that thread up again. Each platform provides a method for accomplishing this, but each method is very closely tied to that platform. The two major models of this are POSIX cancellation and Win32 WaitForMultipleObjects(). The following explains the challanges in emulating the behavior of either model and the portable alternative ZThreads provides.

Problems with POSIX Cancellation:

Cancellation is a C based mechanism; because of this it does not safely interact with exceptions. A thread cancelled within a try block will not neccessarily unwind the stack correctly; this makes it extremely difficult to write safe code that uses cancellation with C++ exceptions.

This library is intended to be object-oriented. Pushing function pointers onto a clean up stack can be somewhat awkward to incorporate nicely into an object-oriented design.

Cancellation is not completely portable. Although it is standard, different operating systems have different levels of support for this mechanism. Some cancellation points are optional, and some cancelltions are non-standard.

When writing code for a single system it is not at all difficult to lookup the supported cancellation points. However, writing portable code would require finding out about each platforms cancellation support. This is tedious at best. If this is not done correctly it is very possible to write code on one system that does not behave the same way on another system, or to write code that relies on a cancellation that exists on one system, but not on another.

It is possible to turn cancellation on and off for various regions of code, but this really leads to the same problem. A programmer can easily forget to do this and get the expected behavior on one system only to find that porting another system has brought about a series of unexpected cancellation problems.

This library is all about abstraction and portability. An important goal is to remove the burden of dealing with the specific details of each system from the programmer.

Cancellation is an invasive mechansim. Invoking the cancellation on a thread has the potentialy to disturb non-pthreads functions. This is by design and again is not an issue when writing code for one system. However, it becomes a tricky issue when writing portable code.

Emulating this kind of behavior would require creating wrappers for functions that should act as cancellation points for systems that don't support POSIX cancellation (Win32). This in completely outside the scope of a thread library. This libraries focus is on portable threads, not on creating a portable version of libc that supports cancellation.

Additionally, the I/O models uses by different systems are very different. For example, Win32 uses compeletion ports, event handles, winsock, etc. It is not possible to choose a single set of functions that are appropriate cancellation points for all systems because of these differences.

Problems with Win32 WaitForMultipleObjects():

WaitForMultipleObjects(), like POSIX cancellation, is also not C++ exception based. Although it does not have the same problems in interacting with exception based code, it can still be a bit more awkward to deal with error codes and switch statements.
WaitForMultipleObjects() is based on Windows handles. It is very versatile because it allows you to wait on a set of handles and to the end that wait when any of those handles is signaled. Just about everything in Windows uses handles making it possible to do very interesting things like wait on thread handle, a socket handle, a file handle and an event handle.

The main drawback is that on other systems not everything uses the same opaque type to create generic handles. For example, on POSIX file handles (int), thread handles (pthread_t) and mutex handles (pthread_mutex_t) are unrelated. It very difficult to create a POSIX implementation of that function with the same versatility. Without the ability to wait on several kinds of handles a big part of the usefulness of WaitForMultipleObjects() would be lost.

Another difficulty in using this type of approach is that, just like POSIX cancelation, it can be easy to misuse. Programmers must be very aware of the consequences involved in using event handles correctly. While this can be chalked up to programmer responsibility, this library is an abstraction. Its intended to take the focus away from these lower level details.

Interruption as a Solution:

To deal with these problems some type of solution was needed. First and foremost, this solution should be 100% portable. The primary goal is portability and abstraction. Remember that this library is not intended to replace pthreads or win32 threads or any operating systems native thread support. Its meant to take advantage of that support and to extend it to provide a truely portable abstraction for using threads.

Interruption is a completely C++ exception based mechanism. Stack unwinding is done correctly, and there are no issues involved with using interruption safely. Its very natural to use interruption if you are used to writing exception based code. No function pointers are required.
Interruption is a completely portable mechanism. There are no issues involved with unexpected behavior when porting interruption-based code to another system. A program using interruption will remain consistent n its behavior.
Interruption is far more flexible. Cancellation is a terminal operation, while interruption is not. Cancellation has cancel-and-exit semantics, which is to say that once a thread is cancelled it has to die. This can be limiting in what can be accomplished.

Cancellation was not intended to be a communication mechanism.

Interruption, on the other hand, is temporary. A thread will recieve an exception when interrupted, but it will reset to a normal state once that exception is thrown; it is not forced to terminate. The thread can go on to do other things, which adds a huge amount of flexibility to what can be accomplished. If a programmer prefers to use cancel-and-exit semantics, they can take an exception thrown as a result of interruption to mean they need to shut down and exit. However, if they want to expliot the flexiblity of interruption for other purposes, that can also be accomplished.

Interruption is an important communication mechanism.

Interruption is not an invasive mechanism. Interruption is focused on threads and thread related operations. by default it will not affect functions outside of the ZThreads library. This is important for portability.

However, interruption does provide a hook that lets a programmer tie some system specific behavior to he interruption mechanism. This can be used to make interruption have some effect on functions external to the ZThreads library if this is desired.

This is the only, and first, free library that provides this kind of portable C++ support.

Motivation for Killing Threads

Most platforms typically have some method the kill a thread, terminating it almost immediately. Originally, this library did include a method for doing just this. However, this encourages writing code that does not exit correctly and often leads to problems. For example, killing a thread that is holding resources (such as a Mutex) can only lead to trouble.

To address this, killing a thread has been altered to act as a specialized form of interruption. Like interruption, killing a thread will produce an exception in an executing thread that is killed. Also, like interruption, after this exception is thrown the thread will continue to act normally. The difference is that a thread will always report that it has been killed (with the isKilled() method).

This method for killing threads allows the thread to cleanup and exit keeps the state of the program as consistent as possible. There is no danger of leaving the program in an inconsistent state. Which should lead to much more stable programs.

Motivation for Daemon Threads

Daemon threads are threads that do not have to be joined by the user. They will be cleaned up after automatically when they are done doing what they are supposed to be doing. This helps people work around issues that would be involed in using the destructos of static instances of some class to join a thread. Because of the limited support for what C++ allows in this area, something to overcome the obstacles it presents was required.

Daemon threads are also quite useful in creatng more flexible code. The Executor implementation is one example of the flexibility that can be achieved with this kind of mechanism.

A program will wait for all daemon threads to exit before it exits.

Motivation for Optional Move Semantics

Runnable objects in ZThreads have optional move semantics. This is to ensure that code that uses daemon threads can be written safely. I'll demonstrate with a brief example.

The following code shows how a normal thread might be used.


class Task : public Runnable {
public:
  
  virtual ~Task() throw() { }

  virtual void run() throw() {
  
    // Do something
    
  }
  
};

void func() {

  Task job;
  Thread t1;
 
  t1.run(job); // Pass by reference, does not transfer ownership
  t1.join();
  
}

In this example, the Task object, job, lives inside the scope of func(). The thread is join()ed before the scope is left, the job object will not be destroyed before its used. Now, if we change func() slightly,

void func() {

  Task job;
  Thread t1;
  
  t1.setDaemon(true);
  t1.run(job); // Pass by reference, does not transfer ownership
  
  
}

t1 becomes a daemon thread. This thread does not have to be joined, that will be taken care of automatically. The problem is that t1 may continue executing after func() exits. The Task object, job will be destroyed when that happens and that will be a big problem. This is where optional move semantics come into play.
void func() {

  Task job;
  Thread t1;
  
  t1.setDaemon(true);
  t1.run(&job); // Pass by pointer, _does_ transfer ownership
  
  
}

By passing the Task object by pointer, rather than by reference, we transfer (or move) the ownership of that object to the thread. The thread will delete that task before it exits. By including both options, both specific tasks for specific threads and shared tasks for many threads can be supported.

Motivation for NonCopyable Synchronization Objects

Most synchronization objects (Mutexes, Conditions, Semaphoers, etc) are NonCopyable objects. Thier copy constructors and assignment operators are disabled. The reason for this is that instances of these objects are points of control. Copying these objects does not make sense. For example, a Mutex may be used to guard access to a region of code. There is only one Mutex and only one thread can acquire() that Mutex at any time. If the Mutex were copyable, it would be possible to create a second Mutex; with two Mutexes, there two objects that need to be acquire()d. This just leads to a tremendous amount of confusion and probably just as much error.

The synchronization objects are designed to be used as members of objects. They have a clear and unique identity because they can not be copied. This eliminates confusion revolving around which object is really getting locked.


class SharedObject {

  Mutex lock;

public:

  void func() {
  
    Guard g(lock);
    
  }
  
};

This is very fitting for an object-orientented library.

Thread objects are NonCopyable as well. They can be allocated on the local stack or on the heap. Making these objects NonCopyable encourages the correct usage of these objects and it simplifies extending the Thread class. For example,


class MyThread : public Thread {

  Data _someData;

public:

  // ...
  
  virtual void run() throw() {
   
    // do something with _someData

  }

};

If the Thread class weren't NonCopyable, several instances of this class could exist at the time. Now, when one of these instances is start()ed the user is going to have are real hard time trying figure out which instance of _someData is actually accessible from the other thread of execution. Making a Thread NonCopyable eliminates this problem.

Motivation for Reference Counted Thread Objects

Threads are reference counting entities by nature. This is required to support all the following constructs.

Creating Threads

Creating normal (on-daemon) Thread objects doesn't need anything special as far as reference counting goes.

// Create a thread

void func() {            
  
   Task aRunnable;            
   Thread t;                  
   
   t.run(aRunnable);                                           
   t.join();                            
                               
}                              
      
                           
// Create a group of threads
                         
void func() {

   Task aRunnable;
   Thread t[10];  
    
   for(int i=0; i<10; ++i)
     t[i].run(aRunnable);
     
   for(int i=0; i<10; ++i)
     t[i].join();
     
}

// Create a group of threads, assign ownership to something else
                         
void func() {

   Task aRunnable;
   std::auto_ptr t[10];  

   for(int i=0; i<10; ++i) 
     t[i] = new Thread;
     
   for(int i=0; i<10; ++i)
     t[i]->run(aRunnable);
     
   for(int i=0; i<10; ++i)
     t[i]->join();
     
}

Creating Daemon Threads

Daemon threads can live outside the scope they were created in. To manage them correctly, reference counting is needed.

void func() {

  Task* aRunnable = new Task();
  
  Thread t;
  t.setDaemon(true);
  t.run(aRunnable);
  
}

 Copyright © 2000 - 2002,  Eric Crahen <crahen at cs dot buffalo dot edu> - All rights reserved.
Permission to use, copy, modify, distribute and sell this documentation for any purpose is hereby granted without fee, provided that the above copyright notice appear in all copies and that both that copyright notice and this permission notice appear in supporting documentation. Eric Crahen makes no representations about the suitability of this software for any purpose. It is provided "as is" without express or implied warranty.