Recently, a client called to describe a specific problem, the general aspects of which are relevant to all script and Windows Script Host (WSH) developers. The problem per se—ensuring synchronization between multiple tasks—wasn't particularly difficult, but the range of tools I needed to use to solve it made the process difficult.
Synchronizing a few tasks in a Win32 application, even in a Microsoft Visual Basic (VB) application, is a matter of calling a couple of API functions: one for creating an appropriate event object and one for putting the calling thread on standby until the OS wakes it up when the event is signaled. So, if you can use Visual C++ (VC++) or VB, the process is easy. However, if—as in this case—the surrounding environment is WSH and VBScript is the language of choice, you must resort to emulation and a bit of creativity.
The Synchronization Problem
My client described the problem thusly: One WSH script has to spawn a few other scripts that use existing executables. The main script has to stop and wait for all the spawned processes to terminate. (The stop-and-wait action is called a blocking wait.) If an error occurs in any child process, the whole operation must be considered canceled, and ideally the overall outcome is as if nothing ever happened.
Two underlying patterns surface after careful analysis of the problem. One pattern is that the script must stop execution and be able to wait for the OS to signal multiple objects before the script continues. This pattern is common in multithreaded programming, and any development kit for the environment usually provides a native tool for it. Win32 is no exception, but WSH isn't full Win32.
The other pattern is the transactional behavior that the user expects from the main script. The spawned scripts are considered the internal operations of a transaction, and the main script waking up after all the children terminate is a sort of automatic commit. (Commit is the command that successfully terminates a transaction, accepting all the results that have been generated.) One user requirement is for a script-specific mechanism to provide for rollback—namely, the ability to cancel all the results generated and invalidate the entire operation.
The Run Method
As a seasoned user of the WSH object model, you probably know that the WshShell object's Run method is the WSH programmatic counterpart of the Start menu's Run dialog box. You can use the Run method to spawn an external program with several possible settings. The full signature for the method looks like this:
WshShell.Run(strCommand, _ \[intWindowStyle\], _ \[bWaitOnReturn\])
The first argument (strCommand) is the command name of the executable to start, including its arguments. Next, you can specify the style of the program's main window \[int Window Style\] and—what really matters here —a Boolean value to specify whether the calling script has to undergo a blocking wait until the spawned executable terminates.
By default, the bWaitOnReturn flag is set to False, which means that the execution is asynchronous. To use the Run method to run a WSH script, use the syntax
Set shell = _ CreateObject("WScript.Shell") shell.Run "wscript.exe test.vbs"
When the bWaitOnReturn argument is set to True, the script that spawns the child process waits for that process to terminate, then lets the next instruction proceed.
You might think that the Run method could solve my client's problem. The Run method can synchronize the caller script with the spawned application and signal the script when the child process terminates. However, the Run method doesn't let you synchronize more than one child process. To solve the problem, you need to emulate the behavior of those low-level constructs that, in Win32, let you synchronize the evolution of a process with system- and application-level events. Here are the requirements for emulating this behavior:
- Without dramatically affecting the load on the CPU, stop the execution of the main script until the spawned processes terminate.
- Detect when a given executable has finished.
- Decide whether to accept the results that have been generated.
As the second requirement hints, some form of interaction with the spawned process is necessary. If you can control the source code of the script or the executable, that's fine. Otherwise, you can wrap the program in a VBScript program spawned with the bWaitOnReturn flag turned on.
Structure of the Caller
The caller script is the main actor, and the script must have a particular structure. The caller script must be able to spawn the various scripts, then stop to wait for events. Unfortunately, the WSH environment doesn't support these processes.
One way to stop a script's execution is to use a While loop based on a Boolean condition. However, such a loop, especially if it's indefinitely long, is expensive in terms of machine resources. Your script isn't in standby, but it must continuously verify that it has nothing to do.
WSH 2.0 (the version that shipped with Windows 2000) introduced another option. You can use WScript's Sleep method to efficiently put a thread to sleep. To call the Sleep method, use the syntax
where 100 is the number of milliseconds the script will pause. The Sleep method times out the script from the system scheduler, marks the script as a not-runnable thread, and leaves it out of the CPU, abandoning it in some remote fold of the memory. After the specified number of milliseconds expires, the script's thread is marked as ready to run and waits its turn to possess the CPU again and continue its work.
Using the Sleep method doesn't have a significant impact on the CPU or overall system performance. However, Sleep requires you to specify a number of milliseconds, not a condition. A good compromise is to use the Sleep method in a While loop:
While bCondition Sleep 100 Wend
In this case, a Boolean condition controls the loop every tenth of a second. By changing the Sleep parameter, you can adjust the frequency of the check and the latency in detecting the outcome of spawned processes. The code
Set shell = _ CreateObject("WScript.Shell") shell.Run "wscript.exe app1.vbs" shell.Run "wscript.exe app2.vbs" shell.Run "wscript.exe app3.vbs" While bCondition Sleep 100 Wend
shows the typical structure of the script. The child processes are spawned one after the other and run asynchronously. Immediately after launching the app3.vbs process, the caller script enters a sort of blocking loop that, thanks to Sleep, isn't particularly CPU- intensive.
However, a problem still exists: You need to be able to track the state of each application. To do so, you must change the Boolean condition for the While loop because it's too generic. In fact, the condition must check the value of a piece of memory common to all script applications. By contrast, the child applications must be able to write information on a sort of common whiteboard from which other WSH processes can read. Instead of using memory, you could create a temporary disk file and check its content. You could also write to the standard event log (in WSH 2.0, you can use the shell's LogEvent method), but then you'd need to be able to read from the log.
While thinking about this, I suddenly remembered the Session mechanism in Active Server Pages (ASP). You use the ASP Session object to make data persistent across multiple page invocations. If you consider the Application object instead of Session, this capability spans all the invocations of all the pages that form an ASP-based Web site. The next challenge is to simulate the behavior of ASP's Session Manager in WSH.
Solution: Using Environment Variables The spawned processes must save information about their status and termination somewhere. The main script must then access this information to check whether the synchronization has completed. But where can this information reside so that all processes can access it? If you limit the search to WSH-accessible resources, variables are your only option.
In WSH, you access environment variables through the Environment property of the WshShell object. The Environment property returns a Wsh-Environment collection that includes a Remove method to delete a certain variable from memory.
Windows has up to four spaces for environment variables: Volatile, User, Process, and System. The Volatile space is relative to the ongoing process, and the content isn't available to any other process. The User environment is accessible only to the user who created it. The Process and System spaces are similar in that all the content is accessible to all processes. However, the standard content of the System space is read-only, available at logon, and for Windows XP, Win2K, and Windows NT only. As custom variables, though, these two spaces are equivalent. Because the various processes are run from the same machine and from the same user, the User environment space is a good place for each spawned process to write status and termination information.
To read and write variables from the current user's environment, you must specify the keyword User when calling the Environment property. You set a variable just as you would with a regular collection. For example,
Set UserEnv = _ shell.Environment("User") UserEnv("VarName") = value
shell.Environment("User") _ ("VarName") = value
Modifying the Child Programs
Now that you've set the variable, consider the simple but effective script that Listing 1 shows. At the end of this code, you must add a couple of lines to create an application-specific variable with a conventional value. In this example, I've created an environment variable called App3_Finished, which callout A in Listing 1 shows, that I set to a value of True. If you don't have access to the source code (i.e., signed code) or if you don't want to modify the source code, simply create a new .vbs file that spawns the original code and add the extra code at the end of it, as Listing 2 shows.
Bringing It All Together
Any spawned program writes to the User environment space a value that specifies the program's status. The main script just reads this information for each spawned application and concatenates the outcomes with the AND logical operator. Listing 3 shows the full source code of the main script. Listing 4 shows an example of a child script. When the comparison is successful, the script executes the rest of its code and makes sure that all child processes are terminated.
The execution of a script can't be fully transactional unless you have a chance to ask any program to cancel its operations. Doing so isn't always possible, but by implementing an extra layer of code in the main loop that governs the synchronization, you can detect the final result of the operation—whether all programs terminated successfully or one or more programs failed. If the final outcome is failure, you need a way to cancel previously generated results. Listing 5 shows how to modify the code to provide for this two-phase commit (2PC).
When you're synchronizing scripts or programs whose source you can access and modify, you can try to implement an internal rollback mechanism. However, doing so is impossible for compiled .exe files. In addition, the ability to execute a rollback also depends on the nature of the operations performed. Commit and rollback, in fact, are database-related concepts in which a runtime processor executes and tracks operations, making accepting or rejecting cross-script changes easier.
You can force child processes to keep more detailed information about the cause of the error and leave the main script free to take that information into account. However, I can't see a general way to make the script execution somewhat transactional for commit and rollback.
The Limitation of WSH
WSH is powerful in its intrinsic simplicity. Of course, the deliberate simplicity of its object model deprives you of some functionality that might turn out to be useful someday. When you need a special solution, you have two general strategies: Either use all the resources and programming creativity you have, or buy (or design and write) an extension to WSH, which usually takes the form of a COM object with full access to the underlying Win32 platform. If you need to synchronize the execution of multiple scripts, either follow the approach I've outlined or come up with a COM object that uses the WaitForMultiple-Objects API function.