Examining OS/2 2.1 Threads

The OS/2 2.1 multitasking model is based on the execution of threads, making it possible for many sections of a single process to execute simultaneously. John examines OS/2's thread architecture, specifically, the scheduling process.


January 01, 1994
URL:http://www.drdobbs.com/parallel/examining-os2-21-threads/184409165

Figure 1


Copyright © 1994, Dr. Dobb's Journal

Figure 2


Copyright © 1994, Dr. Dobb's Journal

Figure 3


Copyright © 1994, Dr. Dobb's Journal

Figure 1


Copyright © 1994, Dr. Dobb's Journal

Figure 2


Copyright © 1994, Dr. Dobb's Journal

Figure 3


Copyright © 1994, Dr. Dobb's Journal

JAN94: Examining OS/2 2.1 Threads

Examining OS/2 2.1 Threads

Understanding the scheduler is the key

John M. Kanalakis, Jr.

John is a programmer with CTB, Macmillan/McGraw Hill and can be contacted at 408-649-7478.


One reason for using modern 32-bit operating systems is that you can simultaneously run multiple applications which require real-time processing. In OS/2 2.1 this is made possible by a preemptive multitasking kernel capable of scheduling tasks back-to-back to reduce or eliminate idle CPU time.

The OS/2 multitasking model is based upon execution of sections of code, as opposed to entire programs. That is, OS/2 identifies the smallest unit of execution to be a thread, rather than a process or program. Since processes consist of one or more threads, many sections of a single process can execute at once. A process also owns different resources. Every thread created by a process shares the creating process's resources. This includes globally declared variables, static variables, and physical devices. Each thread's individuality centers about its own local stack. Since a thread is composed of only registers and memory pointers, creation and context-switching are very fast.

Thread States

Threads exist in one of three specific states: running, ready, or blocked. OS/2 2.1 allows only one thread to be in a running state. Future multiprocessor versions will allow a running-state thread to exist for each active CPU. Threads waiting to be regularly scheduled are placed in the ready state, and the scheduler decides when they will move to the running state and be allowed CPU run time. The scheduler ignores threads in the blocked state until they move to the ready state. Threads are usually put into the blocked state to keep them from interfering with another thread's access to a resource.

Depending on each thread's priority class and level, the scheduler determines which thread will context switch from the ready state to running state. OS/2 maintains four priority classes--time critical, fixed high, regular, and idle time (in order of descending priority). Time-critical threads are executed instantly until they're blocked or destroyed and are often used for real-time processing. Conversely, idle-time threads demand the least CPU attention and run only when all higher-priority threads have been served. Each thread within a specific thread class has an associated priority level between 0 (lowest) and 31 (highest). The thread in the highest class and with the greatest level will always execute first. Threads within the same class and at the same priority level are scheduled in a round-robin fashion.

Round-robin Scheduling

In round-robin scheduling, ready threads posses the same priority as the running thread, and the scheduler shares CPU access equally among them in an order much like that of a circular linked list; see Figure 1. Though most scheduling mechanisms implement some form of round-robin scheduling, there's much debate over time-slice length vs. actual task-switching overhead time. For instance, if it takes the scheduler 5 milliseconds to switch from one thread to another and each time-slice is set for 20 milliseconds, then 20 percent of the total CPU time is inefficiently spent on switching between threads. However, if the time-slice is set to 1000 milliseconds, then only 0.5 percent of the total CPU time is spent on switching between threads. Setting longer time-slices may sound efficient, but with a greater time-slice the system responsiveness becomes slower and the illusion of concurrency dissipates.

One way OS/2 handles efficiency is that you can set the time-slice length in the CONFIG.SYS. This lets you decide which value best meets your needs. Also, OS/2 improves round-robin efficiency by implementing a bias, which allows the smart scheduler to dynamically reset the priorities of different threads.

Implementing a Bias

Each thread's priority may be dynamically set. You're free to write code which initially sets a thread's priority class and level, then dynamically reset this value through API calls. More interestingly, OS/2's smart scheduler can dynamically set these values under any of three conditions. The first condition, a foreground bias, distinguishes the regular priority class from the fixed-high priority class. When several threads are in regular execution, the foreground bias boosts the priority of the foreground thread above other threads also within the regular priority class. This provides smoother user interaction, improved system responsiveness, better keyboard and mouse input, and faster processing of posted messages.

The second condition in which the scheduler changes a thread priority is an I/O bias. A thread receives an I/O bias after an I/O operation is performed. An I/O bias increases the thread's level to its maximum value but does not shift the thread into a higher class. This helps a thread to quickly release ownership of a resource and to perform final data processing for another thread.

