.NET/C#

[C#] 멀티태스킹(Multitasking) - 1. 프로세스(process), 스레드(thread), 태스트(task) 그리고 성능모니터링

클리엘 2022. 7. 6. 12:44
728x90

멀티태스킹은 같은 시간에 더 많은 성능 향상과 확장성 그리고 Application을 통한 사용자 생산성을 향상할 수 있는 방안을 제공해 줄 수 있습니다.

 

1. 프로세스(process), 스레드(thread) 그리고 태스트(task)

 

프로세스는 하나의 프로그램이 될 수 있습니다. 반드시 하나의 프로그램이 하나의 프로세스가 된다는 것은 아니지만(필요하다면 하나의 프로그램에서 여러 프로세스를 생성할 수 있으므로) 통상 OS 단에서는 메모리에 할당되어 현재 실행 중인 프로그램을 하나의 프로세스로 관리하고 있습니다.

 

스레드는 프로그램 안에서 코드를 실행하는 주체를 의미합니다. 기본적으로 하나의 프로세스는 하나의 스레드를 가지는데 이 말은 한 번에 하나씩의 코드만 실행할 수 있다는 얘기가 되므로 같은 시간에 여러 작업이 동시에 실행되어야 하는 순간에는 문제가 될 수 있습니다.

 

Windows와 같은 OS는 선점적 멀티태스팅(preemptive multitasking)을 사용하는데 이는 마치 여러 프로세스가 병렬적으로 실행되고 있는 것처럼 동작합니다. 하지만 스레드마다 처리 시간을 할당하고 할당된 시간마다 각 스레드를 순회하면서 실행하는 것이 본질입니다. 시간이 다 되면 동작중인 스레드는 중지되고 이후 다른 스레드를 실행할 것입니다.

 

OS가 현재 스레드의 실행을 다른 스레드의 동작으로 전환하는 순간에는 현재 스레드의 Context가 저장되고 스레드 큐(thread queue)에서 이전에 저장된 스레드의 Context를 로드하게 됩니다. 이러한 과정으로 인해 해당 작업이 순환되려면 시간과 리소스 모두를 필요로 하게 되는 것입니다.

 

프로세서는 하나의 주 스레드(UI 스레드)가 있고 백그라운드(Backround)에서 실행 가능한 여러 작은 작업들이 존재할 때 ThreadPool클래스를 사용하여 작은 작업으로 구현된 메서드가 queue에 저장된 위치를 가리키는 델리게이트(delegate) 인스턴스를 추가하면 이들은 자동적으로 ThreadPool에 Thread로 할당됩니다.

 

그러나 Thread를 무작정 늘리는 것은 좋지 않습니다. 실제 Task에 따라 Task를 수행하기 위한 Thread의 수를 2배로 늘린다고 해서 Task를 완료하는 데 걸리는 시간이 그만큼 줄어들지는 않는데 경우에 따라 오히려 Task의 시간을 늘릴 수 있으므로 주의해야 합니다. Thread가 늘어난다고 해서 그 만큼 성능이 증가할 거란 추정은 금물입니다.

 

2. 성능 및 리소스 사용률 모니터링

 

실제 Application을 구현하는 데 사용된 모든 코드에 대한 성능 변경 이전에 현재를 기초로 한 기본적인 성능 측정이 필요할 수 있습니다.

 

(1) 성능 및 메모리 사용 진단 모니터링

 

System.Diagnostics에는 Code를 진단하기 위한 다양한 type들이 존재하는데 그중에서 Stopwatch를 사용하면 기본적인 성능을 간단하게 확인할 수 있습니다.

 

성능 측정을 위해서 Stopwatch는 대략 아래 메서드와 속성이 사용될 수 있습니다.

메서드 설명 속성 설명
Restart 측정된 시간을 초기화 하고 타이머를 다시 시작합니다. Elapsed TimeSpan형식(시간:분:초)으로 측정된 시간을 나타냅니다.
Stop 측정을 위한 타이머를 중지합니다. ElapsedMilliseconds 측정된 시간을 Int64형식의 밀리세컨드로 나타냅니다.

메모리 사용률을 확인하기 위한 Process에서는 아래 속성을 통해 확인할 수 있습니다.

VirtualMemorySize64 현재 프로세스에 할당된 가상메모리를 바이트단위로 표시합니다.
WorkingSet64 현재 프로세스에 할당된 물리적메모리를 바이트단위로 표시합니다.

이제 위에서 언급한 Stopwatch와 Process를 사용해 실제 성능 측정을 위한 코드를 만들고 사용해 보려면 일단 성능측정을 위한 코드를 별개의 type으로 구현합니다.

using System.Diagnostics; // Stopwatch 사용
using static System.Diagnostics.Process; // Process 사용

public static class MyDiagnostics
{
    private static Stopwatch _timer = new();
    private static long _beforePhysicalMemory = 0;
    private static long _beforeVirtualMomory = 0;

    public static void Start()
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        
        _beforePhysicalMemory = GetCurrentProcess().WorkingSet64; 
        _beforeVirtualMomory = GetCurrentProcess().VirtualMemorySize64; 

        _timer.Restart();
    }

    public static void Stop()
    {
        _timer.Stop();
    
        long afterPhysicalMomory = GetCurrentProcess().WorkingSet64;
        long afterVirtualMomory = GetCurrentProcess().VirtualMemorySize64;
            
        Console.WriteLine($"사용된 메모리 : {afterPhysicalMomory - _beforePhysicalMemory:N0}");
        Console.WriteLine($"사용된 가상 메모리 : {afterVirtualMomory - _beforeVirtualMomory:N0}");
    
        Console.WriteLine($"측정된 시간 : {_timer.Elapsed}");
    }
}

