Columns


CUG Product Focus

Class DOSThread: A Base Class for Multithreaded DOS Programs

Victor R. Volkman


Victor R. Volkman received a BS in Computer Science from Michigan Technological University. He has been a frequent contributor to The C Users Journal since 1987. He is currently employed as Senior Analyst at H.C.I.A. of Ann Arbor, Michigan. He can be reached by dial-in at the HAL 9000 BBS (313) 663-4173 or by Usenet mail to sysop@hal9k.com.

Synopsis

I have abstracted this article from original documentation by John English; he has provided this documentation expressly for reprint in The C Users Journal.

Class DOSThread is now available as CUG Library volume #385. Volume #385 also includes the class TSR and the class Coroutine. All three packages have been contributed by John English.

DOSThread is a C++ class for creating multithreaded DOS programs. To produce threads, programs must derive classes from DOSThread; each instance of a derived class then implements a single thread. The executable code for each thread will be contained in each class's member function main. Users of DOSThread write member function main, but the derived class already does most of the work of multithreading, by supplying member functions for starting, stopping, and delaying threads.

This article is laid out in four major sections. The first section is a brief introduction to the overall capabilities of DOSThread threaded applications. The second section of the article shows how to create DOSThread applications. The third section introduces interthread communication and the use of Monitors via DOSThread-supplied member functions. Finally, the fourth section discusses a few problem areas in the current DOSThread implementation. The author of DOSThread welcomes communication about DOSThread; his various addresses are listed at the end of this article.

Overall Capabilities of DOSThread

Class DOSThread provides a framework for writing DOS applications which consist of multiple (pseudo) parallel "threads" of execution. DOS is not a multithreading operating system; in fact, DOS actively hinders multithreading by being non-reentrant. But this class allows you to create multithreaded DOS applications without worrying about DOS's non-reentrant design (although you should read the volume's documentation for some important caveats concerning direct calls to the BIOS).

To create a thread, you derive a class from the DOSThread class; the derived class should contain the code you want to execute in a member function main. Then you simply declare an instance of your derived class and call the member function run to start it running. Each thread executes in intervals of 1 clock tick (55 milliseconds), then suspends execution to allow the other threads to execute. You can change the length of this timeslice if necessary, through member function timeslice. If necessary, you can disable timeslicing entirely, in which case it is up to each thread to relinquish the processor at regular intervals so the other threads have a chance to run.

Threads can delay themselves for a given number of clock ticks by calling member function delay; they can relinquish the processor to other threads by calling the member function pause; and they can terminate themselves or each other by calling the member function terminate. You can also inspect the current state of any thread (ready-to-run, terminated, delayed, etc.) by calling member function status, and you can cause your program to wait for a thread to terminate by calling the member function wait.

You can derive monitor classes of your own from two additional classes (DOSMonitor and DOSMonitorQueue) to facilitate communication between threads. A monitor implementation normally includes data structures to be accessed by several threads. To guarantee that only one thread at a time is executing a monitor member function (which accesses the data), call the member function lock at the start of the monitor function. If any other thread is already executing a monitor function guarded by lock, a contending thread will wait until it is safe to proceed. At the end of the monitor function, you should call unlock to allow any waiting threads to proceed.

Monitors containing instances of DOSMonitorQueue can also allow threads to suspend themselves in a monitor function until some condition has been fulfilled (e.g. buffer not empty). Other threads executing within the monitor can resume suspended threads when a condition is fulfilled (e.g. when a data item has been put into an empty buffer). This distribution also includes a template class which implements a bounded buffer.

The preceding paragraphs describe what are probably the most common uses of monitors in DOS applications, so you may not even need to define any monitor classes of your own.

How to Create DOSThread Applications

Deriving a New Thread from DOSThread

You create every thread by deriving from the base class DOSThread. Each derived thread class must define a member function called main which contains code for the thread to execute. Declare main as follows:

void MyThread::main ()
{
    // code to be executed by your thread
}
The constructor for MyThread will invoke the constructor for DOSThread. The constructor for DOSThread takes a single parameter — an unsigned integer specifying the stack size for the thread. However, the default stack size is 2,048 bytes; if this size is sufficient you need not call the DOSThread constructor explicitly.

After deriving a thread class, you can instantiate this derived class, as in:

MyThread threadl;        // a thread called "thread1"
MyThread threads [5];        // five identical threads
The threads you declare will not execute until you call their member functions run, as follows:

