Utility SpotlightFaster CPU Utilization Reports Using Multithreading

James Turner

Code download available at: UtilityOnlineAprTurner2009_05.exe(153 KB)

Contents

Script Prep
Creating the Server List
Multithreading
Wrapping It Up

This edition of Utility Spotlight is a little different than the usual, in that it involves scripts instead of executables, and so the column is dual-purpose: not only does it give you a nice way to build a report on CPU utilization in your organization, it also serves as a mini-tutorial to explain how the script works and how to use it.

If you're a system administrator, you may be familiar with trying to determine a server's CPU utilization remotely by querying the LoadPercentage property of the Win32_Processor Class. I recently wrote such a script to retrieve readings from a list of all of my domain servers. The script satisfied an immediate need to scan and report on all of my servers during a period when we were experiencing an overall server slowdown across our domain.

There was, however, an obvious limitation to the script in that only one server could be evaluated at a time, which meant that it took quite a while to get completely through the entire list of the servers.

To speed up the process, I decided to use multithreading, which resulted in a remarkable improvement—instead of taking close to 15 minutes to complete the scan of over 100 servers, the new process finished in just a few minutes.

That improvement did come at a bit of a cost, however. Instead of using one simple Visual Basic script, the new process requires two scripts, the recording of individual server CPU results to individual text files, a Data folder to hold those files, a routine to monitor the number of consecutive running threads, another routine to monitor the completion of all of the threads, and a process to read all of the text files into a Microsoft Office Excel spreadsheet.

Was it worth the extra work? I think so, because the new process helps improve reaction and response time to possible critical problems that might exist on your servers.

Script Prep

You can download the scripts at the Code Downloads section of our Web site. The main script for this process, CPUloadpercentageMultiThread.vbs, is listed in Figure 1. It runs a separate script called CPUThread.vbs (see Figure 2) that evaluates the CPULoadPercentage for each server in your domain.

Figure 1 CPUloadpercentageMultiThread.vbs

'***********************************************************************************************
'*** CPUloadpercentageMultiThread.vbs - Jim Turner
'*** Report on CPU Load Percentage for Domain Servers
'***  Adjust variables at Call Out A
'*** Uses MultiThreaded approach to speed up process by Calling CPUThread.vbs
'*** Requires Excel
'***********************************************************************************************
On Error Resume Next
Const ADS_SCOPE_SUBTREE = 2
CONST ForReading = 1
CONST xlAscending = 1 : CONST xlDescending = 2 
Const xlHeader = 1 : Const xlNoHeader = 2

'Call Out A
'*** Set these variables
TotalActiveThreads=20
NumberOfSamples = 5
ThreadVBSPath = "C:\scripts\CPUrefresher\"
DataPath = "C:\scripts\CPUrefresher\DATA\"
'*** Set these Variables
'End Call Out A

strMessage = "Excel Spreadsheet will open when process completes"
strScriptName = "CPU LoadPercentage Script"
CreateObject("WScript.Shell").Popup strMessage,5,strScriptName,vbInformation

'*** Call Out B
DNC = GetObject("LDAP://RootDSE").Get("defaultNamingContext")
QueryStr = "SELECT cn FROM 'LDAP://" & DNC & "' where objectcategory='computer' and operatingSystem='*server*'"
'End Call Out B

Set objConnection = CreateObject("ADODB.Connection")
Set objCommand = CreateObject("ADODB.Command")
objConnection.Provider = "ADsDSOObject"
objConnection.Open "Active Directory Provider"
Set objCommand.ActiveConnection = objConnection
objCommand.Properties("Page Size") = 1000
objCommand.Properties("Sort On") = "CN"
objCommand.Properties("Searchscope") = ADS_SCOPE_SUBTREE
objCommand.CommandText = QueryStr
Set objRecordSet = objCommand.Execute
objRecordSet.MoveFirst

