User Reports


Convert C Programs into Multithreaded Applications

Comments by 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 directly at the HAL 9000 BBS (313) 663-4173 or as sysop@hal9k. ann-arbor.mi .us on Usenet.

CTask, CUG 330, is a complete and compact library for converting ordinary C programs into multithreaded applications for DOS. Specifically, it provides prioritized, preemptive task switching along with extensive support for semaphores, pipes, mailboxes, I/O, and more. The CTask library was written and released into the public domain by Thomas Wagner (Berlin, Germany). CTask is distributed as freeware without any registration fees or royalties.

CTask minimally requires Microsoft C 5.1 or Borland Turbo C 2.0. If you want to recompile the source, you will also need Microsoft MASM 5.1 or Turbo TASM 1.01 macro assemblers. The source files compiled flawlessly with Microsoft C 6.00A using warning level 3 (/W3). Porting CTask to other ANSI compliant compilers is possible, but only experienced developers should try this. Since roughly half of the 370K of source code is in assembler, further porting could be a daunting task.

CTask comes with a model-independent library ready for use with small, compact, medium, and large model programs. If you need support for tiny or huge models, then you must recompile the library. Wagner claims CTask adds 14K to 25K of additional overhead to your application. However, when I built the supplied test program with MSC 6.00A, I noticed an increase of about 30K.

CTask will work fine with any CPU from 8088 to 80486. But it does not take advantage of advanced CPU modes (e.g., protected mode) nor built-in multitasking instructions (e.g., task register). CTask also manages contention between multiple tasks for use of the Intel 80x87 compatible numeric data processors (NDPs). Applications using Weitek or other non-Intel compatible NDPs must supply their own context switching functions.

Wagner has tested CTask with MS-DOS versions 3.20, 3.30, 4.00, and 4.01. Since the CTask v2.2 was released before MS-DOS 5.00 and DR-DOS 6.0, the documentation accordingly excludes guarantees of compatability in those environments. However, I found CTask to work just fine under MS-DOS 5.00 with a 20MHz 80386 CPU.

The documentation also indicates that CTask applications will work with Windows 3.0 provided the application does not TSR at any time. But non-TSR CTask applications will work just fine if loaded into an MS-DOS Window in 386 Enhanced Mode. CTask applications work equally well in Windows 3.0 and 3.1.

I received CTask on three uncompressed 5 1/4 inch 360K diskettes from the C Users Group. They can also supply 3 1/2 inch 720K media at no extra charge. There are no instructions for installation, but you can simply copy all the disks into a single directory to get started. The entire product, including source code and documentation, requires about 1MB of disk space. You will need a total of 2MB free if you plan to recompile the libraries.

CTask does provide multitasking within a program, but not concurrent execution of multiple programs at the command line level. In CTask, each task represents a thread of execution within the application program. CTask allows an unlimited number 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 kill_task is issued against it. In the context of this discussion, I'll be using the terms task and thread interchangably.

Systems that 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 MS-DOS windows to be opened. Naturally, these systems require several megabytes of memory before they become very practical. CTask does not provide additional assistance in opening virtual MS-DOS windows.

CTask provides multitasking functionality highly similar to that provided by Conquerrent C, by Interstitial Software Systems (Garland, TX). Additionally, it also matches a subset of the DESQview API for C by Quarter-deck Office Systems (Santa Monica, CA). CTask is also similar to the DIVVY non-preemptive multitasker by Drumlin (Glendale, CA). (See the accompanying bibliography for information on all these products.) The multithreading functions of these products are surprisingly similar (see Figure 1) .

In the remainder of this discussion, I'll present how to initialize the system, create and manage tasks, control task priorities, manage critical sections with semaphores, communicate between tasks, multitask serial I/O, and debug with snapshots.

Initializing CTask

Before you can create any tasks or use any part of CTask, you must first initialize the multitasking kernel. The install_tasker function starts up the multitasker and the remove_tasker function shuts it down. The function calling install_tasker becomes the main task and each thread started by it belongs in its task group. An example calling sequence is:

