2

I have a PowerShell script where I want to create a background thread and dynamically exchange data with my primary thread. The idea was to use the information stream since it can handle all kind of objects easily.

Usually I do so by giving the PowerShell-Object to itself like the following:

$Code =
{
    Param($Me)
    #Here I can use $Me.Streams.Information to exchange data any time,
    #for example to feed my thread with more work to do on the fly

    $ResultData = [System.Object[]]::new(0)
    $WorkCounter = 0
    $Finished = $false
    while (-not $Finished)
    {
        while ($Me.Streams.Information.Count -eq $WorkCounter)
        {
            #Wait for data to be added to the information stream
            Sleep -MilliSeconds 10
        }

        $InputData = $Me.Streams.Information[-1].MessageData
        if ($InputData -eq "FINISHED")
        {
            $Finished = $true
        }
        else
        {
            <# Do some stuff with the $InputData #>
            $ResultData += $ProgressedInputData

        }
        $WorkCounter++
    }
    Write-Information $ResultData
}
$PS = [PowerShell]::Create()
$PS.AddScript($Code) | Out-Null
$PS.AddArgument($PS) | Out-Null #Hand the PS to itself to make the streams accessible inside the thread
$Handle = $PS.BeginInvoke() | Out-Null

for ($i = 0; $i -lt 10; $i++)
{
    $PS.Streams.Information.Add([System.Management.Automation.InformationRecord]::new($i, ""))
    #I just gave my background thread some stuff to do without the need to instantiate a new one again
    #Now this thread can do some work too...
}
$PS.Streams.Information.Add([System.Management.Automation.InformationRecord]::new("FINISHED", ""))
$Handle.AsyncWaitHandle.WaitOne() #Wait for my background thread to finish all its work
$SomeReturnValue = $PS.Streams.Information[-1].MessageData

My actual question is: Is it possible, to access the current PowerShell instance without the need to hand it over like I did with $PS.AddArgument($PS)?

GuidoT
  • 280
  • 1
  • 12
  • I haven't found a way to do this without using reflection and even then it was very hard to do (I only found a decent way to do this in ISE). I also wanted to get the streams (and subscribe to the events in them) – bluuf Feb 16 '22 at 12:04
  • Not really, streams are not really meant for this kind of two-way communication. Would you be interested in alternative solutions to that? – Mathias R. Jessen Feb 16 '22 at 12:09
  • Please enlighten me regarding those alternatives Mathias. This stream solution just works so convenient. :-) – GuidoT Feb 16 '22 at 12:17
  • I'm not writing multithreaded scripts myself, but I see people often use a thread safe collection, such as a synchronized hash table to exchange info between runspaces. – MikeSh Feb 16 '22 at 12:32
  • Ask and thou shalt receive! ^_^ @MikeSh is spot on, you just need a thread-safe collection to exchange the data – Mathias R. Jessen Feb 16 '22 at 12:50

2 Answers2

4

You don't need to abuse PowerShell.Streams for two-way communication here - PowerShell already has a facility for bridging session state between two runspaces, a so-called Session State Proxy!

Let's start with a slight rewrite of your $code block:

$code = {
  while($config['Enabled']){
    $inputData = $null
    if($queue.TryDequeue([ref]$inputData)) {
      #process input data
      Write-Information $inputData
    }
    else {
      Start-Sleep -Milliseconds 50
    }
  }
}

Notice that I'm using two variables, $config and $queue, even though I haven't actually parameterized or otherwise defined them, but we're still using the Information stream to communicate output (you could use standard output as well).

Now we just need to create two objects that we can bind to those variables. For thread-safety, we'll be using:

  • A ConcurrentQueue[psobject] for the input data
  • A thread-synchronized [hashtable] for the config data
# Create PowerShell instance like before
$PS = [powershell]::Create()

# Create the thread-safe collections we'll be using to communicate
$queue = [System.Collections.Concurrent.ConcurrentQueue[psobject]]::new()
$config = [hashtable]::Synchronized(@{
  Enabled = $true
})

# Now make those variable references available in the runspace where the backgroun code will be running
$ps.Runspace.SessionStateProxy.PSVariable.Set('queue', $queue)
$ps.Runspace.SessionStateProxy.PSVariable.Set('config', $config)

