about_Thread_Jobs — PowerShell 스레드 기반 작업

about_Thread_Jobs — PowerShell 스레드 기반 작업

PowerShell에서 명령어를 "동시에" 돌리고 싶을 때 가장 가볍고 빠르게 쓸 수 있는 방법이 스레드 작업(thread job)이에요. 같은 프로세스 안에서 스레드를 따로 띄워 명령을 실행하는 방식이라, 아직 안 써 보셨다면 이 글에서 하나씩 짚어 드릴게요.

출처: PowerShell 공식 문서 — about_Thread_Jobs

본문

짧게 설명하면

PowerShell의 스레드 기반 작업에 대한 정보를 다루는 문서예요. 스레드 작업은 현재 세션 프로세스 안에서 별도의 스레드로 명령이나 식을 실행하는 일종의 백그라운드 작업입니다.

자세히 살펴보면

PowerShell은 작업(job)을 통해서 명령과 스크립트를 동시에 실행해요. PowerShell이 동시성을 지원하기 위해 제공하는 작업 유형은 세 가지예요.

  • RemoteJob — 명령과 스크립트가 원격 세션에서 실행돼요. 자세한 내용은 about_Remote_Jobs를 참고하세요.
  • BackgroundJob — 명령과 스크립트가 로컬 컴퓨터의 별도 프로세스에서 실행돼요. 자세한 내용은 about_Jobs를 참고하세요.
  • PSTaskJob 또는 ThreadJob — 명령과 스크립트가 로컬 컴퓨터의 같은 프로세스 안에서 별도의 스레드로 실행돼요.

스레드 기반 작업은 원격 작업이나 백그라운드 작업만큼 견고하지는 않아요. 같은 프로세스 안에서 서로 다른 스레드로 실행되거든요. 만약 한 작업에서 프로세스를 죽이는 치명적 오류가 나면, 그 프로세스 안의 다른 작업까지 전부 종료돼요.

하지만 그 대신 오버헤드가 훨씬 적어요. 리모팅 계층이나 직렬화(serialization)를 쓰지 않으니까요. 결과 객체도 현재 세션의 "살아 있는" 객체에 대한 참조(ref)로 그대로 돌아와요. 이 오버헤드가 없다 보니 스레드 기반 작업은 다른 작업 유형보다 더 빠르고, 리소스도 덜 써요.

중요 — 작업을 만든 부모 세션은 작업 상태를 계속 감시하고 파이프라인 데이터를 수집해요. 작업 자식 프로세스는 작업이 완료 상태에 도달하면 부모 프로세스에 의해 종료돼요. 만약 부모 세션이 종료되면, 실행 중인 모든 자식 작업이 그 자식 프로세스들과 함께 종료됩니다.

이 상황을 우회하는 방법은 두 가지가 있어요.

  • Invoke-Command를 사용해 **연결이 끊긴 세션(disconnected session)**에서 실행되는 작업을 만든다. 자세한 내용은 about_Remote_Jobs를 참고하세요.
  • 작업 대신 Start-Process새 프로세스를 만든다. 자세한 내용은 Start-Process를 참고하세요.

스레드 기반 작업 시작하고 관리하기

스레드 기반 작업을 시작하는 방법은 두 가지예요.

  • Start-ThreadJob — ThreadJob 모듈에서 제공
  • ForEach-Object -Parallel -AsJob — 병렬 기능은 PowerShell 7.0에서 추가됨

스레드 기반 작업을 관리할 때는 about_Jobs에서 설명하는 것과 같은 Job cmdlet을 그대로 사용하면 돼요.

Start-ThreadJob 사용하기

ThreadJob 모듈은 PowerShell 6에서 처음 함께 제공됐어요. Windows PowerShell 5.1에서는 PowerShell Gallery에서 설치할 수도 있어요.

로컬 컴퓨터에서 스레드 작업을 시작하려면 Start-ThreadJob cmdlet에 중괄호({ })로 감싼 명령이나 스크립트를 전달하면 돼요.

다음 예제는 로컬 컴퓨터에서 Get-Process 명령을 실행하는 스레드 작업을 시작해요.

