상세 컨텐츠

본문 제목

[C#] Thread Synchronization C#스레드 안전화의 모든것

C#

by 메타샤워 2023. 7. 27. 18:12

본문




Thread Synchronization

스레드 동기화 Thread-Safe

한 메서드를 다수의 스레드가 동시에 실행하고 그 메서드에서 클래스 객체의 필드들을 읽거나 쓸때, 다수의 스레드가 동시에 필드값들을 변경할수 있게 됩니다. 객체의 필드값은 모든 스레드가 자유롭게 엑세스할 수 있기 때문에, 메서드 실행 결과가 잘못될 가능성이 큽니다. 이렇게 스레드들이 공유된 자원을 동시에 접근하는것을 박고, 각 스레드들이 순차적,제한적으로 접근하도록 하는것을 스레드 동기화(Thread Synchronization)이라고 합니다. 또한 이렇게 ㅅ레드 동기화를 구현한 메서드나 클래스를 Thread-Safe하다 라고 합니다. .NET의 많은 클래스들은 Thread-Safe하지 않은데, Thread-Safe를 구현하려면 Locking 오버헤드 보다 많은 코딩을 요구합니다. 실제 실무에서 이러한 Thread-Safe를 필요로 하지 않은 경우가 더 많기 때문입니다.

스레드 동기화를 위한 .NET 클래스들

스레드 동기화를 위해서 .NET에는 많은 클래스와 메서드들이 있습니다. 이중 중요한 것들로는Monitor,Mutex,Semaphore,SpinLock,ReaderWriterLock,AutoResetEvent등이 있으며, C#키워드로는 lock, await등이 있습니다. 스레드 동기화를 위해 자주 사용되는 방식으로서, Locking으로 공유 리소스에 대한 접근을 제한하는 방식으로 C# lock,Monitor,Mutex,Semaphore,SpinLock,ReaderWriterLock 등이 사용되며, 타 스레드에 신호를 보내 스레드 흐름을 제어하는 방식으로 AutoResetEvent,ManualResetEvent,CountdownEvent등이 있습니다.

lock 키워드에 의한 블럭

Thread-Unsafe

아래 예제는 여러 개의 스레드가 Thread-Safe하지 않은 메서드를 호출하는 예를 보여주고 있습니다. 10개의 스레드가 counter라는 필드를 동시에 쓰거나 읽는 샘플로서 한 스레드가 counter변수를 변경하고 있기 전에 다른 스레드가 다시 counter변수를 변경할 수 있기 때문에 불확실한 결과가 출력되게 될겁니다.

 

using System;
using System.Threading; 
namespace George
{ 
    class MyClass 
    { 
        private int counter = 0; 
        public void Run() 
        { 
            // 10개의 스레드가 동일 메서드 실행 
            for (int i = 0; i < 10; i++) 
            { 
                new Thread(UnsafeCalc).Start(); 
            } 
        } 
        // Thread-Safe하지 않은 메서드 
        private void UnsafeCalc() 
        { 
            // 객체 필드를 모든 스레드가 자유롭게 변경 
            counter++; 
            // 가정 : 다른 복잡한 일을 한다 
            for (int i = 0; i < 10000; i++) 
                for (int j = 0; j < 10000; j++); 
            // 필드값 읽기 
            Console.WriteLine(counter); 
        } 
    }
} 
 
/* 출력 결과 
5
5 
7 
6 
7 
8 
9 
10
10 
10 
*/

 

lock 키워드

C#의 lock 키워드는 특정 블럭의 코드를 임계영역으로 만들어 한번에 하나의 스레드만 실행할 수 있도록 해줍니다. lock()파라미터에는 임의의객체를 사용할 수 있는데 , 주로 object 타입의 private필드를 지정합니다. lock(this)와 같이 클래스 객체 전체를 지정하는 this를 사용할 수도 있는데, 이는 불필요하게 모든 클래스 객체를 잠그는 효과가 있어, object타입의 필드를 만들어 사용하는것이 좋습니다. 임계영역 코드블럭은 가능한한 범위를 작게 하는 것이 좋은데, 필요한 부부만 Locking 한다는 스레드 동기화 설계 원칙에 의거한 것입니다.

using System;
using System.Threading; 
namespace George 
{ 
    class MyClass 
    { 
        private int counter = 0; 
        // lock문에 사용될 객체 
        private object lockObject = new object(); 
        public void Run() 
        { 
            // 10개의 스레드가 동일 메서드 실행 
            for (int i = 0; i < 10; i++) 
            { 
                new Thread(SafeCalc).Start(); 
            } 
        } 
        // Thread-Safe 메서드 
        private void SafeCalc() 
        { 
            // 한번에 한 스레드만 lock블럭 실행 
            lock (lockObject) 
            { 
                // 필드값 변경 
                counter++; 
                // 가정 : 다른 복잡한 일을 한다 
                for (int i = 0; i < 10000; i++) 
                    for (int j = 0; j < 10000; j++); 
                // 필드값 읽기 
                Console.WriteLine(counter); 
            } 
        } 
    }
}
//출력 예: 
// 1 
// 2 
// 3 
// 4 
// 5
// 6 
// 7 
// 8
// 9 
// 10

 

Monitor 클래스에 의한 동기화

Monitor클래스는 C#의 lock과 같이 특정 코드 블럭을 임계영역으로 만들어 배타적으로 Locking하는 기능을 가지고 있습니다. Monitor.Enter()메서드는 임계영역 블럭을 시작하여 한 스레드만 블럭으로 들어가게 하며, Monitor.Exit()는 Locking을 해제하여 다음 스레드가 임계영역 블럭을 실행하게 합니다. C# lock키워드는 실제로는 Monitor.Enter()와 Monitor.Exit()를 사용하여 임계영역을 try finally문으로 감싼 문장들로 컴파일시 코드를 변경하는 것입니다. 즉, 자주 사용되는 Monitor.Enter() ~ Exit()를 간략하게 표현한것이 lock 키워드입니다.

 

class MyClass
{    
    private int counter = 0;
    // lock문에 사용될 객체
    private object lockObject = new object();
    public void Run()
    {
        // 10개의 스레드가 동일 메서드 실행
        for (int i = 0; i < 10; i++)
        {
            new Thread(SafeCalc).Start();
        }
    }
    // Thread-Safe하지 않은 메서드
    private void SafeCalc()
    {
        // 한번에 한 스레드만 lock블럭 실행
        Monitor.Enter(lockObject);
        try
        {
            // 필드값 변경
            counter++;
            // 가정 : 다른 복잡한 일을 한다
            for (int i = 0; i < 10000; i++) 
                for (int j = 0; j < 10000; j++);
            // 필드값 읽기
            Console.WriteLine(counter);
       }
       finally
       {
            Monitor.Exit(lockObject);
       }
   }
}

 

Monitor의 Wait()와 Pulse()

Monitor클래스의 또 다른 중요한 메서드로는 Wait()와 Pulse() / PulseAll()이 있습니다. Wait()메서드는 현재 스레드를 잠시 중지하고 lock을 해제한 후, 다른 스레드로부터 Pulse신호가 올때까지 기다립니다. Wait에서 Lock이 해제되었으므로 다른 스레드가 lock을 획득하고 작업을 실행할수 있습니다. 다른 스레드가 자신의 작업을 마치고 Pulse()메서드를 호출하면 대기중인 스레드는 lock을 획득하고 계속 작업을 실행하게 됩니다. Pulse()메서드가 호출되었을때, 만약 대기중인 스레드가 있다면 , 그 스레드가 계속 실행하게 되지만 만약 대기중인 스레드가 없다면 Pulse신호는 없어집니다. 이러한 Wait/Pulse는 개념적으로 AutoResetEvent과 같은 이벤트 개념과 비슷하다 하지만, AutoResetEvent는 Set()메서드로 펄스신호를 보내는데 대기중인 스레드가 없는 경우 하나의 Pulse 신호가 있었다는 것을 계속 가지고 있게 됩니다. Pulse() 메서드는 다음 한개의 스레드만 계속 실행하게 하지만 PulseAll()메서드는 현대 대기중인 모든 스레드를 풀어 실핼하게 됩니다. Monitor 클래스의 Wait(), Pulse() 메서드를 사용하기 위해 한가지 전제조건이 있는데 이 메서드들이 lock블럭 안에서 호출되어야 한다는 것입니다. 아래 예제는 10개의 작업 스레드들이 데이타를 Queue에 집어넣고, 하나의 일기 스레드가 데이타를 계속 Queue에서 꺼내오는 샘플입니다.

 

class Program
{
    static Queue Q = new Queue();
    static object lockObj = new object();
    static bool running = true;
    static void Main(string[] args)
    {
        // reader 스레드 시작
        Thread reader = new Thread(ReadQueue);
        reader.Start();
        // writer 스레드들 시작
        List<Thread> thrds = new List<Thread>();
        for (int i = 0; i < 10; i++)
        {
            var t = new Thread(new ParameterizedThreadStart(WriteQueue));
            t.Start(i);
            thrds.Add(t);
        }
        // 모든 writer가 종료될 때까지 대기
        thrds.ForEach(p => p.Join());
        // reader 종료
        running = false;
    }
    static void WriteQueue(object val)
    {
        lock (lockObj)
        {
            Q.Enqueue(val);
            Console.WriteLine("W:{0}", val);
            Monitor.Pulse(lockObj);
        }
    }
    static void ReadQueue()
    {
        while (running)
        {
            lock (lockObj)
            {
                while (Q.Count == 0)
                {
                    Monitor.Wait(lockObj);
                }
                for (int i = 0; i < Q.Count; i++)
                {
                    int val = (int)Q.Dequeue();
                    Console.WriteLine("R:{0}", val);
                }
            }
        }
    }
}

Mutex 클래스에 의한 동기화

 

Mutex 클래스는 monitor클래스와 같이 특정 코드 블럭을 임계영역으로 지정하여 배타적으로 Locking을 하는 기능을 가지고 있습니다. 단 Monitor클래스는 하나의 프로세스내에서 사용할 수 있는 반면 Mutex클래스는 해당 머신의 프로세스 간에서도 배타적으로 Locking을 하는데 사용된다. Mutex 락킹은 Monitor 락킹보다 약 50배 정도 느리기 때문에 한 프로세스내에서만 배타적으로 Lock이 필요한 경우는 C#의 lock이나 Monitor 클래스를 사용하는것이 바람직합니다. 아래 예제는 MyClass가 외부 프로세스에서도 사용할 수 있다는 가정하에 배타적으로 Lock으로 뮤텍스를 사용하는 예제입니다.
using System;
using System.Threading;
using System.Collections.Generic;    
namespace George
{
    class Program
    {
        static void Main(string[] args)
        {
            // 2개의 스레드 실행
            Thread t1 = new Thread(() => MyClass.AddList(10));
            Thread t2 = new Thread(() => MyClass.AddList(20));
            t1.Start();
            t2.Start();
            // 2개의 스레드 실행완료까지 대기
            t1.Join();
            t2.Join();
            // 메인스레드에서 뮤텍스 사용
            using (Mutex m = new Mutex(false, "MutexName1"))
            {
                // 뮤텍스를 취득하기 위해 10 ms 대기
                if (m.WaitOne(10))
                {
                    // 뮤텍스 취득후 MyList 사용
                    MyClass.MyList.Add(30);
                }                
                else
                {
                    Console.WriteLine("Cannot acquire mutex");
                }
            }
            MyClass.ShowList();
        }
    }
    public class MyClass
    {
        // MutexName1 이라는 뮤텍스 생성
        private static Mutex mtx = new Mutex(false, "MutexName1");
        // 데이타 멤버
        public static List<int> MyList = new List<int>();
        // 데이타를 리스트에 추가
        public static void AddList(int val)
        {
            // 먼저 뮤텍스를 취득할 때까지 대기
            mtx.WaitOne();
            // 뮤텍스 취득후 실행 블럭
            MyList.Add(val);
            // 뮤텍스 해제
            mtx.ReleaseMutex();
        }
        // 리스트 출력
        public static void ShowList()
        {
            MyList.ForEach(p => Console.WriteLine(p));
        }
    }
}

 

프로세스간 Mutex 활용

Mutex 활용에 적합한 예로서 흔히 한 머신 내에서 오직 한 응용프로그램만이 실행되도록 하는 테크닉을 들수 있습니다. 한 컴퓨터 내 한 프로세스만 실행하고자 하기 위해 고유의 Mutex명을 지정합니다. 일반적으로 이렇게 구현하기 위해 GUID를 많이 사용합니다. 처음 프로세스가 먼저 Mutex를 획득하면 다른 프로세스는 Mutex를 획득할 수 없기 때문에 오직 하나의 프로그램만 머신 내에서 실행 되는 것입니다.

class Program
{
    static void Main()
    {
        // Unique한 뮤텍스명을 위해 주로 GUID를 사용한다.
        string mtxName = "60C3D9CA-5957-41B2-9B6D-419DC9BE77DF";
        // 뮤텍스명으로 뮤텍스 객체 생성
        // 만약 뮤텍스를 얻으면, createdNew = true        
        bool createdNew;
        Mutex mtx = new Mutex(true, mtxName, out createdNew);
        // 뮤텍스를 얻지 못하면 에러
        if (!createdNew)
        {
            Console.WriteLine("에러: 프로그램 이미 실행중");
            return;
        }
        // 성공하면 본 프로그램 실행
        MyApp.Launch();    
    }
}

 

MethodImplOption.Synchronized 열거형 속성에 의한 동기화 (.NET 4.5 이상)

.NET 4.5 버전에 새롭게 추가된 메서드 속성 열거형에 포함된 MethodImpl.Option.Synchronized 옵션을 사용하여 해당 메서드의 진입을 배타적으로 Lock하는 편리한 기술이 등장하였습니다. 이 옵션을 사용하면 lock키워드나 Monitor와같은 객체가 필요없이 임계영역을 설정할수 있습니다. 편리하게 사용할 수는 있지만 메소드 전체를 임계영역으로 설정하기 때문에 메소드의 작업이 길어지면 불필요한 영역까지 배타적 Lock을 걸기때문에 사용상에 있어 민감하게 사용해야 합니다. 배타적 임계영역이 필요한 부부만 새로운 메소드로 만들어내서 해당 메소드만 MethodImplOption.Synchronized 옵션을 설정하는것이 바람직합니다.
using System;
using System.Runtime.ComplierServices; 
// 이 네임스페이스를 추가해야 메소드 속성을 부여 할수 있다.
using System.Threading; 
namespace George
{
    class MyClass
    {
        private int counter = 0;
        // lock문에 사용될 객체
        private object lockObject = new object();
        public void Run()
        {
            // 10개의 스레드가 동일 메서드 실행
            for (int i = 0; i < 10; i++)
            {
                new Thread(SafeCalc).Start();
            }
        }
        // Thread-Safe 메서드
        [MethodImpl(MethodImplOptions.Synchronized)] 
        // 메서드 속성옵션으로 스레드 동기화 부여
        private void SafeCalc()
        {
            // 필드값 변경
            counter++;
            // 가정 : 다른 복잡한 일을 한다
            for (int i = 0; i < 10000; i++)
                for (int j = 0; j < 10000; j++);
             // 필드값 읽기
            Console.WriteLine(counter);
        }
         //출력 예:
        // 1
        // 2
        // 3
        // 4
        // 5
        // 6
        // 7
        // 8
        // 9
        // 10
    }
}​

 

SemaPhore 클래스에 의한 동기화

 

SemaPhore 클래스는 공유된 리소스를 지정된 수의 스레드만 엑세스할 수 있게 허용하는데, 예를 들어 10개의 스레드들이 엑세스 하도록 허용하였다면 11번째 스레드는 현재 사용중인 10개의 스레드중 누군가 리소스 사용을 마쳐야지만 해당 리소스에 진입할 수 있습니다.

 

using System;
using System.Threading;
 
namespace George
{
    class Program
    {
        static void Main()
        {
            MyClass c = new MyClass();
 
            // 10개 스레드들 실행
            // 처음 5개만 먼저 실행되고 하나씩 해제와 함께
            // 실행될 것임.
            for (int i = 1; i <= 10; i++)
            {
                new Thread(c.Run).Start(i);
            }
        }
    }
 
    class MyClass
    {
        private Semaphore sema;
 
        public MyClass()
        {
            // 5개의 스레드만 허용
            sema = new Semaphore(5, 5);
        }
 
        public void Run(object seq)
        {
            // 스레드가 가진 데이타(일련번호)
            Console.WriteLine(seq);
 
            // 최대 5개 스레드만 아래 문장 실행
            sema.WaitOne();
 
            Console.WriteLine("Running#" + seq);
            Thread.Sleep(500);
 
            // Semaphore 1개 해체. 
            // 이후 다음 스레드 WaitOne()에서 진입 가능
            sema.Release();
 
        }
    }}
 
/*출력 결과
1
2
3
Running#3
Running#1
Running#2
5
Running#5
6
Running#6
4
7
8
9
10
Running#9
Running#4
Running#10
Running#8
Running#7
*/

'C#' 카테고리의 다른 글

[C#] Form 폼 최소화버튼 막기  (0) 2023.07.25
[C#] exe 실행파일에 DLL파일을 임베디드하여 배포 하기  (0) 2023.07.25
[C#] 일반화 프로그래밍  (0) 2023.07.25
[C#] 컬렉션 Collection  (0) 2023.07.25
[C#] 프로퍼티  (0) 2023.07.25

관련글 더보기