'Call Out C
Set oShell = CreateObject("WSCript.shell")
Do Until objRecordset.EOF
 Err.Clear
 strComputer = ""
 strComputer = objRecordSet.Fields("CN").Value
 cmd = ThreadVBSPath & "CPUThread.vbs " & strComputer & " " & NumberOfSamples & " " & DataPath

 ThreadAvailable = False
 Do Until ThreadAvailable
  Set Processes = GetObject("winmgmts:\\.\root\cimv2").ExecQuery("Select * from Win32_Process where name ='wscript.exe'")
  If Processes.count < (TotalActiveThreads + 1) Then
   ThreadAvailable = True
   oShell.Run cmd,1,False
  End If
  Set Processes = Nothing
 Loop
 objRecordset.MoveNext
Loop
'End Call Out C

'Call Out D
MarkTime = Now()
ThreadAvailable = False
Do Until ThreadAvailable
 Set Processes = GetObject("winmgmts:\\.\root\cimv2").ExecQuery("Select * from Win32_Process where name ='wscript.exe'")

'*** Wait until all wscript processes are done or 120 seconds, whichever occurs first
'*** possible that other unrelated scripts may be running
 If Processes.Count < 2 Or DateDiff("s",MarkTime,Now()) > 120 Then
  ThreadAvailable = True
 Else
  Set Processes = Nothing
 End If
Loop
'End Call Out D

Set XL = CreateObject("Excel.Application")
XL.WorkBooks.Add
XL.Cells(1,1).Value = "Server"
XL.Cells(1,2).Value = "Processor"
XL.Cells(1,3).Value = "Load Average"

Row = 2

strPath = "C:\scripts\CPUrefresher\DATA\"
Set FSO = CreateObject("Scripting.FileSystemObject")
Set objFolder = FSO.GetFolder(strPath)
For Each objFile In objFolder.Files
 aray = ""
 strFile = strPath & objFile.Name
 Set fsoRead = fso.OpenTextFile(strFile,ForReading,False)
 CPUinfo = fsoRead.ReadAll
 fsoRead.Close
 aray = Split(CPUinfo,VBCRLF)

'Call Out E
 Set UniqCPU = CreateObject("Scripting.Dictionary")
 Set CPUAverage = CreateObject("Scripting.Dictionary")
'End Call Out E

'Call Out F
 For Each arItem In aray

  UniqCPU.RemoveAll
  CPUAverage.RemoveAll

  If Trim(arItem) <> "" Then
   ArFields = Split(arItem," ")
   If Instr(ArFields(1),"CPU") <> 0 Then
    UniqCPU.Add ArFields(1),0
   End If
  End If
 Next
'End Call Out F

'Call Out G
 For Each arItem In aray
  If Trim(arItem) <> "" Then
   ArFields = Split(arItem," ")
   If IsNumeric(ArFields(2)) Then

    UniqCPU.Item(ArFields(1)) = clng(UniqCPU.Item(ArFields(1))) + Clng(ArFields(2))

   End If
  End If
 Next
'End Call Out G

'Call Out H
 For Each arItem In aray
  If Trim(arItem) <> "" Then

   If Not CPUAverage.Exists(ArFields(1)) Then
    CPUAverage.Add ArFields(1),ArFields(1)
    ArFields = Split(arItem," ")
    XL.Cells(Row,1).Value = ArFields(0)
    XL.Cells(Row,2).Value = ArFields(1)

    XL.Cells(Row,3).Value = Round(UniqCPU.Item(ArFields(1)) / NumberOfSamples)

    Row = Row + 1
   End If
  End If
 Next
'End Call Out H
Next

XL.Cells.Select
XL.Selection.Sort XL.Range("c1"),xlDescending,XL.Range("A1"),,xlAscending,XL.Range("B1"),xlAscending,XLHeader,1,False
XL.Cells.EntireColumn.AutoFit
XL.Rows("2:2").Select
XL.ActiveWindow.FreezePanes = True
XL.Range("A1").Select
XL.Visible = TRUE

Figure 2 CPUThread.vbs

