본문 바로가기

진리는어디에/C#

[C#] 비동기 프로그래밍 - Thread

이번 포스트에서는 C# 스레드 생성하고 사용하는 기본적인 방법에 대해서 살펴 보겠다. 스레드의 개념적인 부분은 이미 알고 있다고 가정하고 API 사용법에 대해서만 다룰 예정이다.

Thread 생성

아래는 메인 스레드와 사용자가 생성한 스레드에서 각각의 번호를 만번씩 출력하는 예제이다. 스레드를 통해 동시에(concurrency) 코드를 진행하므로 1과 2가 섞여서 출력 되는 것을 확인 할 수 있다.

using System;
using System.Threading;                 // Thread를 위한 네임스페이스

class Program
{
    public static void Foo()
    {
        for (int i = 0; i < 10000; i++)
        {
            Console.Write("1");
        }
    }

    public static void Main()
    {
        Thread t = new Thread(Foo);     // Thread 객체 생성
        t.Start();                      // Thread 시작

        for (int i = 0; i < 10000; i++)
        {
            Console.Write("2");
        }
    }
}
  • System.Threading 네임스페이스 사용
  • Thread t = new Thread(Foo);
    t.Start()

스레드 클래스의 생성자에 스레드가 실행 할 메소드를 넘겨 주고 Start()를 통해 스레드를 실행 시켰다.

Thread로 실행할 메소드 모양

이번 섹션에서는 앞에서 살펴 보았던 Foo 메소드 처럼 스레드게 일을 시키기 위한 메소드가 가질 수 있는 형식에 대해 알아보도록 한다.

먼저 Thread 클래스의 생성자들을 살펴 보자.

Thread 클래스 생성자 모양

public Thread(ParameterizedThreadStart start);
public Thread(ThreadStart start);
public Thread(ParameterizedThreadStart start, int maxStackSize);
public Thread(ThreadStart start, int maxStackSize);

총 네 종류의 생성자가 있다. 일단 첫번째와 두번째 생성자 부터 살펴 보자. 각각의 생성자는 ParameterizedThreadStart와 ThreadStart 델리게이트를 인자로 받는다.

maxStackSize는 스레드의 스택 사이즈를 설정한다. 아무런 값을 지정하지 않거나 0을 넘겨주게 되면 기본 스택 사이즈인 1M가 설정된다.

스레드로 수행할 메소드(delegate) 모양

public delegate void ParameterizedThreadStart(object? obj);
public delegate void ThreadStart();

위는 스레드의 실행 메소드로 사용 되는 메소드의 형태다. 리턴 타입은 void이며 ParameterizedThreadStart의 경우는 nullable object 타입[?], ThreadStart의 경우는 인자가 없다.

스레드의 실행 메소드로 사용 되어질 메소드는 아래와 같이 요약 된다.

  • 리턴 타입이 void다.
  • 메소드의 인자가 없거나, object 타입이어야 한다.

사용

using System;
using System.Threading;                 // Thread를 위한 네임스페이스

class Program
{
    public static void Foo1()               { Console.WriteLine($"Foo1()"); }
    public static void Foo2(object? obj)    { Console.WriteLine($"Foo2({obj.ToString()})"); }

    // public delegate void ParameterizedThreadStart(object? obj);
    public static void Foo3(object obj)     { Console.WriteLine($"Foo3({obj.ToString()})"); }
    public static void Foo4(string str)     { Console.WriteLine($"Foo4({str})"); }
    public static void Foo5(int a, int b)   { Console.WriteLine($"Foo5({a}, {b})"); }
    public static void Main()
    {
        // void ThreadStart();
        Thread t1 = new Thread(Foo1); t1.Start();

        // void ParameterizedThreadStart(object? obj);
        Thread t2 = new Thread(Foo2); t2.Start("Hello World");

        // void ParameterizedThreadStart(object? obj);
        Thread t3 = new Thread(Foo3); t3.Start("Hello World");

        // void ThreadStart();
        Thread t4 = new Thread(() => Foo4("Hello World")); t4.Start();

        // void ParameterizedThreadStart(object? obj);
        Thread t5 = new Thread((arg) => Foo4((string)arg)); t5.Start("Hello World");

        // void ThreadStart();
        Thread t6 = new Thread(() => Foo5(1, 2)); t6.Start();
    }
}

위 예제는 스레드를 생성하고 실행하는 다양한 방법들을 보여주고 있다. 주목해서 보아야 할 부분은 Foo4를 사용하는 25라인이다. 스레드 메소드의 인자는 없거나 object 타입어야 하는데 Foo4는 string 타입을 인자로 받고 있다.

Foo4를 스레드 생성자에 바로 넘겨 주게 되면 컴파일 오류가 발생한다. 이럴 땐 람다 표현식을 이용해 인자 없는 메소드 처럼 만들고 람다 내부에서 Foo4를 호출하는 방법이 있다. 아니면 28라인 처럼 object 타입을 인자로 받을 수 있는 람다를 만든 뒤에 내부에서 캐스팅해서 사용하는 방법도 있다.

31라인의 Foo5도 같은 연장선에서 생각하면 된다.

메소드의 모양이 다른 경우, 람다 표현식을 사용해서 전달

람다 표현식 사용시 주의 할 점

using System;
using System.Threading;

class Program
{
    public static void Foo(int n)
    {
        Console.Write($"{n} ");
    }

    public static void Main()
    {
        for (int i = 0; i < 10; i++)
        {
            Thread t = new Thread(() => Foo(i));
            t.Start();
        }
    }
}

// OUTPUT :
// 4 8 10 6 5 2 7 3 2 9

위 예제의 OUTPUT을 보면 1은 출력 되지 않고 대신 2가 두번 중복해서 출력 됨을 확인 할 수 있다. 심지어 출력되서는 안되는 10도 출력 되었다(이건 실행 할 때 마다 랜덤으로 결과가 달라진다. 여러분이 실행 했을 때는 다른 결과가 나올 수도 있다).

이는 람다 표현식에서 지역 변수를 캡쳐 할 때, 특히 위 예제 처럼 for 루프 내에서 계속 되는 변경 되는 변수를 캡쳐하여 스레드의 인자로 넘겨 줄때 발생한다. 지역 변수의 캡쳐에 관련된 내용은 따로 포스팅 하도록하겠다. 지금은 람다 표현식에서 계속 변경 되는 지역 변수를 캡쳐 해야 할 때는 아래 처럼 임시 변수를 만들어 캡쳐 해야 한다는 것만 기억 해두록 하자.

    public static void Main()
    {
        for (int i = 0; i < 10; i++)
        {
            int temp = i;   // 임시 변수를 만들어 캡쳐
            Thread t = new Thread(() => Foo(temp));
            t.Start();
        }
    }

// OUTPUT :
// 6 4 7 0 1 5 9 8 2 3

위와 같이 수정 후 실행하면 스레드 작업이라 순서는 꼬였을지라도 0부터 9까지의 값이 제대로 출력 되는 것을 확인 할 수 있다.

Thread 속성

using System;
using System.Threading;

class Program
{
    public static void Foo()
    {
        // 스레드 대기
        Thread.Sleep(3000);

        // 스레드 자신에 대한 참조
        Console.WriteLine($"thread id:{Thread.CurrentThread.ManagedThreadId}");
    }

    public static void Main()
    {
        Thread t = new Thread(Foo);
        t.Start();

        // 스레드 아이디 얻기 ManagedThread
        Console.WriteLine($"thread id:{t.ManagedThreadId}");

        // 스레드 이름 설정(디버깅 용도로 사용, 한번만 설정 가능)
        t.Name = "Worker";

        // 스레드 실행 여부 체크
        if (true == t.IsAlive)
        {
            Console.WriteLine($"it is still alive({t.ManagedThreadId})");
        }

        // 백그라운드 스레드 설정
        t.IsBackground = true;

        //스레드 종료까지 대기
        t.Join();
    }
}
  • 지정된 시간 동안 스레드 멈추기
    Thread.Sleep(3000)
  • 스레드 자신에 대한 참조 얻기
    Thread.CurrentThread
  • 스레드 이름 설정(디버깅 용도로 사용, 한번만 설정 가능)
    t.Name = "Worker"
  • 스래드 실행 여부 조사
    t.IsAlive
  • 백그라운드 스레드 조사 및 설정
    C#에서 프로세스의 종료 시점은 "모든 foreground thread가 종료 되었을 때"이다. 그래서 위 예제의 33라인과 36라인을 주석처리하고 실행하게 되면 스레드 t가 작업을 완료 할 때까지 프로세스는 종료 되지 않고 대기한다. 하지만 t를 백그라운드 스레드로 변경(33라인)하고 실행하게 되면 유일한 foreground 스레드인 메인스레드가 완료되는 즉시 t와는 관계없이 프로세스가 종료 된다.
  • 스레드가 종료 될때 까지 프로세스 블로킹
    t.Join()

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!