With a facility for exchanging both input and configuration data, you can now invoke the background job and observe how it behaves based on the input provided via the $queue (I strongly suggest entering the following statements into an interactive prompt, play around with it a bit):

# Invoke background code
$asyncHandle = $PS.BeginInvoke()

# Try adding some data to the queue
$queue.TryAdd([pscustomobject]@{ Property = "Value 123"})

# Wait for a bit
Start-Sleep -Milliseconds 100

# Observe that the queue has been emptied by the background code
Write-Host "Queue is empty: $($queue.IsEmpty)"

# Observe that the background code actually processed (and output) the data
$ps.Streams.Information
Mathias R. Jessen
  • 157,619
  • 12
  • 148
  • 206
  • Thanks alot m8! I always wondered how to make a Hashtable thread safe. Can you explain how to make a [System.Windows.Forms.ImageList] synchronized as well? There is a $ImgList.Images.IsSynchronized property that is always set to $false. – GuidoT Feb 16 '22 at 13:24
  • @GuidoT That's because the image collection implemented for that type is not thread-safe. You can solve that by using a technique similar to above: only `Add()` or `Remove()` images from the thread/runspace that owns the control, but use a concurrent collection to feed new images to that thread/runspace from elsewhere – Mathias R. Jessen Feb 16 '22 at 13:28
  • What exactly do you mean by "[instead of Write-Information] you could use standard output as well"? – GuidoT Feb 16 '22 at 20:08
2

Let me offer an alternative to Mathias R. Jessen's helpful answer, based on the ThreadJob module's Start-ThreadJob cmdlet, which ships with PowerShell (Core) v6+ and in Windows PowerShell can be installed on demand (e.g., Install-Module ThreadJob -Scope CurrentUser)

As the name suggests, it offers thread-based background operations, as a faster and lighter-weight alternative to the child-process-based background jobs created by Start-Job (see this answer for a juxtaposition).

As such, it is a friendlier, higher-level alternative to managing multiple threads (runspaces) via the PowerShell SDK, allowing you to use the usual job-management cmdlets to interact with the background threads.

A simple example:

# Create a synchronized (thread-safe) queue.
$threadSafeQueue = [System.Collections.Concurrent.ConcurrentQueue[string]]::new()

# Start a thread job that keeps processing elements in the queue
# indefinitely, sleeping a little between checks for new elements.
# A special element value is used to signal that processing should end.
$jb = Start-ThreadJob {
  $q = $using:threadSafeQueue # get a reference to the thread-safe queue.
  $element = $null # variable to receive queue elements
  while ($true) {
    # Process all elements currently in the queue.
    while ($q.TryDequeue([ref] $element)) {
      # Check for the signal to quit, by convention a single NUL char. here.
      if ("`0" -eq $element) { 'Quitting...'; return }
      # Process the element at hand.
      # In this example, echo the dequeued element enclosed in "[...]"
      '[{0}]' -f $element
    }
    # Queue is (now) empty, sleep a little before checking for new elements.
    Start-Sleep -MilliSeconds 100
  }
}

# Populate the queue with the numbers from 1 to 10.
1..10 | ForEach-Object {
  $threadSafeQueue.Enqueue($_) # This triggers activity in the background thread.
  # Retrieve available output from the thread job.
  $jb | Receive-Job
}

# Send the quit signal, retrieve remaining output and delete the job.
$threadSafeQueue.Enqueue("`0")
$jb | Receive-Job -Wait -AutoRemoveJob

Output:

[1]
[2]
[3]
[4]
[5]
[6]
[7]
[8]
[9]
[10]
Quitting...

See also:

  • The PowerShell (Core) v7+ Foreach-Object -Parallel feature, which similarly uses threads to process pipeline input in parallel.
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • Thanks for the advice, but I need my Script to work out of the box without adding any additional packages if feasible because I'm going to deploy it in my whole company. Installing additional packages on every single client might be a stumbling block I need to avoid for now. – GuidoT Feb 16 '22 at 20:03
  • Understood, @GuidoT. Hopefully others who do have the option to install the module or are already using PowerShell (Core) 7+ - where the solution works out of the box - will find the answer useful. – mklement0 Feb 16 '22 at 20:05