install_tasker (FALSE, 4,
 IFL_DISK|IFL_PRINTER, "FindRoot");
The first parameter is a boolean value which tells whether tasks will have variable priorities. If variable priorities are enabled, then any priority level can be assigned to any task. Disabling variable priorities makes the task switching process simpler and more efficient.

The next parameter is the system timer speedup factor (see Figure 2) . All preemptive multitaskers (e.g. DESQView/386) can only switch tasks as often as the system timer interrupts. The timer tick is the smallest time-slice that PC multitaskers can handle. The frequency of the system timer is determined by dividing the 1.193 MHz clock signal by the pre-programmed divisor value of 65,536:

The system timer tick frequency, fixed in the BIOS of the original IBM PC more than a decade ago, can be safely increased on today's faster CPUs. The value of 18.2065 interrupts/second was originally designed to minimize the overhead of timer interrupt service routines on the struggling 4.77MHz 8088 CPU. Of course, speeding up the timer tick frequency does not increase the CPU speed. Rather, it simply increases the number of opportunities for task switching.

CTask can reprogram the Intel 8254 Timer chip to run the system timer at 2, 4, 8, or 16 times its standard frequency. I was able to run my application at 16 times the standard task switch rate on a 20MHz 80386 computer. At this setting, I noticed that the performance of the CTask test program had more than doubled. Performance increases will be the most significant for applications that perform the most intertask communication and rendezvous. CTask time-slices are always exactly one tick in duration. Although DESQview/386 cannot adjust the system timer tick frequency, it can assign a variable number of time-slices per task. For more information about programming the 8254 Timer, see Allison (1992).

The third parameter of install_tasker is the set of CTask installation flags. The installation flags mainly tell which BIOS and DOS interrupts to guard with semaphores. Since these interrupts are notoriously non-reentrant, this extra protection insures integrity. The flags IFL_VIDEO, IFL_DISK, and IFL_PRINTER protect interrupts 10h, 13h, and 17h respectively. Additionally, IFL_PRINTER prevents tasks from "busy-waits" on the printer by idling them while the printer is busy. CTask always guards the keyboard handler and timer (interrupts 16h and 08h respectively).

The last parameter to install_tasker is an optional ASCII string naming the task group.

Starting New Tasks

The basis of multithreaded execution is the ability to fork new tasks. In CTask, the create_task function prepares a new task for execution and places it in the stopped state. The stopped state implies that the task has been suspended until reactivated later by start_task. An example calling sequence for create_task is:

create_task (&tcb3, task3, stack3,
           STACKSIZE, PRI_STD,
           NULL, "TASK3");
The first parameter is a pointer to a task control block (TCB). CTask stores all task related information into its TCB (see
Listing 1) . You may specify NULL for this parameter and CTask will allocate the TCB for you.

The next parameter is the function to start as a new thread of execution. This function must be declared as a void far function which accepts a far pointer argument, as in:

void far task3 (char far *my_job)
The third parameter is a pointer to the stack which the new execution thread will use. CTask will allocate a task stack for you if you leave this parameter NULL. The task stack must reside in the stack area of your main program if the task calls any of the C runtime library functions. This is because the functions in the C runtime library have been compiled with stack checking enabled. If you have a full copy of the runtime library source for your compiler, you can presumably bypass this limitation.

The next parameter is the size of the stack passed to the thread. Determining your stack requirements is important since modules must be compiled with stack checking disabled (/Gs option on MSC).

The fifth parameter is the priority level assigned to the newly created thread. The CTask library will supports 65,535 different priority levels. The constant PRI_STD sets up a standard task without special prioritization needs.

The next parameter is a far char pointer to an argument that the thread execution function will receive. This construct spares the need for a separate initialization message from the main task. When the same function is spawned for several different tasks, this helps the task identify itself.