MyDiagnostics의 Start() 메서드가 호출되면 어디에서도 참조되지 않지만 여전히 해제되지 않은 메모리를 위해 2번의 garbage collection을 강제하고 현재의 물리적 메모리와 가상 메모리 용량을 확인한 뒤 성능 측정을 위한 타이머를 시작하게 됩니다.

 

그리고 타이머가 종료되면 다시 현재 사용 중인 물리적 메모리와 가상메모리 용량을 확인하고 처음에 확인했던 용량의 계산을 통해 Application수행에 사용된 최종 메모리용량을 확인합니다. 또한 타이머를 통해 측정됐던 실행시간도 같이 표시합니다.

 

이제 성능 측정을 위한 코드가 있는 부분에서는 위에서 만들어둔 type을 아래와 같이 사용할 수 있습니다.

MyDiagnostics.Start();

//메모리 사용율 증가
int[] array = Enumerable.Range(1, 10000).ToArray();

//동작시간 증가
Thread.Sleep(new Random().Next(5, 10) * 1000);

MyDiagnostics.Stop();

위 진단 프로그램을 실행하면 대략 다음과 비슷한 결과를 얻을 수 있습니다.

사용된 메모리 : 651,264
사용된 가상 메모리 : 446,464
측정된 시간 : 00:00:06.0067626

(2) Benchmark.NET

 

Benchmark.NET는 NuGet Package에서 내려받을 수 있는 .NET을 위한 성능 측정 도구이며 마이크로소프트는 성능 향상에 관련한 Post를 작성하는데 해당 도구를 사용하기도 했습니다.

 

Benchmark.NET를 사용하기 위해 프로젝트 파일(csproj)에 다음 패키지를 추가합니다.

<ItemGroup>
  <PackageReference Include="BenchmarkDotNet" Version="0.13.1" />
</ItemGroup>

그리고 아래와 같이 실제 성능을 테스트할 메서드를 모아 하나의 클래스로 생성합니다.

using BenchmarkDotNet.Attributes;

public class MyDiagnostics
{
    int[] _n;

    public MyDiagnostics()
    {
        _n = Enumerable.Range(1, 20).ToArray();
    }

    [Benchmark(Baseline = true)]
    public string StringTest()
    {
        string s = string.Empty;
        
        for (int i = 0; i < _n.Length; i++)
        {
            s += _n[i] + ",";
        }

        return s;
    }

    [Benchmark]
    public string StringBuilderTest()
    {
        System.Text.StringBuilder builder = new();
        
        for (int i = 0; i < _n.Length; i++)
        {
            builder.Append(_n[i]);
            builder.Append(",");
        }
        
        return builder.ToString();
    }
}

예제는 2개의 메서드를 테스트하는데 2개 메서드 모두 20개의 정수 배열을 만들어 이들을 문자열로 결합하는 동작을 수행합니다. 2개의 메서드의 차이는 문자열 결합에서 하나는 string을 사용한 것이며 다른 하나는 StringBuilder를 사용한 것입니다.

 

실제 테스트할 메서드가 완성되면 테스트하고자 하는 메서드에 [Benchmark(Baseline = true)] Attribute를, 비교대상이 될 메서드에는 [Benchmark]를 사용합니다.

 

위와 같이 테스트할 코드가 마련되면 해당 코드를 불러와 성능 테스트할 수 있도록 BenchmarkRunner의 Run() 메서드를 호출합니다.

using BenchmarkDotNet.Running;

BenchmarkRunner.Run<MyDiagnostics>();

단, 위 코드를 실행할 때는 Debug가 아닌 Release모드로 실행해야 합니다.

dotnet run --configuration Release

코드를 실행하면 최종적으로 보고서 형식이 파일과 여러 측정 결과를 화면에 표시하게 됩니다. 이 중에서 특히 Summary부분을 보면

// * Summary *

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19044.1766 (21H2)
Intel Core i7-8700 CPU 3.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET SDK=6.0.301
[Host] : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT
DefaultJob : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT


| Method | Mean | Error | StdDev | Ratio |
|------------------ |---------:|--------:|--------:|------:|
| StringTest | 454.1 ns | 2.58 ns | 2.42 ns | 1.00 |
| StringBuilderTest | 244.0 ns | 0.81 ns | 0.76 ns | 0.54 |

메서드의 실행을 완료하는 데 걸리는 시간이 표시됨을 알 수 있으며 Outliers에서는 string을 사용한 문자열 연결방식이 StringBuilder보다 더 느리다는 것뿐 아니라 얼마나 걸릴지에 대한 시간도 일관성이 없음을 나타내고 있습니다.

Outliers
MyDiagnostics.StringBuilderTest: Default -> 1 outlier was detected (245.96 ns)

 

728x90