thread1.run ();
run returns true(1) if the thread was started successfully, and false (0) if the thread could not be started (either because there was insufficient memory to create the necessary data structures, or because it had already been started). Note that you cannot call run from your thread constructor, because the virtual function main is not accessible until you have finished executing the constructor.

Once a thread has started successfully, it will execute in parallel with the main program. The main program effectively becomes another thread (although it has no name, and it can only call the static functions pause and delay described later).

As a default, each thread executes in a "timeslice" of one clock tick (55 msec). If a thread is still running when its timeslice expires, it moves to the back of the queue of ready-to-run threads, and the next thread in the queue resumes execution. You can call static member function timeslice to change the length of the timeslices. timeslice requires an unsigned integer parameter specifying the desired timeslice duration in clock ticks. For example:

DOSThread::timeslice (18);
    // timeslice once a second (18 x 55ms)
If you set the parameter to zero, timeslicing is disabled. In this case each individual thread must explicitly relinquish control at regular intervals. Threads can release control by calling a member function which will schedule another thread for execution. DOSThread provides member function pause for just this purpose; pause is described later in this article.

You must call timeslice before you declare any threads; as soon as the first thread has been declared, the program will ignore calls to timeslice. In other words, you cannot change the length of the timeslice during execution of the program.

Writing the Member Function main

MyThread::main (the main function of your derived class) executes in parallel with the rest of the program once it has been started by calling run. While you can write MyThread::main exactly like any other function, remember that it is sharing the processor with a number of other threads; so if it has nothing useful to do, it should allow some other thread to run. You can temporarily release the processor to another thread by calling the member function pause:

pause ();           // schedule another thread
pause is a static member function, so it can be called from any point in a program as DOSThread::pause. Even if you have enabled timeslicing, it's a good idea to call pause if your thread is temporarily unable to proceed (e.g. if it is waiting for a key to be pressed), otherwise the thread will waste several milliseconds until its timeslice expires.

You can also make your thread wait for a fixed time by calling the static member function delay, specifying the delay period as a number of 55 msec clock ticks:

delay (18);            // delay for 1 second (18 x 55ms)
Note that pause and delay are both static member functions, and they always affect the current thread. Consequently, you cannot pause or delay any other thread. Also, you can call these functions from the main program if you need to.

When MyThread::main returns, the main thread terminates. You can also terminate a thread explicitly by calling the member function terminate. If another thread (or the main program) wants to terminate thread1, it can do so like this:

thread1.terminate ();
However, abitrarily terminating a thread can cause problems; in the given example you have no idea what thread1 is doing at the time. A thread can also terminate itself, as in:

terminate ();
which has the same effect as returning from the main function of the thread.

Initialization and Finalization

When the main program (or another thread) declares a thread, the program must call a constructor for class DOSThread to create the thread, and complete initialization by calling the constructor defined by your derived thread class. Note that a thread is not completely constructed until this sequence is complete; in particular, note that you cannot call run from inside your derived class constructor to start the thread running immediately.

When execution reaches the end of a block in which a thread was declared, the program calls the thread's destructor. The program calls any destructor in your derived class first (the thread could still be running), and then calls the standard DOSThread destructor to wait for the thread to terminate before tidying up. Your destructor should not do anything that might cause the thread to fail. To ensure that it does not interfere with the thread's execution, your destructor may call the member function wait, to wait for the thread to terminate. Your destructor should call this function before doing anything that might cause the thread to fail. In other words, your destructor should be written like this:

MyThread::~MyThread ()R
{
    wait ();          // wait for thread to terminate
    ...                // do any class-specific tidying up
}

Handling Control-Break and Critical Errors

Class DOSThread provides a simple mechanism for dealing with events reported by DOS. The first such event occurs when the user presses the control-break key to abort a program. Class DOSThread intercepts these events and sets an internal flag. Individual threads (or the main program) can call the static member function userbreak to test if control-break has been pressed:

if (DOSThread::userbreak ()) ...
This flag will remain set so other threads can also inspect it. Alternatively, you can call the static function cancelbreak, which is identical to userbreak except that it also resets the internal flag. Calling cancelbreak allows an individual thread to respond to a control-break event while preventing any other threads from responding. cancelbreak also provides a means for resetting the flag. If the program's threads do not use either of these functions, the program will ignore control-breaks completely.