Last, the thread can receive an optional name string as a parameter. This string can be used for identification purposes or as a debugging aid. The Ctask "snapshot" utilities take advantage of this string to provide human-readable reports of task activity. I'll discuss the snapshot utilities later.

The create_task function also examines the boolean variable tsk_use_ndp each time to see whether 80x87 support is required for this task. If TRUE, then the scheduler will save and restore an image of the 80x87 registers each time this task is swapped. If some of your tasks do not need the 80x87, then you can increase task swapping efficiency by selectively omitting 80x87 support.

Task Management

Once multitasking has begun, special functions control the behavior and lifetime of the tasks. Since CTask assumes a peer-to-peer relationship rather than a strict parent-child relationship, any task is free to control any other task. A task can also control its own operational abilities. Specifically, the task management functions return task information, change task states, and establish save and restore functions.

Since all task management functions require a TCB pointer, your application should maintain TCB pointers in a common area. Alternately, you can search for the TCB with find_name and the ASCII string assigned to the task. Since find_name simply performs a linear search, the efficiency of your application may suffer. You can disable object naming and thus eliminate its overhead completely by changing TSKCONF.H as follows:

#define TSK_NAMEPAR      FALSE
In some cases you find out about a task by examining the TCB (see
Listing 1) ; in other cases you call a status function. For example, the state field describes the current condition of the task: ST_KILLED, ST_STOPPED, ST_DELAYED, ST_WAITING, ST_ELIGIBLE, or ST_RUNNING. The relationships between all states, except ST_KILLED, are depicted in Figure 3.

The flag bitmask of the TCB contains F_CRIT (0x01) if the task has disabled preemption and F_USES_NDP (0x02) if 80x87 context switching is required. Since priority is not part of the TCB structure, you must call get_priority to discover the base priority of a task.

The documentation specifically advises against explicitly modifying the contents of a TCB. Rather, you must use the function calls so CTask can maintain internal consistency. Accordingly you would use the set_priority function to modify the base priority of a task. Similarly, you would call set_task_flags to change the F_CRIT or F_USES_NDP flags. CTask also provides one user-definable pointer per TCB with the set_user_ptr function. Most often, applications will set this pointer to point to a unique block of data for each thread.

Tasks can be terminated explictly by the kill_task function. Alternately, a task can simply return immediately to terminate itself. However, CTask does not free any resources still allocated at the time of termination. Thus, Wagner recommends kill_task only for fatal-error handling. Furthermore, if the task was executing a DOS function, then killing it explicitly may cause DOS to become unstable. The best practice is to allow tasks to terminate themselves after receiving a special intertask message. After all tasks have been closed, control returns immediately to the point in the main program where multitasking first began.

The stop_task function suspends a task for an indefinite period of time. Although a task may suspend itself, it must then rely on another task to call start_task. If all existing tasks are in the stopped state, the scheduler loops with interrupts enabled patiently waiting for a task to be restarted. The t_delay function allows a task to put itself to sleep in a delayed state for a specified period. A sleeping task in the delayed state can be resumed by another task calling wake_task. The wake_task function also resumes a task waiting on any other event as well.

Additionally, CTask allows you to register save and restore functions via the set_funcs call. The save function is called just before a task is deactivated and the restore function is called just before scheduling it. These functions are ideally suited for maintaining the context of shared hardware resources such as non-80x87 NDPs, VGA registers, or extended CPU registers. CTask automatically maintains context of EMS page maps during task switching unless specifically disabled.

Priority Scheduling

As mentioned earlier, you can individually set the priority of each task in the system. Since CTask identifies priorities with an unsigned 16-bit word, there are 65,535 different possible values. The base priority of each task is established when it is spawned by the create_task function. The base priority can be modified at any time via set_priority.

The CTask scheduling algorithm always picks the highest priority eligible task as the next task to run. CTask uses a priority "aging" mechanism to prevent starvation of low-priority processes. Accordingly, each time a process is not picked as the next task to run, its current priority gets incremented. Thus, even the lowest priority task will eventually rise to the top and get its time-slice. After a task finishes its time-slice, its priority is reset back to the base priority and it is inserted back into the priority queue.