On Error Resume Next
If WScript.Arguments.Count < 1 Then
 Wscript.Quit
Else
 strComputer = WScript.Arguments.Item(0)
 NumberOfSamples = Clng(WScript.Arguments.Item(1))
 Datapath = WScript.Arguments.Item(2)
End If

Set fso = CreateObject("Scripting.FileSystemObject")

txtfile= Datapath & strComputer & ".txt"
Set TextFile = fso.CreateTextFile(txtfile,True)

Set objWMIService = GetObject("winmgmts:" _
 & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")
If Err.Number <> 0 Then
 TextFile.WriteLine strComputer & " " & "ConnectionProblem"
 Set objWMIService = nothing
 Err.Clear
Else
 Set OSItems = objWMIService.ExecQuery("Select * from Win32_OperatingSystem",,48)
 For Each OpSys in OSItems
  OS = OpSys.Caption
 Next
 Set OSItems = nothing
 If instr(OS,"Windows 2000") <> 0 Then
  For i = 1 to NumberOfSamples
   Set objWMIService = GetObject("winmgmts:" & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")
   Set colItems = objWMIService.ExecQuery("Select * from Win32_Processor",,48)
   For Each objItem in colItems
    TextFile.WriteLine strComputer & " " & objItem.DeviceID & " " & objItem.LoadPercentage
   Next
  Next
 Else
  Set objWMIService = nothing
  Set RefreshingObject = CreateObject("WbemScripting.SWbemRefresher")
  Set objWMIService = GetObject("winmgmts:\\" & strComputer & "\root\cimv2")
  If Err.Number <> 0 Then
   TextFile.WriteLine strComputer & " " &  "ConnectionProblem"
   Err.Clear
  Else
   Set ItemObjects = RefreshingObject.AddEnum(objWMIService,"Win32_Processor")
   RefreshingObject.Refresh
   For i = 1 to NumberOfSamples
    RefreshingObject.Refresh 
    For Each Sampling in ItemObjects.ObjectSet
     TextFile.WriteLine strComputer & " " & Sampling.DeviceID & " " & Sampling.LoadPercentage
    Next
   Next
  End If
 End If
End If
TextFile.Close

Before you can run the main script, there are a few variables you will need to adjust. These can be found at Call Out A in the main script:

TotalActiveThreads=20
NumberOfSamples = 3
ThreadVBSPath = "C:\scripts\  CPUrefresher\"
DataPath = "C:\scripts\CPUrefresher\DATA\"

TotalActiveThread represents the number of separate scripts you will allow to run at any one time on your computer. You can change TotalActiveThreads to any number you desire. Keep in mind, however, that though setting this value higher means you'll see results for all of your servers quicker, you'll also be using more memory and increasing the CPU processing load on your local computer. I suggest keeping the value for this variable between 15 and 30.

NumberOfSamples represents the number of times you want to take a CPU Load Percentage reading for each processor on a given server. Simply change the value of this variable to the number of CPU readings you want to take for each processor. Three to five should provide an accurate overall average.

ThreadVBSPath, as its name suggests, needs to point to the folder location of the CPUThread.vbs script. And you'll also need to point the DataPath variable value to the location where you want the resulting CPU load text files stored. Be sure this folder is empty or contains only text files produced by this script. The script attempts to open and read all files within this folder.

Note that you must manually create the folders specified in the ThreadVBSPath and DataPath variables before running the script.

Creating the Server List

Instead of trying to acquire a collection of servers by hardcoding a specific Active Directory OU, I chose a slightly different approach so I wouldn't have to change the code should an OU get changed, renamed, or moved, or should there be multiple OUs that contain Server objects.

At Call Out B in the main script, you'll see that I use a very generic query to produce my collection of servers from Active Directory:

QueryStr = "SELECT cn FROM 'LDAP://" & DNC & "' where
 objectcategory='computer' and operatingSystem='*server*'"

As you can see, the key to this query is to simply look for objects where the operating system contains the word "Server". This helps make the script even more generic, so the only mandatory changes you initially need to make are creating the Script and Data folders and initializing the variables for those two folders.

Multithreading

After setting up my ADO connection object, the script cycles through the returned collection of servers at Call Out C. Within this loop, I set up the command statement I will use to perform the multithreading, with this statement:

cmd = ThreadVBSPath & "CPUThread.vbs " & strComputer & " " &
 NumberOfSamples & " " & DataPath

Here's an example of what the concatenated command might actually look like:

C:\Scripts\CPUrefresher\CPUThread.vbs
  ServerOne 5
 C:\Scripts\CPUrefresher\Data\

Here you can see that the CPUThread script will be executed with arguments representing the name of the server, ServerOne, the number of samples to take, 5, and a reference to where the resulting text files should be written, C:\Scripts\CPUrefresher\Data\. The variable strComputer, which contains the servername, is the only piece of this command string that actually changes as each item in the server collection loop is iterated.

After setting the cmd variable, the following snippet of code controls the multithreading portion of the script:

ThreadAvailable = False
 Do Until ThreadAvailable
  Set Processes = GetObject("winmgmts:\\.\root\cimv2").ExecQuery("Select * from Win32_Process where name ='wscript.exe'")
  If Processes.count < (TotalActiveThreads + 1) Then
   ThreadAvailable = True
   oShell.Run cmd,1,False
  End If
  Set Processes = Nothing
 Loop

I start by creating a Boolean Flag variable called ThreadAvailable and set it to False. This Boolean value is used within a Do Until loop to restrict the number of wscript.exe processes that can be running at one time. As you'll soon see, this restriction is directly related to the value of the TotalActiveThreads variable, and as long as the total number of active wscript.exe threads is under a set value, the Boolean value of ThreadAvailable is set to True.

The next two lines of code determine whether a thread is available.

Set Processes = GetObject("winmgmts:\\.\root\cimv2").
      ExecQuery("Select * from Win32_Process where name ='wscript.exe'")
  If Processes.count < (TotalActiveThreads + 1) Then

The WMI query returns a collection of processes named wscript.exe, and Process.count indicates how many of those processes are currently running. If that number is less than the number you've set as TotalActiveThreads (plus one to account for this main script itself), then the ThreadAvailable variable is set to True and a multithreaded script is spawned via the following statement:

oShell.Run cmd,1,False

This statement is essentially running the CPUThread script (with the three arguments). As you recall, the assignment of the cmd variable looked something like this:

cmd = ThreadVBSPath & "CPUThread.vbs " & strComputer & " " &
  NumberOfSamples & " " & DataPath 

The number 1 that follows cmd indicates that the resulting command window will run in a minimized state. The False parameter says you don't want to wait for the command to complete before moving on to the next line of code. This is basically the magic line of code that provides the multithreading functionality.

When the number of wscript.exe processes currently running is greater than or equal to TotalActiveThreads (plus one), the script stays within the Do Until loop until the number of wscript.exe processes drops and becomes less than TotalActiveThreads (plus one). Obviously, as the multithreaded scripts (CPUThread.vbs) finish, the number of wscript.exes drop and in essence open up a slot for another multithreaded script to be spawned.

Then, after all of the items in the server collection have been enumerated, the script goes into a waiting state that begins at Call Out D. This part of the code is designed to wait for all of the spawned multithreaded scripts to finish up before moving on to the final step that collects the data and creates the report.

The code for the wait state is very much like the code used in the Do Until loop just described, except that I only want to wait until the number of wscript.exe processes is less than two or until two minutes have passed. The reason for the added two-minute condition is that you could have other unrelated scripts running. I know that I need to account for this possibility because I have several local scheduled tasks that launch periodically throughout the day. Two minutes should be enough time to allow the remaining spawned processes finish up. If it's not, simply increase the number 120 to a higher value.

When either of these two conditions is met, the script begins its descent in which it opens up the files created by all of the CPUThread scripts and reads them into a spreadsheet. Much of this section is pretty basic stuff—opening files and reading them—but there is a bit of complexity involved that keeps track of multiple CPU load totals and averages that I'd like to touch upon.

At Call Out E, I set up two Dictionary objects, UniqCPU and CPUAverage. UniqCPU is used to keep track of each individual CPU and the combined sampling total CPU Loadpercentages for that CPU. The key portion of this dictionary element is the CPU device Id (CPU0, CPU1…), and the item portion of the dictionary element is a combined total of the cpuloadpercentage samples taken.

The other dictionary object, CPUAverage, is used as a Flag of sorts. I use it to determine if I've already reported on a specific CPU. I'll explain that in more detail shortly.

To understand how dictionary objects are put to use, you need to have an idea of what the data looks like in one of the text files created by the process. Here's an example:

SERVER3 CPU0 56
SERVER3 CPU1 94
SERVER3 CPU0 88
SERVER3 CPU1 57
SERVER3 CPU0 43
SERVER3 CPU1 54

Here, Server3 is simply the name of the server. CPU0 is the Device ID of the first processor on that server. 56 represents the return value of the first CPULoadPercentage sample taken. The next line represents the second CPU (device ID CPU1) and the first CPULoadPercentage sample (94) for that processor. The third line represents the second sampling of CPU0 and its CPULoadpercentage and so on and so on. So from this example, you can tell that there are two processors and three CPULoadpercentage samples for each. By the way, each line is subsequently converted into an array using the split function, making it easier to reference the CPU and CPULoadpercentage.

To set up my UniqCPU dictionary so that it contains just one key entry for each CPU, I run through the code at Call Out F, which creates the keys and initially sets the item value to zero.

If the sample data above were the actual data, my UniqCPU dictionary would have two entries, one key named for each CPU and an item element for each that would be a combined total of that CPU's loadpercentages. So for instance, UniqCPU key CPU0 would have an item value of 187. The process that adds the loadpercentage to each key can be viewed at Call Out G.

At this point in the code, I am ready to write results to Excel. As you can see at Call Out H, I cycle through the data once again and use my second dictionary CPUAverage to calculate the average for each CPU.

fig03.gif

Figure 3 The Excel Report Showing CPU Load Average(Click the image for a larger view)

After reading all of the text file data into the spreadsheet, I simply do a little Excel housekeeping and sort the spreadsheet so that the "hottest" CPUs are listed first.

As you'll recall, the other piece of this process involves the CPUThread script. It is in this script that the actual CPU Load Percentages are gathered. The CPULoadPercentage is an averaged percentage of the load on a particular CPU over a one-second time period.

As noted earlier, the main script passes three arguments to the CPUThread script, Server Name, Number Of Samples, and the Location of the Data files. These three arguments tell the CPUThread script which server to perform the WMI processor query on, how many times to refresh the query to accommodate the number of samples I want, and finally, where to write the results to.

The only twist is that this script uses a method of refreshing the processor collection information by utilizing the WbemScripting.SWbemRefresher class, and thereby eliminating the need to re-instantiate the WMI Win32_Processor class each time it needs to get a new CPU Load reading.

Note that I've added some simple logic to this script to make accommodations for Windows 2000, which is not compatible with the refresh class. If the operating system is Windows 2000, the WIN32_Processor class is repeatedly used to create a new instance of the processor object for as many times as you specify in the NumberOfSamples variable.

Wrapping It Up

When everything has completed, the Excel report lists each CPU's average in a single row, as you can see in Figure 3. This lets you sort to on the CPU Average Column and quickly see all of the "hot" CPU loads.

James Turner is a Domain Administrator for Computer Sciences Corporation and an Active Directory scripting applications and tools developer. He's been developing PC and server-based tools and applications using VBScript for nearly 10 years, and he has also authored many HTA applications, VBScripts, and scripting articles for Windows IT Pro Magazine. He specializes in Active Directory scripting and using Excel as his scripting reporting medium.