Finally, there's the time-out bias. A thread of very low priority may rarely obtain any CPU time. To compensate, the scheduler offers the time-out bias, whereby threads within the regular class run after waiting a certain period of time. This wait length is determined by OS/2's CONFIG.SYS file and has a default of three seconds. Threads offered the time-out bias don't execute within the length of a standard time-slice. To be fair to legitimately higher-priority threads, OS/2 offers time-out-biased threads shorter time-slices. CONFIG.SYS determines the lengths of both short and standard time-slices. This lets the OS/2 smart scheduler decide how to efficiently schedule threads.

Context Switching

The OS/2 scheduler handles context switching in a straightforward manner. An application running on top of MS-DOS is, at any instant, composed of register values that hold: machine instructions and work variables in the AX, BX, CX, and DX registers; pointer values in the SI, DI, BP, and SP registers; segment pointers in the CS, DS, ES, and SS registers; and CPU flags. The application runs by incrementing the instruction pointer, IP, which points to machine code stored in the code segment. To perform a context switch, the scheduler simply saves the register values to a structure in memory; see Figure 2. This structure, the thread control block (TCB), contains an entry for each thread that exists, regardless of its state. When booting, the 8259 programmable timer is set to trigger the clock interrupt (INT 8), roughly a thousand times per second. After a specific clock interrupt, the current thread state is stored in memory and the scheduler determines which thread to run next. The context switch is then performed by duplicating the register information from the next TCB entry into the CPU registers. Finally, the scheduler resumes with a new thread's code execution.

With multiple threads accessing shared resources, one thread's actions may affect another's processing. If two threads are free to simultaneously access one global variable, it can become confusing to manage that variable's data. You might want to use a Boolean flag variable set to True if any thread is accessing a specific data structure. Normally, a thread could test the value of the Boolean variable, find it False (available for access), set it to True to publicly indicate the thread's access, and begin using the guarded data structure. The only problem is that the scheduler might perform a context switch just after the Boolean variable is tested, but before it resets; see Figure 3. In this case, two threads own access to the guarded data structure. A semaphore is a special variable managed by the kernel that's tested and reset within a single CPU action. As a result, the scheduler can't perform a context switch until the tested variable is actually reset. Since this variable is established and maintained by the operating system, it's guaranteed to protect guarded data from multiple threads.

Example Multithreaded Programs

Listing One (page 96) shows the fundamental framework of a multithreaded program. The program does little more than inform the user when each thread executes. It essentially stands as the fundamental framework to work from. The preprocessor definition, INCL_DOSPROCESS, instructs the compiler to include information relating to processes and threads. Each thread function marks the existence of a new thread. The new thread exists as long as its function is in scope or until it is destroyed with DosKillThread(). The only new variable required is a thread handle of type TID. The main program creates the threads by calling DosCreateThread() and passing a thread handle, thread function, optional parameter and flag, and local stack size. Following the DosCreateThread() calls, the two threads are created and run instantly. From this point, three threads exist: main, ThreadFunction1, and ThreadFunction2, all running simultaneously. main sleeps for 3700 milliseconds to allow the other two threads to continue. If the main thread doesn't sleep, it may end before the other two threads are even created. (Remember, ending the main thread destroys all subsequent threads.) Both threads run until they lose scope and are terminated. The final call in main to DosExit() closes the main thread, but this call isn't required. Any code that exists within the main function is permissible in the thread function. The thread's data elements are shared with individual threads.

One problem with multithreaded programming is that two threads may change important shared variables. OS/2 provides "critical sections" and "semaphores" for handling these situations. A critical section is a region in memory that's declared to be under special protection, which prevents multiple threads from accessing it at the same time. When a thread reaches a critical section, DosEnterCritSec() may be called to prevent other threads from accessing it. Upon leaving the critical section, the thread must call DosExitCritSec() to restore mutual access. Using the critical-section approach will guarantee that no other threads will access the same data; however, this is inefficient. By calling DosEnterCritSec(), the scheduler suspends all other threads within the process whether they use the same data or not. A semaphore is a more reasonable approach to data protection. As mentioned earlier, it is a special flag used between threads.

The three types of semaphores are: events, mutual exclusion, and multiple wait. Event semaphores, created with DosCreateEventSem(), notify a thread that a certain event has occurred and are usually used for protecting shared data. For a thread to wait for the semaphore before executing, it may call DosWaitEventSem(), which blocks the waiting thread until the event-semaphore signal is posted. When another thread is ready to signal the waiting threads, it calls DosPostEventSem(). The waiting threads are then moved from a blocked condition to a ready condition until the scheduler allows the function to continue running. The thread can specify how long it is willing to wait before timing out and returning an error as an argument passed to DosWaitEventSem(). Otherwise, by default, the thread waits indefinitely.