Start-ThreadJob -ScriptBlock { Get-Process }

Start-ThreadJob 명령은 실행 중인 작업을 나타내는 ThreadJob 객체를 반환해요. 이 작업 객체에는 현재 실행 상태를 포함해 작업에 대한 유용한 정보가 담겨 있어요. 결과가 생성되는 대로 그 결과를 수집하기도 하죠.

ForEach-Object -Parallel -AsJob 사용하기

PowerShell 7.0에서 ForEach-Object cmdlet에 새로운 매개 변수 세트가 추가됐어요. 이 새 매개 변수를 쓰면 스크립트블록을 PowerShell 작업으로 병렬 스레드에서 실행할 수 있어요.

ForEach-Object -Parallel에 데이터를 파이프하면, 그 데이터가 병렬로 실행되는 스크립트블록으로 전달돼요. -AsJob 매개 변수는 병렬 스레드 각각에 대해 작업 객체를 만들어요.

다음 명령은 명령으로 파이프된 각 입력 값에 대해 자식 작업을 포함하는 작업을 시작해요. 각 자식 작업은 파이프된 입력 값을 인자로 받아 Write-Output 명령을 실행하지요.

1..5 | ForEach-Object -Parallel { Write-Output $_ } -AsJob

ForEach-Object -Parallel 명령은 파이프된 각 입력 값에 대한 자식 작업을 포함하는 PSTaskJob 객체를 반환해요. 이 작업 객체에는 자식 작업들의 실행 상태에 대한 유용한 정보가 담겨 있어요. 결과가 생성되는 대로 수집하기도 하죠.

작업 완료를 기다리고 결과 가져오기

Wait-JobReceive-Job 같은 PowerShell 작업 cmdlet을 사용해서 작업이 완료될 때까지 기다린 뒤, 작업이 생성한 모든 결과를 돌려받을 수 있어요.

다음 명령은 Get-Process 명령을 실행하는 스레드 작업을 시작하고, 명령이 완료될 때까지 기다린 다음, 마지막으로 명령이 생성한 모든 데이터 결과를 반환해요.

Start-ThreadJob -ScriptBlock { Get-Process } | Wait-Job | Receive-Job

다음 명령은 파이프된 각 입력에 대해 Write-Output 명령을 실행하는 작업을 시작하고, 모든 자식 작업이 완료될 때까지 기다린 다음, 마지막으로 자식 작업들이 생성한 모든 데이터 결과를 반환해요.

1..5 | ForEach-Object -Parallel { Write-Output $_ } -AsJob | Wait-Job | Receive-Job

Receive-Job cmdlet은 자식 작업들의 결과를 반환해요.

1
3
2
4
5

각 자식 작업이 병렬로 실행되기 때문에, 만들어지는 결과의 순서는 보장되지 않아요.

스레드 작업 성능

스레드 작업은 다른 유형의 작업보다 빠르고 가벼워요. 하지만 작업이 하는 일에 비하면 클 수 있는 오버헤드가 여전히 있어요.

PowerShell은 세션 안에서 명령과 스크립트를 실행해요. 한 세션에서는 한 번에 하나의 명령이나 스크립트만 실행될 수 있어요. 그래서 여러 작업을 실행할 때는 각 작업이 별도의 세션에서 실행되죠. 세션 하나하나가 오버헤드에 기여합니다.

스레드 작업은 작업이 수행하는 일이 그 작업을 실행하는 데 쓰인 세션의 오버헤드보다 클 때 가장 좋은 성능을 내요. 이 기준을 충족하는 경우는 두 가지가 있어요.

  • 계산이 집중적인 작업(compute intensive) — 여러 스레드 작업으로 스크립트를 실행하면 멀티프로세서 코어의 장점을 활용해 더 빨리 끝낼 수 있어요.
  • 대기를 많이 하는 작업 — I/O나 원격 호출 결과를 기다리는 데 시간을 쓰는 스크립트는, 순차적으로 실행할 때보다 병렬로 실행하면 보통 더 빨리 끝나요.
