Victor R. Volkman received a BS in Computer Science from Michigan Technological University. He has been a frequent contributore to The C Users Journal since 1987. He is currently employed as Software Engineer at Cimage Corporation of Ann Arbor, Michigan. He can be reached directly at the HAL 9000 BBS (313) 663-4173 or as vrv@cimage. com on Usenet.
Introduction
Conquerrent C is a complete and compact library for turning ordinary Microsoft C and Turbo C programs into multithreaded applications. Programs linked with the Conquerrent C library can fork up to 256 tasks simultaneously. You may develop programs with it which freely mix preemptable and nonpreemptable tasks. Conquerrent C includes basic semaphore handling and message passing for task synchronization. Conquerrent C lists for $45 and includes a 30-day money-back guarantee.Conquerrent C is designed to help existing programs take advantage of multitasking. Since Conquerrent C adds less than 24K of overhead, the impact on program size is minimal. The library is implemented with only 18 different entry points. The entire list of available functions appears in Figure 1. Conquerrent C works with all standard memory models: Small, Compact, Medium, Large, and Huge.
Conquerrent C will work fine with any CPU from 8088 to 80486. It will run with any version of MS-DOS 2.1 or later. Conquerrent C is shipped standard with both 5-1/4" 360K and 3-1/2" 720K media included. The entire product, including sample programs, requires less than 120K of disk space.
Conquerrent C does provide multitasking within a program, but not concurrent execution of multiple programs. In Conquerrent C, each task represents a thread of execution within the application program. Conquerrent C allows up to 256 of these simultaneous tasks or "threads." Each thread begins its life as the invocation of a function. A thread ends when its function exits or a CloseTask call is issued against it. In the context of this article, I'll be using the terms "task" and "thread" interchangably.
Systems which provide concurrent execution of multiple programs, such as DESQview/386 or OS/2, usually support multithreading as well. With an 80386 or better CPU and sufficient memory, these systems normally allow several virtual DOS windows to be opened. Naturally, these systems require several megabytes of memory before they become very practical. Conquerrent C does not provide additional assistance in opening virtual DOS windows.
Starting New Tasks
The basis of multithreaded execution is the ability to fork new tasks. In Conquerrent C, the OpenTask function prepares a new task for execution and places it in the run queue. An example OpenTask call from my own application:
OpenTask(i+1, task_findroot, STACKSIZE, stacks[i], PREEMPTIVE);The first parameter is the TaskID which will be assigned to the new task. In Conquerrent C, the application assigns its own TaskIDs rather than receiving them from the task switcher. TaskIDs must be between 1 and 256 to conform with the 256-task limit of Conquerrent C.The second parameter to OpenTask is a pointer to a void function which is known as the TaskFunction. A TaskFunction neither accepts nor returns values. Some intertask communications might be simplified if the TaskFunction were allowed to accept a pointer parameter and return a pointer, for example.
The next parameter is the size of the stack which the new task will accept. Determining your stack requirements is important since modules must be compiled with stack checking disabled (/Gs option on MSC).
The next-to-last parameter is a pointer to the actual stack that the newly spawned function will use. If this pointer is null, Conquerrent C will allocate it from the far heap for you. This can be problematic in Large model, as I will comment on later.
The final parameter is the task Attribute which may be either PREEMPTIVE or COOPERATIVE. In preemptive mode, the task will be routinely interrupted by the task switcher. In cooperative mode, the task will retain control until a call to ReleaseTask is made or it terminates. The ability to easily mix preemptable and non-preemptable tasks in the DOS environment is unique to Conquerrent C.
A Conquerrent C application normally prepares several tasks via OpenTask before invoking the preemptive task switcher. Otherwise StartTaskSwitcher, the preemptive task switcher, will find nothing to do and exit. However, once StartTaskSwitcher has been invoked, the active tasks can spawn additional tasks as required. If your entire application operates in a cooperative mode, then you need not invoke StartTaskSwitcher.
To handle preemptive tasks, Conquerrent C switches tasks each time the user-defined timer service routine (INT 1Ch) is invoked. This interrupt is signalled from the system-timer service routine (INT 08h). The system-timer service routine is triggered by a countdown timer in the Intel 8254 Timer chip. The 8254 signals the IRQ0 line in the Intel 8259 interrupt controller. The interrupt rate can be computed by dividing the 8254 clock input frequency (1.19 Mhz) by the pre-programmed timer divisor:
Conquerrent C multitasking could be improved through the use of task priorities. All preemptive tasks running under Conquerrent C are considered equals each task gets one time-slice each time it gets its turn. If Conquerrent C allowed each task to have a user-defined time-slice, then the developer could establish priorities among tasks. A prioritized system (e.g. DESQview) allows distinctions between "foreground" and "background" tasks.
Task Management
Once multitasking has begun, special functions control the behavior and lifetime of the tasks. Since Conquerrent C assumes a peer-to-peer relationship of tasks rather than a parent-child relationship, any task is free to control any other task. A task can perform these operations on itself or other tasks. Since nearly all of these functions require a TaskID, tasks must understand each other to use the functions.First, tasks can be terminated explicitly by CloseTask. Althernately, the entire set of tasks can be aborted by CloseAllTasks. A task which closes itself returns immediately. After all tasks have been closed, the task switcher exits and control returns immediately to point the main program where multitasking first began.
The SuspendTask and ResumeTask functions can be used to temporarily put a task to sleep. Although a task may suspend itself, it must then rely on another task to wake it up. If every currently spawned task becomes suspended, the task switcher exits as if CloseAllTasks had been invoked. Conquerrent C does not provide the ability to suspend a task for a specific time period. This would be useful for a task that you wanted to perform every five minutes, for example.
The CurrentTask, AvailTask, IsTaskOpen, and IsTaskActive functions all report on the current state of a task. CurrentTask tells a task what its own TaskID is. AvailTask returns the lowest TaskID which is eligible for an OpenTask. IsTaskOpen tells whether a thread with the specified TaskID has been opened. IsTaskActive is similar but tells whether the thread is suspended or active.
Last, the TaskAttribute function can be used to change the operating mode (preemptive or cooperative) of a task at any time. A task usually only changes its own task attributes and not those of other tasks. I'll provide a detailed description of TaskAttribute later.
Semaphores
Conquerrent C's most interesting innovation is the way in which semaphores can be used to prevent reentrancy of any function. Even functions for which you don't have source code, such as printf, can be protected. The SemProtect function links re-entrancy of any function to a semaphore. Groups of related non-reentrant functions, such as printf, scanf, and fprintf, can be assigned to the same semaphore to treat them as critical sections. SemProtect provides transparent re-entrancy protection and the functions remain permanently protected for the duration of program execution. Conquerrent C does this by redirecting the target function to call its own semaphore handler. The technique is similar to how debuggers drop breakpoints into code. If the semaphore has already been raised, then thread which called the target function is blocked until the semaphore is cleared.Of course, if your application runs purely in cooperative mode, you will not need to use SemProtect on C runtime library functions. This is because the task switcher will not interrupt a running task in cooperative mode.
SemProtect does have one minor limitation. According to the README file on the distribution disk, the original intent was to allow SemProtect to work on both near-call and far-call functions in the same library. This would help facilitate mixed-model programming. The result is that SemProtect can sometimes mistake near-call functions for far-call functions. However, if you use Large model or explicitly declare your user-defined functions as far-call functions, then you should be safe.
In addition to function-level semaphores, Conquerrent C supports traditional low-level semaphores. The GetSemaphore function blocks the calling task until the requested semaphore ID is available. The ReleaseSemaphore function releases a previously held semaphore ID. The documentation does not specify that tasks queued up by GetSemaphore will be unblocked in FIFO order.
Each semaphore requires 120 bytes. Up to 500 semaphores can be defined at once, which would require a total of 120 * 500 = 60,000 bytes of global data. Both SemProtect and the Get/Release functions share the same set of semaphore IDs. This means you must take care not to use any semaphore IDs in Semprotect that you use in GetSemaphore.
Intertask Messages
Conquerrent C provides a simple message queuing (mailbox) facility. In addition to passing messages, the synchronization option allows tasks to rendezvous. Each task is automatically assigned its own message queue. However, only one message queue is allowed per task. Only pointers to message buffers rather than actual message buffers may be sent. This means that the sending task must preserve the contents of each message buffer until the receiving task has read it. The PostTaskMsg function sends a new message to a message queue and the PendTaskMsg function receives them.The PostTaskMsg function requires three parameters. The first parameter is the TaskID of the message's recipient. The next parameter is a far void pointer to message buffer being sent. The last parameter is the operation code. If the operation code is MSG_NOSYNC, then the task will continue executing after posting the message. If the operation code is MSG_SYNC, then the task will be blocked until the recipient rendezvous with its corresponding PendTaskMsg. There is no timeout option for the MSG_SYNC rendezvous.
The PendTaskMsg call is simpler because the only parameter is the operation code. This implies that tasks can only read from their assigned message queue and nobody else's. If the operation code is MSG_NOSYNC and no message is available, then PendTaskMsg immediately returns a null pointer. If the operation code is MSG_SYNC, the task remains block until it rendezvous with the sender's PostTaskMsg.
Message queues are limited to eight message-buffer pointers. If more than eight messages are pending in a given queue, then subsequent messages to that queue will not be delivered. Removing this arbitrary limitation would make consumer/producer types of tasks, which rely heavily on messaging, easier to implement.
Documentation And Support
The documentation consists of a 100-page spiral-bound book which serves as both tutorial and reference. The 25 pages of tutorial take the reader briskly but thoroughly through the nomenclature, pitfalls, and payoffs of multithreaded execution. The author correctly explains that concurrency does not maximize program speed, but rather minimizes idle time wasted by the CPU. Numerous examples and code fragments succinctly illustrate the author's arguments.The reference section describes each function including syntax, parameters, return values, and code fragments. Generally, each code fragment describes a nearly complete program. The order in which the reference presents functions is a bit unpredictable. The author's groupings of related functions together makes it more accessible for the first-time reader. However, its more difficult to refer to the functions later since they are not lexically ordered.
Both the tutorial and reference sections are devoid of graphic illustrations. Such illustrations could help diagram such things as task switching, semaphores, and critical sections. The manual could also be improved by adding a formal glossary of parallel programming and multitasking nomenclature. For completeness, the manual should also have an index and a suggested background reading list.
Conquerrent C includes two short applications which visually demonstrate the workings of the multitasking switcher. HEADS is a program which simply starts 16 ASCII heads ("smiley-faces") careening around the screen. Each moving head is spawned as a separate task. The smoothness of the animation demonstrates the efficiency of the task switcher.
SNAKES, the other application, is an arcade-style game in which your character must avoid colliding with randomly moving snakes. Each successive game level adds another snake until 16 snakes are active. Unfortunately, neither application demonstrates semaphores or messages. Additional programs combining multitasking, semaphores, and messages effectively would be very helpful for less experienced developers.
Technical support for Conquerrent C is primarily available via Compuserve e-mail addressed to EASYPLEX id [76216,2376]. Users must mail in their registration cards before they are eligible for technical support. Direct telephone contact is discouraged as evidenced by the complete absence of telephone numbers in the documentation, software, advertisements, and even the phone book.
Root-Finder Revisited
Multitasking systems are especially suited for simulating parallel-processing problems. Each concurrent task can pretend that it has its own dedicated CPU. One problem that I have previously investigated with parallel processing simulators is that of approximating the roots of a function (see bibliography). The roots of the function f(x) are those values of x such that f(x) = 0. In a multiple-CPU sytem, each CPU can be given an equal partition of the range in which the root lies. If you ignore the communications overhead, the potential speed of solving increases linearly with the number of CPUs involved. My most recent implementation of the parallel root-finder was in conjunction with the DESQview Applications Programming Interface (API) C Library. I ported the root-finder to the Conquerrent C library to provide a basis for comparison.My development system for this exercise consisted of a 20Mhz 80386 computer with 4MB RAM under MS-DOS 4.01. Applications built with Conquerrent C worked fine in both a DESQview/386 DOS window and a Microsoft Windows 3.0 DOS window. I built the application with Microsoft C 5.1 and LINK v3.65. The target executable was 32K in size when built with Conquerrent C in the Large model. Its predecessor was 24K in size when built with the DESQview API in the same model.
While porting the root-finder, I quickly discovered that each Conquerrent C function had a directly equivalent call in the DESQview API. The similarity of the overall multitasking interface simplified the job immensely. Figure 2 lists the most important functions in each interface. The DESQview API actually includes much more than just multitasking: e.g. windowing, mouse support, timers, and dialog-box handling. The most important distinction between the two products is that the DESQview API supports concurrent execution of multiple programs as well as multiple tasks within a single program. The Conquerrent C package supports only multiple tasks within a single program.
I encountered only two minor problems in the course of conversion. First, the OpenTask function lets you choose passing in the address of the stack to be used for the new task or allowing Conquerrent C allocate the stack off the far heap. My first attempt was using the latter scheme to allocate the stack. The manual warns against using the far heap for the stack in Large model applications because it claims some third-party libraries require SS and DS to be equal (p. 34). I have seen this behavior in the MSC runtime library itself before so I was not surprised to see it in this context. Specifically, the compiled code was unable to properly address data structures residing in another data segment. The alternate method, passing in the address of a local variable for the stack of the new task, proved to an effective solution.
The other problem I encountered was also documented in the manual. The manual warns that if you have several tasks performing floating-point expressions, you will need to treat each expression as a critical section. A critical section is a fragment of code that accesses global resources and must be executed without interruption to avoid potential corruption of the resources by competing tasks. In Conquerrent C, this consists of the following sequence:
TaskAttribute(CurrentTask(),COOPERATIVE); /* your code here */ TaskAttribute(CurrentTask(),PREEMPTIVE);Since the MSC compiler uses global static buffers for the temporary operands of floating-point expressions, conflicts will occur if more than one task is executing an expression simultaneously. I received many messages from the MSC runtime library reporting "floating-point error: stack underflow" and "floating-point error: stack overflow." Presumably, one task performed only part of its expression evaluation before the scheduler switched tasks. Following the manual's guidelines, I eventually isolated all floating-point critical sections and properly protected them. I am still curious as to why the DESQview API implementation of the root-finder seems immune to this requirement.
Concurrent Debugging
Debugging concurrent programs is always challenging. For example, simply adding a printf call to examine the contents of a variable can bump the clock enough to change the order of execution and bugs which are related to it. This phenomenon is more likely to occur in preemptive multitasking than in cooperative multitasking. It is equally inherent in any multitasking system. The Conquerrent C library does not contain any debugging aids for tracing or breakpointing concurrent threads.However, I did discover one debugging technique that may be helpful. Since StartTaskSwitcher must wait for all tasks to complete before continuing, I created a "breakout task." The breakout task, started concurrently with the rest, simply waits for a keypress and then shuts down the remaining tasks. If your tasks require keyboard input, then you could set up the breakout task to poll the joystick fire button, mouse button, or other device. Of course, you can still get into trouble if one of your tasks gets stuck while in non-preemptive mode.
void breakout (void) { while ( !kbhit() ) ; CloseAllTasks(); printf("breakout!\n"); }Making sure your application returns correctly to StartTaskSwitcher is very important. If you leave directly via CTL-C, longjmp, exit, or a critical error (INT 24h) and return to the DOS prompt, then you will find yourself needing an immediate reboot. This is because the scheduler is still dispatching tasks every 1/18th second.
Conclusion
Conquerrent C provides a simple and effective interface for writing multithreaded C programs in the DOS environment. It provides all the requisite task management, semaphore, and message passing support. It is reasonably priced and includes a money-back guarantee. If you need multitasking internal to your programs, then Conquerrent C is an excellent choice.
Bibiography
Hwang, Kai, and Briggs, Fay, Computer Architecture and Parallel Processing; New York, NY. McGraw-Hill Inc. 1984. pp. 570-577 a textbook that includes many dissections of parallel computer and supercomputer architectures. It provides a thorough survey of memory, I/O, pipelining, vector processing, array processors, and dataflow computers. Software techniques, such as semaphores and monitors, for effectively utilizing parallel processing are emphasized.Volkman, Victor R., "Multitasking with the DESQview API C Library," The C Users Journal, July 1990, Vol. 8, No. 7, pp. 99-109 This article introduces FINDROOT, a simulation of a parallel-processing program to determine the roots of an equation. The underlying technique is to partition the solution space among CPUs.
In Summary
Operating Environment: plain DOS
Version/Release Number: 2.01
Single copy: $45 (30-day moneyback guarantee)
Address: Interstitial Software Systems, 321 East Buckingham Road, Suite #140, Garland, TX 75040
Contact: Brian Wells, President
Phone: (214) 669-5781
E-mail: CompuServe EASYPLEX [76216,2376]