Michael Kelly is a member of the staff of Computer Task Group. His current assignment is at IBM in Boca Raton Florida, where he deals with OS/2-related issues.
MS-DOS programmers who experiment with multithreading usually develop a keen desire for reentrant libraries in short order. I have used third-party C++ classes and C libraries to write multithreading programs in MS-DOS. The classes and/or libraries usually worked well, but I could not call the C standard library I/O functions (e.g. printf) from multiple threads, because my compiler provided only non-reentrant versions of these functions. Moving from MS-DOS to OS/2 solves two problems at once with respect to multithreading. Not only does OS/2 support multiple threads (elimating the need for third-party libraries) but compilers for OS/2 are more likely to provide reentrant I/O libraries. For instance, the Borland C++ compiler for OS/2 2.x comes with reentrant libraries. Having reentrant libraries available speeds development enormously since you don't have to write your own assembly language functions to do I/O.
This article shows an example of a multithreading utility using Borland's reentrant library functions, and OS/2 System API calls. The utility is a file filter that can filter multiple files simultaneously. It uses C++ classes to provide a convenient interface to the threads that do the actual file I/O and filtering.
Each filter class consists of a producer thread and a consumer thread linked together using an OS/2 System Queue and OS/2 System Semaphores. The OS/2 System Queue allows the producer thread to add filtered blocks of data to the end of the queue while the consumer thread de-queues the data and writes it to the output file. The filter class uses the OS/2 System Semaphores to signal when the filtering is complete.
Multithreading Design IssuesA person writing multithreaded programs must solve several problems not encountered in simple, single-threaded programs. One of these problems is controlling access to shared data. Since a thread is a "light-weight process" it may share variables with other threads in the same program. The program must ensure that only one thread has access to shared data during a particular time interval. (That is, the program must serialize access to data.) My FileFilter class must manage shared data in two places.
The first place is in access to file data. In this program a producer thread reads a block of data from an input file, filters it, and passes it along to the consumer thread, which writes the filtered data to the output file. I use an OS/2 System Queue between the producer thread and the consumer thread to ensure that the data is handled cleanly. (The System Queue uses its own semaphore to control access to its data.) To serialize file data access, my FileFilter class dynamically allocates and frees each block of file data as it is used. All this dynamic allocation may seem to require a lot of overhead, but it is negligible compared to the disk I/O involved. Dynamic allocation helps prevent simultaneous access to data, which may occur if static data buffers are used.
The second place requiring serialization is in a class variable named blocks2write. This variable is initialized by the FileFilter constructor; after that the only thing that touches the variable is a consumer thread,which decrements it on each write until it reaches 0. The variable is protected and, since the class is written with this restriction in mind, it is not associated with a semaphore.
Another problem encountered in multithreaded programming is that of adequately coordinating execution between threads. This coordination is required because of multithreading's non-sequential nature. For example, I may create a FileFilter instance to filter a large file, then create another instance to filter a small file. The second instance will usually complete before the first. The main part of the program that launched the threads must not be allowed to drop through to the end of the program while the operations are incomplete. The OS/2 System provides an Event Semaphore for this purpose. To use this semaphore, I have provided a class member function, wait4completion. This function takes a single parameter of type ULONG named max_wait. The function passes this value to the OS/2 API DosWaitEventSem. DosWaitEventSem interprets two values of max_wait as "magic cookies." A value of 0 means to poll the semaphore once to see if it has completed and return. A value equivalent to -1L (0xffffffff) means to wait forever. DosWaitEventSem interprets any other values as an unsigned long designating the number of milliseconds to wait on the semaphore. If only two or three instances of the FileFiIter class are running concurrently, a program could include a simple conditional statement to wait for them all to complete and handle the return codes. A more robust design would probably incorporate the waiting and error checking in a function with time-out values for each instance to avoid hanging the application.
A problem related to multithreading is obtaining optimum performance from the particular platform in use while preserving as much portability as possible. In this example, I wanted to use dynamic memory allocation, and also take advantage of OS/2 2.x's demand paging plus the hardware support available in 386 and higher CPUs. Therefore, I've made the producer thread use a default block size of 4,096 to match 4KB pages issued by the system when an allocation of 4KB or smaller is requested. The consumer thread frees memory blocks after it has dequeued and processed them to the output file. (One hazard of the 4KB page memory allocation is that programs written for 286 protected modes, such as found in OS/2 1.x, sometimes neglect to free allocated memory after usage, especially if the memory blocks are very small. These programs can run out of virtual memory quickly when run on an OS/2 2.x platform.) Freeing dynamic RAM as soon as practicable increases efficiency since the memory will be available for reallocation instead of paging to the system swap file, swapper.dat.
To facilitate error checking, I have provided the valid member function. valid returns a nonzero integer value if the constructor completed successfully. If valid should return zero, the culprit is probably the call to fopen. The companion member function error_code returns a ULONG with the error code last stored in the filter_error member variable. Building on this structure you can bullet-proof your enhanced implementation of the FileFilter.
Advantages of Using Multiple ThreadsIn the previous section I've discussed some of the difficulties of using multiple threads; it's time I pointed out some of the positive aspects. I take advantage of several of these in the file filtering utility. One advantage is that applying multithreading to the producer/consumer paradigm helps the application do productive work when it would otherwise be kept waiting. You can demonstrate this with the file filter utility by reading from a fixed disk and writing to a floppy drive. In a purely sequential implementation the producer thread would be waiting for the consumer thread, which is tied to the slow floppy drive. By contrast, the multithreaded implementatation queues up the producer's filtered data in virtual memory while waiting to write blocks to the floppy drive.
Another advantage of multithreading — even over multi-processing — is that it simplifies the creation of parallel processes. Creating another instance of FileFilter is faster and puts less of a load on system resources than creating another full-fledged process, such as you might do with a call to fork or spawn.
Though not an advantage of multithreading per se, a feature that makes it more practical is the synchronization provided by OS/2. The OS/2 System Queue and System Semaphore APIs enable you to keep the code clean and concise. Indeed one of the problems I encountered when designing my file filter utility is that I kept trying to complicate things unnecessarily. In my first attempts to come up with a working design, I was confusing the issue by trying to use indexes into multiple buffers and other similar techniques. I realized that the best design for my purpose was much easier to implement than that, once I determined to keep the algorithm simple. That is when I decided to allocate blocks of memory as required and keep the data management simple. Once I stripped away the non-essential I discovered that using the OS/2 APIs made the rest of the implementation an enjoyable exercise, and resulted in a clean design.
Running the ApplicationListing 1 shows the FileFilter class definition; Listing 2 shows its implementation. I've written the filter function itself in assembly language (Listing 3) . Listing 4 is a header file for the filter function, while Listing 5 provides a dummy C++ wrapper to "name-mangle" the function name. You need Borland C++ v.1.0 or higher for OS/2 2.x to assemble, compile, and link the sample application. Listing 6 shows the test stub (gofilter) for the FileFilter class; A .prj file required by the Borland IDE is included on this month's code disk. If you don't wish to prepare UNIX-style ASCII files to use with gofilter, you may use ordinary IBM PC text files. If you look at the output files with a binary editor you will see that each line will have an extra carriage-return inserted directly before the line-feeds for each line. I used this UNIX-to-PC filter for the test program because it was easy to implement in assembly language and I wished to try out the 32-bit TASM that comes with Borland C++ for OS/2. The secondary motivation was that I have a CD with UNIX source code that I wanted to filter while transferring it from the CD to my hard drive.
As written, you can use the FileFilter class for any filtering job that can operate on blocks of data. If you need to do the filtering line-by-line, then it should be easy to make slight modifications to the code so that the file is read a line at a time and each line fed to the filter as a block of data. Of course additional error checking would be desirable if these classes were to be used in a commercial application.
ConclusionsEncapsulating threads in a C++ class provides a clean interface for the application programmer. As the listings show, creating multiple instances of filter classes to run simultaneously is straightforward. I give the FileFilter class's constructor only three parameters: the source file name, destination file name, and a pointer to the function used to filter the data.
Wrestling with this design has confirmed for me the value of the classic algorithms. When I realized that the class I was trying to construct matched the pattern of the producer/consumer algorithm, I saw the way to a clean concise design. C++ classes provide a framework that requires more time devoted to class design, but rewards this effort with a clean implementation. Last but not least, writing OS/2 2.x multithreaded programs is fun!