DOS will generate critical errors (the familiar "Abort, Retry, Fail?" errors) if a disk is write-protected or if a printer is offline. Classes derived from DOSThread can provide a virtual function error to deal with critical errors generated within their member functions. Threads must provide their own critical error handlers on an individual basis; the default handler just fails the operation. To provide a critical error handler for a thread class, define a member function DOSerror as follows:

DOSThread::Error DOSerror (int N);
The parameter N is the DOS code defining the cause of the error. DOSerror should return DOSThread::IGNORE to ignore the error, DOSThread::RETRY to retry the operation that caused the error, or DOSThread::FAIL to fail the operation. Note that during critical-error handling, the only DOS services that you can call are functions 00 to 0C. Class DOSThread will intercept and ignore any other functions, since they would cause DOS to crash.

You should never call the function DOSerror directly; the program will call it automatically if an error occurs during execution of a thread.

Inspecting the Status of Threads

The member function status allows you to determine the status of a thread at any time. status can be called as follows:

state = thread1.status ();
The result is a value of type DOSThread::State, which will be one of the following values:

Value                     Indication
OSThread::CREATED       — the thread is newly created and
                           can be started by calling run.
DOSThread::READY        — the thread is ready to run
                           (or is currently running).
DOSThread::DELAYED      — the thread has delayed itself
                           by calling delay.
DOSThread::WAITING      — the thread is waiting to enter a
                           monitor function guarded by lock.
DOSThread::QUEUED       — the thread is inside a monitor and
                           is suspended on a monitor queue.
DOSThread::TERMINATED   — the thread has terminated.

Interthread Communication

One of the more difficult features to implement in multi-threaded programs is communication between threads. Since you do not know when a thread will be rescheduled, it is unsafe to modify shared global variables, as it is perfectly possible to be interrupted during the update process. If another thread performs a similar update, you may complete your update with out-of-date values when your thread resumes. As a result, global variables may end up in an inconsistent and incorrect state.

The base class DOSMonitor provides a convenient base for building safe interthread communication. You simply derive a class from DOSMonitor which encapsulates any shared data, and which provides functions to access the data. Each access function should begin by calling the member function lock and end by calling unlock. This practice will guarantee that only one thread at a time is executing an access function in any individual monitor. Therefore, the general structure required for a monitor access function is as follows:

void MyMonitor::access ( /* parameter list */ )
{
    lock ();
    ...           // access shared data as required
    unlock ();
}
Classes derived from DOSMonitor can also contain instances of class DOSMonitorQueue. Within an access function, you can call the member function suspend with a DOSMonitorQueue object as its parameter. You call suspend to suspend the thread executing the access function until some condition is satisfied. Calling suspend will allow other threads to execute access functions within that monitor. The other access functions can resume any threads suspended on a particular queue by calling the member function resume, with the queue as a parameter. Calling resume will reawaken the threads suspended in that queue.

Note that you should call suspend from within a loop; since resume will restart all the threads in the specified queue, you can't be sure that when your thread resumes execution that the condition the thread was waiting for will still be true. Thus, to suspend a thread until a counter is non-zero, use code such as the following:

while (counter != 0)
    suspend (some_queue);
As an example of a monitor class, consider a monitor providing a 20-character buffer to transfer data from one thread to another. The code might look something like this:

class Buffer : public DOSMonitor
{
    char data[20];          // the buffer itself
    int count;              // no. of chars in buffer
    int in;                 // where to put next char
    int out;                // where to get next char from
    DOSMonitorQueue full;
    OSMonitorQueue empty;
public:
    Buffer ()                  {
count = in = out = 0; }
    void get (char& c);
     // get a char from the buffer
    void put (char& c);
     // put a char in the buffer
};
The class constructor initialises count to zero to indicate an empty buffer and points in and out to the start of the buffer. Threads must then call get and put in succession to access the buffer's contents. The buffer class declares two DOSMonitorQueue instances; full suspends threads which call put when the buffer is full, and empty suspends threads which call get when the buffer is empty. The code for get would be similar to the following:

void Buffer::get (char& c)
{
    //--- lock the monitor
    //--- against re-entry
    lock ();

    //--- suspend until the
    //--- buffer isn't empty
    while (count == 0)
        suspend (empty);

    //--- get next character from the buffer
    c = data [out++];
    out %= 20

    //--- resume any threads waiting until buffer isn't full
    resume (full);

    //--- unlock the monitor to let other threads in
    unlock ();
}