(Measure-Command {
1..1000 | foreach { Start-ThreadJob { Write-Output "Hello $Using:_" } } | Receive-Job -Wait
}).TotalMilliseconds
36860.8226
(Measure-Command {
1..1000 | ForEach-Object { "Hello: $_" }
}).TotalMilliseconds
7.1975

첫 번째 예제는 foreach 루프로 간단한 문자열 쓰기를 하는 스레드 작업 1000개를 만드는 모습이에요. 작업 오버헤드 때문에 완료에 36초가 넘게 걸려요.

두 번째 예제는 ForEach-Object cmdlet으로 같은 1000개의 연산을 수행해요. 이번에는 ForEach-Object단일 스레드에서 작업 오버헤드 없이 순차적으로 실행되죠. 불과 7밀리초 만에 끝나요.

그다음 예제는 10개의 시스템 로그에서 각각 최대 5000개의 항목을 수집하는 경우예요. 스크립트가 여러 로그를 읽는 일을 하니, 연산을 병렬로 처리하는 게 말이 되지요.

$logNames.Count
10
Measure-Command {
$logs = $logNames | ForEach-Object {
Get-WinEvent -LogName $_ -MaxEvents 5000 2>$null
}
}
TotalMilliseconds : 252398.4321 (4 minutes 12 seconds)
$logs.Count
50000

작업을 병렬로 실행하면 스크립트가 절반의 시간에 끝나요.

Measure-Command {
$logs = $logNames | foreach {
Start-ThreadJob {
Get-WinEvent -LogName $Using:_ -MaxEvents 5000 2>$null
} -ThrottleLimit 10
} | Wait-Job | Receive-Job
}
TotalMilliseconds : 115994.3 (1 minute 56 seconds)
$logs.Count
50000

스레드 작업과 변수

스레드 기반 작업 안으로 값을 전달하는 방법은 여러 가지가 있어요.

Start-ThreadJob은 cmdlet으로 파이프된 변수, Using: 범위 한정자(scoped modifier)로 스크립트블록에 전달된 변수, 또는 ArgumentList 매개 변수로 전달된 변수를 받아들일 수 있어요.

$msg = "Hello"
$msg | Start-ThreadJob { $input | Write-Output } | Wait-Job | Receive-Job
Start-ThreadJob { Write-Output $Using:msg } | Wait-Job | Receive-Job
Start-ThreadJob { param ([string] $Message) Write-Output $Message } -ArgumentList @($msg) |
Wait-Job | Receive-Job

ForEach-Object -Parallel은 파이프로 들어온 변수와 Using: 범위 한정자를 통해 스크립트블록에 직접 전달된 변수를 받아들여요.

$msg = "Hello"
$msg | ForEach-Object -Parallel { Write-Output $_ } -AsJob | Wait-Job | Receive-Job
1..1 | ForEach-Object -Parallel { Write-Output $Using:msg } -AsJob | Wait-Job | Receive-Job

스레드 작업은 같은 프로세스 안에서 실행되기 때문에, 작업 안으로 전달되는 참조형 변수는 조심해서 다뤄야 해요. 스레드에 안전한(thread safe) 객체가 아니라면 그 변수에 값을 할당해서도 안 되고, 메서드나 속성을 호출해서도 안 돼요.

다음 예제는 스레드에 안전한 .NET ConcurrentDictionary 객체를 모든 자식 작업에 전달해서, 고유한 이름을 가진 프로세스 객체를 수집해요. 스레드에 안전한 객체이기 때문에 작업들이 프로세스 안에서 동시에 실행되는 동안 안전하게 사용할 수 있어요.

$threadSafeDictionary = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()
$jobs = Get-Process | foreach {
Start-ThreadJob {
$proc = $Using:_
$dict = $Using:threadSafeDictionary
$dict.TryAdd($proc.ProcessName, $proc)
}
}
$jobs | Wait-Job | Receive-Job
$threadSafeDictionary.Count
96
$threadSafeDictionary["pwsh"]
NPM(K)  PM(M)   WS(M) CPU(s)    Id SI ProcessName
------  -----   ----- ------    -- -- -----------
112  108.25  124.43  69.75 16272  1 pwsh

더 알아보기