Listing Two (page 96) implements an event semaphore. The main thread is executed and creates two peer threads. The main thread is blocked with a semaphore until thread2 posts a flag; it's then blocked until thread1 posts a flag. Thus, the main thread suspends while thread1 works; it then allows thread2 to work, which allows main to continue. The code is similar to Listing One. The added INCL_DOSSEMAPHORES includes semaphore information. Two event handles are created to be used as the actual signal. A semaphore may also be named, as in the example. By convention, the first seven characters in the name must be \SEM32\ SemaphoreName. After both threads are created, main calls DosWaitEventSem(), passing the handle of the event to wait for a completion signal from thread2. At the same time, thread2 is making the same function call to DosWaitEventSem(), waiting for the completion of thread1. Thread1 executes its code and, upon completion, signals the second thread to stop waiting by posting a signal with DosPostEventSem(). Eventually, the main function is returned to running status and finishes the program.

The mutual-exclusion (mutex) semaphore is commonly used to serialize access to a shared resource. This means the mutex is a flag which informs other threads that the desired resource is unavailable. The mutex semaphore is primarily used for shared allocated resources, rather than shared data members. It's created with DosCreateMutexSem() with the same arguments as those for event semaphores. When a thread needs exclusive control over a resource, it calls DosRequestMutexSem(). From this point, the thread has complete, unshared access to the specified resource. Other threads attempting to use that resource will be blocked in a sequential order. When the thread owning a resource is finished, it calls DosReleaseMutexSem() to release possession. Ownership is then transfered to the next waiting thread with the highest priority.

Conclusion

Threads are becoming better recognized for their natural ability to increase program efficiency, and it makes sense to limit the amount of CPU time wasted on user input or the moving of disk heads. With a good design, multithreaded programs can have amazing speed advantages over linear programs.

Figure 1: Round-robin scheduling.

Figure 2: The scheduler performs a context switch to save the register values to a structure in memory.

Figure 3: Two threads owning access to a guarded data structure.


[LISTING ONE]


#define INCL_DOSPROCESS

#include <OS2.h>
#include <Stdio.h>
#include <String.h>

VOID EXPENTRY ThreadFunction1(ULONG);
VOID EXPENTRY ThreadFunction2(ULONG);

main()
{
   TID   FirstThreadID, SecondThreadID;
   printf("Executing main thread.\n");
   DosCreateThread(&FirstThreadID, ThreadFunction1,0,0,4096);
   DosCreateThread(&SecondThreadID, ThreadFunction2,0,0,4096);
   DosSleep(3700);
   DosExit(EXIT_PROCESS, 0);
}
VOID EXPENTRY ThreadFunction1(ULONG)
{
   printf("Thread Function 1 is currently executing.\n");
}
VOID EXPENTRY ThreadFunction2(ULONG)
{
   printf("Thread Function 2 is currently executing.\n");
}

[LISTING TWO]


#define INCL_DOSPROCESS
#define INCL_DOSSEMAPHORES

#include <OS2.h>
#include <Stdio.h>
#include <String.h>

VOID EXPENTRY ThreadFunction1(ULONG);
VOID EXPENTRY ThreadFunction2(ULONG);

HEV   EventHandle1, EventHandle2;
UCHAR SemaphoreName1[27], SemaphoreName2[27];
TID   FirstThreadID, SecondThreadID;

main()
{
   printf("Executing main thread.\n");
   strcpy(SemaphoreName1,"\\SEM32\\FirstSemaphore");
   strcpy(SemaphoreName2,"\\SEM32\\SecondSemaphore");
   DosCreateEventSem(SemaphoreName1, &EventHandle1, 0, 0);
   DosCreateEventSem(SemaphoreName2, &EventHandle2, 0, 0);
   DosCreateThread(&FirstThreadID, ThreadFunction1,0,0,4096);
   DosCreateThread(&SecondThreadID, ThreadFunction2,0,0,4096);
   DosWaitEventSem(EventHandle2, SEM_INDEFINITE_WAIT);
   DosExit(EXIT_PROCESS, 0);
}
VOID EXPENTRY ThreadFunction1(ULONG)
{
   printf("Thread Function 1 is currently executing.\n");
   DosPostEventSem(EventHandle1);
}
VOID EXPENTRY ThreadFunction2(ULONG)
{
   DosWaitEventSem(EventHandle1, SEM_INDEFINITE_WAIT);
   printf("Thread Function 2 is currently executing.\n");
   DosPostEventSem(EventHandle2);
}
End Listings


Copyright © 1994, Dr. Dobb's Journal

Terms of Service | Privacy Statement | Copyright © 2024 UBM Tech, All rights reserved.