The highest priority task in the system is normally the timer task; its priority defaults to F000h. The install_tasker function assigns the next highest priority to the "main task." This allows the main task to process all initializations before the other tasks start running very far. For successful operation, the main task must eventuallly have its priority reduced or be put in a waiting state. Otherwise, the newly spawned tasks would not get much chance to accomplish any work.

Multitasking Resources

CTask defines a resource as an object which only one task can possess at a time (i.e. mutually exclusive). Thus, a resource may be used to protect a critical section of code. A critical section is a fragment of code which accesses shared program resources and must be executed without interruption. If a critical section were interrupted, it would face potential corruption of the resources by competing tasks. The "semaphore" is a special type of variable used to track mutual exclusion of processes. Specifically, CTask provides two different constructs called "flags" and "counters" for managing mutual exclusion.

A CTask flag is a boolean semaphore that can be accessed by any task or even an interrupt handler. The create_flag function instantiates the flag and sets it to zero. As with all other CTask objects, flags may be optionally named with an ASCII string. The delete_flag function destroys the flag and kills any threads still waiting for it. The wait_flag_set and wait_flag_clear functions suspend the calling task until the condition is fulfilled or timeout occurs. When the flag changes state, all tasks that were waiting are simultaneously restarted. If you simply want to find out the current state of the flag, you can call the non-blocking check_flag function. Effectively, the Ctask flag acts as a semaphore for a bunch of tasks rather than just a single task.

A Ctask counter resembles the traditional counted semaphore more closely than the flag resource does. Flags may contain only boolean values, but counters can contain any value from 0 to 232 — 1. The create_counter function instantiates the counter and sets it to zero. Similarly, the delete_flag function destroys the flag and kills any threads still waiting for it. The semantics of waiting for a counter are different than waiting for a flag.

The wait_counter_set and wait_counter_clear functions suspend the calling task until the condition is fulfilled or timeout occurs. When waiting for a zero (clear) condition, all tasks will be reactivated upon completion. However, when waiting for a non-zero (set) condition, only the first task will be reactivated. Additionally, the counter is simultaneously decremented as the task is restarted. Normally, the counter changes state only when inc_counter or dec_counter are called. The clear_counter and set_counter functions let your application override the state progression at any time. Last, the check_counter function returns the current state of any counter.

Intertask Messages

Although semaphores can be used to send simple messages between tasks, higher-level constructs are better suited to message passing. Specifically, CTask provides mailboxes, pipes, and buffers for intertask communications. Mailboxes work best with large, unbuffered blocks. Pipes are ideal for byte or word (16-bit) message streams. Buffers are specifically designed to handle variable-length message strings. As with other CTask constructs, each instance of a mailbox, pipe, or buffer can optionally be named with an ASCII string.

CTask mailboxes, created with the create_mailbox function, can hold an unlimited number of messages. Although a mail message is just an unstructured block of bytes, you must reserve the first four bytes for the CTask kernel. Furthermore, Wagner recommends using a standard size message block to simplify your free block management routines. Mailbox messages are unbuffered but guaranteed to arrive in FIFO order. Accordingly, the receiver should be expected to either free or re-use the incoming message block. Similarly, the sender should refrain from modifying the block after calling send_mail.

The receiver can call wait_mail to wait in a suspended state until mail arrives or a specified timeout occurs. Interrupt handlers are not allowed to use wait_mail. Alternately, c_wait_mail reads mail only if available rather than blocking the task. The check_mail function returns a boolean value corresponding to mail availability. When multiple tasks are waiting for the same mailbox, the task with the highest priority always gets it. Last, delete_mail destroys the mailbox and any remaining tasks waiting for its mail.