The Class BoundedBuffer

The class BoundedBuffer included in this distribution is a template class derived from DOSMonitor, which implements a bounded buffer similar to the preceding example. You can create a 20-character buffer by instantiating this class as follows:

BoundedBuffer<char> buffer(20);
The type given in angle brackets <...> is the type of item you want to store in the buffer, and the parameter value is the maximum number of items the buffer can hold. BoundedBuffer provides the following member functions:

Function       Description
get (item)  — Get the next item from the buffer and store it
               in item. The function returns 1 (true) if it is
               successful and 0 (false) if the buffer has
               been closed (see close).
put (item)  — Put a copy of item into the buffer. This
               function returns 1 (true) if it is successful
               and 0 (false) if the buffer has been closed
               (see close).
items ()    — Return the number of items in the buffer.
close ()    — Close the buffer to prevent further accesses.
               If you do not close buffers when you have
               finished using them, you run the risk of your
               program never terminating — a thread may be
               suspended waiting for a character that will
               never arrive, which means that its destructor
               will wait forever for it to terminate.

Error Handling in Monitors

Monitors derived from class DOSMonitor should provide a virtual function called error, which the program will call if it detects any errors in a monitor. You should declare Error as follows:

void error (DOSMonitor::ErrorCode);
The parameter to error is a code for the detected error. This code can take any of the following values:

Error Code Value             Interpretation
DOSMonitor::NEW_FAIL      — there was insufficient memory to
                             create the necessary data structures
                             for the monitor.
DOSMonitor::NO_THREAD     — a monitor has been called when
                             there are no threads running.
DOSMonitor::LOCK_FAIL     — the current thread is calling lock
                             when it has already locked the
                             monitor.
DOSMonitor::UNLOCK_FAIL   — the current thread has called
                             unlock without having locked
                             the monitor.
DOSMonitor::SUSPEND_FAIL  — the current thread has called
                             suspend without having locked
                             the monitor.
DOSMonitor::RESUME_FAIL   — the current thread has called
                             resume without having locked
                             the monitor.
The last five of these error codes will occur when a bug exists in the monitor code itself (which should be corrected). If a monitor does not provide a definition for error, the class provides a default response to exit the program with an exit status in the range —1 to —6 (—1 for NEW_FAIL through to —6 for RESUME_FAIL).

Potential Problem Areas

Class DOSTask guards against reentrant calls to DOS with an internal monitor, as reentrant calls are certain to crash your machine. DOSTask does not protect direct calls to BIOS functions in the same way. While BIOS calls are generally safer (because they use the caller's stack), they still manipulate a global shared data area. Therefore, you should not call BIOS functions directly; direct BIOS calls can create hard-to-identify bugs resulting from an inconsistent internal state. As for C++ library functions, these functions normally call DOS services rather than BIOS functions, so most of the functions in the standard library are safe to use. The major exceptions to this are the functions defined in <bios. h> and the functions int86 and int86x.

If you do need to use BIOS functions directly, the best way is to localize all BIOS calls in a single monitor so that only one task can call a BIOS function at a time; however, since DOS services will make BIOS calls to perform their functions, you must also encapsulate all DOS calls in the monitor to guarantee that only one task is making a BIOS call at a time. Encapsulating all DOS calls may not be a terribly practical solution.

Another point worth noting: you should perform screen output with fputs rather than cout, printf or puts. These last three functions generate several DOS calls to output their data, so their output could become interleaved with the output of another thread. In particular, cout may produce the same output twice if its calling thread is interrupted at a particular moment (after output has been displayed but before the internal buffer has been cleared). Also, the next thread calling cout will append its output to the existing contents of the buffer, which will then be displayed in its entirety.

A more serious problem is that the program will not work with upper memory managers. In order to work, the program would need to save memory management context information during a thread context switch. In the interest of removing this limitation, the author would greatly appreciate the assistance of anyone familiar with the inner-workings of upper memory managers.

Contacting the Author

You may contact John English via e-mail at je@unix.brighton.ac.uk, or via regular mail at the Department of Computing, University of Brighton, Brighton BN2 4GJ, England. If you use this class, please contact the author via the addresses; if you don't have e-mail access please send him a postcard just to let him know you've looked at it. Feel free to suggest enhancements, find bugs, or (better still) fix them and send him patches. Happy hacking!