CTask pipes, unlike mailboxes, are fully buffered intertask communication constructs. Pipes created with create_pipe hold byte-sized objects while pipes created with create_wpipe hold word-sized objects. The pipe buffer and pipe size (in bytes) are two of the parameters of these functions. Unlike the mailbox, system information is stored in a separate pipe control block data structure rather than inside the actual message buffer.

Reading and writing from pipes is analogous to the fgetc and fputc functions in the C runtime library. A task can call write_pipe without being blocked as long as the pipe is not full. Alternately, the c_write_pipe function puts data in the pipe if it is not currently full. You can find out how much space is left in the pipe by calling pipe_free. Tasks may use read_pipe or c_read_pipe to get data out of the pipe. The former blocks until read or a timeout occurs and the latter reads only if data is curently available. You can invalidate the remaining contents of the pipe with flush_pipe. Last, the delete_pipe function destroys the pipe and any remaining tasks waiting for it.

CTask buffers are actually implemented with pipes and semaphores, thus making them the highest level of intertask communication. Buffers are meant to streamline the handling of variable-length message strings. The documentation does not specify whether or not these are meant to be zero-terminated (ASCIZ) strings. The total size of the string buffer is one of the parameters to create_buffer. Tasks calling write_buffer will be blocked or suffer timeouts if insufficient space remains to fit the entire string. Alternately, the c_write_buffer function will return immediately if unable to write the whole string. Similarly, read_buffer and c_read_buffer allow blocked and non-blocked fetching from the buffer. The reading functions also have maximum size parameters to prevent overrun. You can call check_buffer to find the total amount of bytes remaining in the buffer. Last, the delete_buffer function destroys the buffer and kills any remaining tasks waiting for it.

Serial Communication

In addition to a full complement of multitasking, semaphore, and intertask communications functions, CTask also provides a complete serial communications library. The serial library is implemented with CTask pipes for interrupt-driven transmit and receive buffers. Multitasking also allows CTask to simultaneously handle I/O with multiple COM ports. Further, the serial library supports non-standard COM ports, XON-XOFF and RTS-CTS handshaking, and baud rates up to 38,400 baud.

CTask does not provide support for the extra buffering capabilites of the NS16550AFN UART chips. The 16550 family chips provide 16-byte FIFO transmit and receive buffers as compared to the single-byte buffer of the older 8250 and 16450 chips. Since CTask v2.2 was released before the 16550 came into widespread use, this omission is not surprising.

I traced the 38,400 baud limitation to a static array of baud rates and precomputed baud rate divisors. The baud_rate[] table should be extensible to accommodate 57,600 baud (divisor==2) and 115,200 baud (divisor==1). However, these rates were probably excluded because the code might not be efficient enough to run without data loss on PCs with 8088 CPUs.

Before any serial function can be used, the handler must be installed by calling v24_install. An example invocation of v24_install is:

siop = v24_install(COM1, TRUE, rcvbuf,\\
                sizeof(rcvbuf), xmtbuf, sizeof(xmtbuf));
The first parameter specifies the COM port number for which the handler will be installed. If you OR the port number with 0x80, then CTask will pull the serial port address out of the BIOS table at 0040:0000h. The v24_install function must be called once for each COM port that will be active simultaneously. If you need to access other non-standard serial ports beyond COM2, then you must first register their base addresses and IRQ numbers with v24_define_port. The second parameter is a Boolean variable telling whether CTask should initialize the UART Modem Control Registers and Line Control Registers. If TRUE, then CTask will set the port for 1,200 baud 8-N-1 operation. The remaining parameters specify the location and size of the receive and transmit buffers. In practice, these buffers are used by the pipes and should not be addressed directly by applications.

CTask provides a complete set of port control functions. These functions manually set DTR, RTS, baud rate, parity, word length, and number of stop bits. CTask also lets you set the flow control parameters governing the receive and transmit buffers. Specifically, the v24_protocol function sets XON-XOFF and/or RTS-CTS handshaking as desired. Additionally, it sets the minimum threshold number of characters in the receive buffer before flow-off is asserted and the minimum number of characters before flow-on is reasserted.

Once properly initialized, receiving and transmitting characters is very easy. The v24_send function sends a single character with a timeout parameter. The v24_receive function waits for an incoming character or a timeout, whichever comes first. For polling, v24_check tells whether any characters are in the receive buffer and v24_complete tells whether any characters are in the transmit buffer.

When you are done with an individual serial port, you can deinstall the handler by calling v24_remove. Alternately, you can remove all serial handlers simultaneously by calling v24_remove_all.

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 the behavior of bugs which are related to it. This phenomenon is more likely to occur in preemptive multi-tasking than in cooperative multitasking, and it is equally inherent in any preemptive multitasking system. The CTask library does not contain any debugging aids for breakpointing concurrent threads. Ctask does, however, include a helpful snapshot utility.

The CTask snapshot utility provides a quick overview of all of the tasks and resources in the system (see Figure 4) . For each task, the display shows its name, current state (Stop, Run, Ellig, Wait, or Delay), stack top and current stack pointer, current priority, and more. For each resource, the display shows its name, resource type (counter, flag, etc.), which task it is waiting for, and the task to which it currently belongs. The snapshot is fastest and most accurate when dumped directly to the hardware display buffer with screensnap. Alternately, you can force it to be written to the screen through INT 10h with csnapshot. Finally, you can dump it to a stream file with snapshot. I produced Figure 4 directly with one call to snapshot.

Documentation and Support

The CTask documentation consists primarily of a 120-page manual which occupies nearly an entire 5 1/4 inch 360K diskette. The manual is formatted to 58 lines per page with form feeds and should print correctly on any printer. The documentation audience seems to be C programmers with an intermediate knowledge of DOS internals. Those with less knowledge may not fully comprehend the subtleties of CTask's highly flexible configuration options. However, the novice should have good success by staying with the default parameters. In the Advanced Topics chapter, the author covers how system timer and interrupt controller chips interact with the multitasking environment.

The manual is divided into two roughly equal parts of tutorial and reference materials. The tutorial section correctly assumes the reader has no prior experience with writing multi-tasking software. The second half covers the support functions grouped by categories (e.g. task management). The manual finishes with a detailed revision history going back to the first release and an index of all function names. The manual could be improved by the addition of code fragments, sample programs, and more figures. Also, since CTask includes full source-code, the source filename in which each function actually resides should be included as part of its description. For example, I had to resort to grep to find out that read_buffer is implemented in TSKBUF.C. The overall documentation quality is comparable to that supplied with commercial multi-taskers (see bibliography).

Since CTask is freeware, without any remuneration to the author, one might expect a dearth of support. However, Wagner promises to "try his best to eliminate any bugs reported ... and to incorporate suggested enhancements and changes." Although he can be reached directly in Germany, he prefers contact through electronic mail. Specifically, he moderates the ibm.other conference on BYTE magazine's BIX online service. You can leave comments and questions under the topic ctask in that conference. Additionally, Wagner pledges that new versions of CTask will always appear first on BIX. He also includes detailed BIX signup information for first time users. For those inclined to use Internet, he can be reached via a UUCP mailing address as well.

Conclusion

CTask offers 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. CTask admirably handles I/O and includes a complete low-level serial communications library. It is public domain and has no licensing restrictions whatsoever. If you need multitasking internal to your programs, then CTask is an excellent choice.

Bibliography

Allison, Charles B. April 1992. "Fast Times with the PC Clock." Windows/DOS Developer's Journal. Vol. 3, No. 4. pp. 37-48.

Volkman, Victor R. July 1990. "Multitasking with the DESQview API C Library." The C Users Journal. Vol. 8, No. 7. pp. 99-109.

Volkman, Victor R. December 1990 "DIVVY Multitasking Library." The C Users Journal. Vol. 8, No. 12. p. 100.

Volkman, Victor R. October 1991. "Conquer Multitasking with Conquerrent C." The C Users Journal. Vol. 9, No. 10. pp. 64-70.