스레드 간 동기화는 여러 스레드가 동시에 실행될 때 발생할 수 있는 문제들을 해결하기 위한 필수적인 기술이다. 마치 여러 사람이 동시에 같은 문서를 편집할 때, 누군가가 수정한 내용을 다른 사람이 덮어쓰는 것을 방지하기 위해 문서를 잠그는 것과 같은 개념이다.
애플리케이션을 구성하는 각 스레드는 여러 자원을 공유하는 경우가 많다. 예를 들어 파일 핸들, 네트워크 커넥션, 메모리에 선언한 변수 등이 있다. 어떤 스레드가 특정 자원을 잡고 사용하고 있는데 갑자기 다른 스레드가 끼어들어 멋대로 사용해버릴 수 있다.
이러한 문제가 발생하지 않도록, 프로그래머가 스레드들이 순서를 갖춰 자원을 사용하게 하는 것을 동기화(Synchronizaion) 라고 한다.
즉, 스레드 동기화는 '자원을 한 번에 하나의 스레드가 사용하도록 보장' 하는 것이 목적이다.
여러 스레드가 동시에 같은 데이터에 접근하고 변경하면 데이터가 예상치 못한 상태로 변경될 수 있다. 이를 데이터 경쟁이라고 하며, 프로그램 오류의 주요 원인 중 하나이다. 스레드 간 동기화를 통해 데이터 경쟁을 방지하고 데이터의 일관성을 유지할 수 있다.
특정 작업들이 순서대로 실행되어야 하는 경우, 스레드 간 동기화를 통해 실행 순서를 제어할 수 있다. 예를 들어, 파일을 열고, 데이터를 쓰고, 파일을 닫는 작업은 순서대로 실행되어야 한다.
두 개 이상의 스레드가 서로 다른 자원을 잠그고, 상대방이 잠근 자원을 기다리는 상태를 교착 상태라고 한다. 교착 상태에 빠지면 프로그램이 더 이상 진행되지 못한다. 스레드 간 동기화를 통해 교착 상태를 방지할 수 있다.
데이터 경쟁을 방지하여 데이터의 일관성을 유지할 수 있다.
교착 상태를 방지하여 프로그램의 안정성을 높일 수 있다.
멀티 스레드 환경에서 안전하게 공유 자원에 접근할 수 있도록 한다.
스레드 동기화를 위해 락을 사용하면, 락을 얻기 위해 스레드가 대기해야 하는 경우 성능이 저하될 수 있다.
스레드 동기화 코드는 복잡하고 이해하기 어려울 수 있으며, 잘못 사용하면 오히려 오류를 발생시킬 수 있다.
C#에서는 다양한 방법으로 스레드 간 동기화를 구현할 수 있다.
lock 문: 특정 코드 블록을 한 번에 하나의 스레드만 실행할 수 있도록 잠그는 데 사용된다.Monitor 클래스: lock 문과 유사한 기능을 제공하지만, 더 세밀한 제어가 가능하다.Mutex 클래스: 여러 프로세스에서 공유 자원에 접근할 때 동기화를 위해 사용된다.Semaphore 클래스: 제한된 수의 스레드만 공유 자원에 접근할 수 있도록 제어하는 데 사용된다.Interlocked 클래스: 간단한 연산을 원자적으로 수행하는 데 사용된다.결론
스레드 간 동기화는 멀티 스레드 프로그래밍에서 필수적인 기술이다. 스레드 간 동기화를 통해 데이터 경쟁, 순서 문제, 교착 상태 등의 문제를 해결하고, 프로그램의 안정성과 신뢰성을 높일 수 있다.
lock 키워드lock 키워드는 C#에서 스레드 동기화를 위해 사용하는 가장 기본적인 도구이다. 마치 여러 사람이 동시에 사용하는 화장실에 잠금 장치를 거는 것과 같다.
lock 문은 특정 코드 블록을 한 번에 하나의 스레드만 실행할 수 있도록 잠그는 역할을 한다. 즉, 여러 스레드가 동시에 lock 문으로 보호되는 코드 블록에 접근하려고 할 때, 먼저 도착한 스레드가 잠금을 획득하고 코드 블록을 실행한다. 다른 스레드들은 잠금이 해제될 때까지 대기해야 한다.
lock 문은 다음과 같은 형식으로 사용한다. C#에서는 lock 키워드로 감싸주기만 해도 평범한 코드를 크리티컬 섹션으로 바꿀 수 있다.
lock (lockObject)
{
// 크리티컬 섹션 (critical section)
}
lockObject: 잠금을 걸 대상 객체이다. 일반적으로 private static readonly object 타입의 객체를 사용한다.
크리티컬 섹션: 한 번에 하나의 스레드만 실행해야 하는 코드 블록이다.
lock 키워드 사용 예시
using System;
using System.Threading;
class Counter
{
private int count = 0;
private static readonly object lockObject = new object();
public void Increase()
{
lock (lockObject) // lockObject 객체에 잠금을 겁니다.
{
count++; // count 변수를 1 증가시킵니다.
} // lock 블록을 벗어나면 잠금이 해제됩니다.
}
}
class MainApp
{
static void Main(string[] args)
{
Counter counter = new Counter();
Thread t1 = new Thread(new ThreadStart(counter.Increase));
Thread t2 = new Thread(new ThreadStart(counter.Increase));
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine(counter.count); // 200000 출력
}
}
lock 키워드의 장점lock 키워드의 단점lock 키워드 사용 시 주의 사항lock 키워드는 스레드 동기화를 위한 강력한 도구이지만, 잘못 사용하면 프로그램에 예상치 못한 문제를 일으킬 수 있다. 마치 열쇠를 잘못 사용하면 문이 잠기거나 고장 나는 것과 같다.
lockObject는 모든 스레드에서 접근 가능한 공유 객체여야 한다.lockObject는 값 형식이 아닌 참조 형식이어야 한다.lock 문 내부에서 예외가 발생하면 잠금이 자동으로 해제된다.lock 문은 가능한 짧게 유지하는 것이 좋다.lock 대상 객체는 모든 스레드에서 접근 가능해야 한다lock 문은 잠금을 걸 대상 객체를 필요로 한다. 이 객체는 모든 스레드에서 접근 가능해야 한다. 즉, 모든 스레드가 같은 객체를 사용하여 잠금을 걸어야 동기화가 제대로 이루어진다.
lock 문에 사용되는 객체는, 일반적으로 private static readonly object 타입으로 선언한다. private은 해당 클래스 내부에서만 접근 가능하도록 제한하고, static은 모든 인스턴스가 같은 객체를 공유하도록 한다. readonly는 객체가 한 번 초기화된 후에는 변경할 수 없도록 한다.
class Example
{
private static readonly object lockObject = new object();
public void MyMethod()
{
lock (lockObject)
{
// ...
}
}
}
lock 대상 객체는 참조 형식이어야 한다lock 문에 사용되는 객체는 참조 형식이어야 한다. 값 형식을 사용하면 잠금이 제대로 동작하지 않을 수 있다.
값 형식은 변수에 직접 값을 저장하는 반면, 참조 형식은 값이 저장된 메모리 주소를 저장한다. lock 문은 객체의 메모리 주소를 사용하여 잠금을 걸기 때문에, 값 형식을 사용하면 잠금이 제대로 동작하지 않는다.
lock 객체로 this, Type 인스턴스, 문자열 인스턴스는 사용하지 않는 것이 좋다. 이러한 객체는 다른 코드에서 잠금 객체로 사용될 수 있으며, 데드락(두 개 이상의 스레드가 서로 다른 자원을 잠그고, 상대방이 잠근 자원을 기다리는 상태)을 발생시킬 수 있기 때문이다. 즉, public 키워드 등을 통해 외부 코드에서도 접근 가능하므로 이들을 lock 키워드의 매개변수로 절대로 사용하지 않는 것이 좋다.
(lock(this) / lock(typeof(SomeClass)), lock(obj.GetType()) / lock("abc")같은 코드를 쓰는 행동을 하지 말아야 한다.)
// 잘못된 예시: 값 형식을 lock 대상으로 사용
int number = 10;
lock (number) // 컴파일 오류 발생
{
// ...
}
// 올바른 예시: 참조 형식을 lock 대상으로 사용
object obj = new object();
lock (obj)
{
// ...
}
lock 문 내부에서 예외가 발생하면 잠금이 자동으로 해제된다lock 문 내부에서 예외가 발생하면 finally 블록을 사용하지 않아도 잠금이 자동으로 해제된다.
lock 문은 내부적으로 try-finally 블록을 사용하여 구현된다. finally 블록에서는 Monitor.Exit() 메서드를 호출하여 잠금을 해제한다. 따라서 lock 문 내부에서 예외가 발생하면 finally 블록이 실행되어 잠금이 해제된다.
lock (lockObject)
{
try
{
// 예외가 발생할 수 있는 코드
}
catch (Exception e)
{
// 예외 처리
}
// finally 블록 없이도 lock이 해제됩니다.
}
lock 문은 가능한 짧게 유지해야 한다(꼭 필요할 때만 써야 한다)lock 문으로 보호되는 코드 블록은 가능한 짧게 유지하는 것이 좋다. 잠금을 오래 유지하면 다른 스레드의 실행을 지연시켜 성능이 저하될 수 있다.
lock 문은 스레드 동기화를 위해 사용되지만, 동시에 다른 스레드의 실행을 막는 역할도 한다. 따라서 lock 문으로 보호되는 코드 블록이 길어지면 다른 스레드들이 잠금을 얻기 위해 오래 기다려야 하고, 이는 프로그램의 성능 저하로 이어질 수 있다.
// 좋지 않은 예시: lock 블록 안에 너무 많은 코드가 포함됨
lock (lockObject)
{
// ... 많은 코드 ...
}
// 좋은 예시: lock 블록 안에 꼭 필요한 코드만 포함
lock (lockObject)
{
// 공유 자원에 접근하는 코드
}
// lock 블록 밖에서 다른 작업 수행
또 lock 문을 꼭 필요할 때만 쓰는 것이 좋다. 스레드들이 lock 키워드를 만나 크리티컬 섹션을 생성하려고 할 때, 다른 스레드들이 자기들도 크리티컬 섹션을 만들어야 하니 락을 달라고 아우성치며 대기하는데, 이런 경우 소프트웨어의 성능이 크게 떨어질 수 있다. 따라서 스레드의 동기화를 설계할 떄는 크리티컬 섹션을 반드시 필요한 곳에만 사용하는 것이 중요하다.
두 개 이상의 스레드가 서로 다른 자원을 잠그고, 상대방이 잠근 자원을 기다리는 상태를 데드락이라고 한다. 데드락에 빠지면 프로그램이 더 이상 진행되지 못한다.
데드락을 방지하려면, 모든 스레드가 같은 순서로 자원을 잠그도록 해야 한다. 예를 들어, 스레드 A와 스레드 B가 자원 1과 자원 2를 사용한다면, 두 스레드 모두 자원 1을 먼저 잠그고 자원 2를 잠그도록 해야 한다.
// 데드락 발생 가능성이 있는 코드
object lock1 = new object();
object lock2 = new object();
// 스레드 A
lock (lock1)
{
lock (lock2)
{
// ...
}
}
// 스레드 B
lock (lock2)
{
lock (lock1)
{
// ...
}
}
// 데드락을 방지하는 코드
// 스레드 A와 스레드 B 모두 lock1을 먼저 잠그고 lock2를 잠급니다.
lock (lock1)
{
lock (lock2)
{
// ...
}
}
결론
lock 키워드를 사용할 때 이러한 주의 사항들을 숙지하고, 스레드 동기화를 안전하고 효율적으로 구현해야 한다.
lock 키워드는 스레드 동기화를 위한 간단하고 효과적인 방법이다. 하지만 더 복잡한 동기화 작업을 수행하려면 'Monitor 클래스'와 같은 다른 동기화 도구를 사용해야 한다.
lock 키워드 사용 예시
using System;
using System.Threading;
namespace Synchronize
{
class Counter
{
const int LOOP_COUNT = 1000; // 반복 횟수를 나타내는 상수입니다.
readonly object thisLock; // 잠금 객체입니다.
private int count; // 카운터 값을 저장하는 필드입니다.
public int Count // count 필드에 접근하기 위한 프로퍼티입니다.
{
get { return count; }
}
public Counter() // 생성자입니다.
{
thisLock = new object(); // 잠금 객체를 초기화합니다.
count = 0; // 카운터 값을 0으로 초기화합니다.
}
public void Increment() // 카운터 값을 증가시키는 메서드입니다.
{
int loopCount = LOOP_COUNT; // 반복 횟수를 loopCount 변수에 저장합니다.
while (loopCount-- > 0) // loopCount가 0보다 클 때까지 반복합니다.
{
lock (thisLock) // 잠금 객체를 사용하여 임계 영역을 보호합니다.
{
count++; // 카운터 값을 1 증가시킵니다.
}
Thread.Sleep(1); // 1밀리초 동안 스레드를 일시 중지합니다.
}
}
public void Decrement() // 카운터 값을 감소시키는 메서드입니다.
{
int loopCount = LOOP_COUNT; // 반복 횟수를 loopCount 변수에 저장합니다.
while (loopCount-- > 0) // loopCount가 0보다 클 때까지 반복합니다.
{
lock (thisLock) // 잠금 객체를 사용하여 크리티컬 섹션을 보호합니다.
{
count--; // 카운터 값을 1 감소시킵니다.
}
Thread.Sleep(1); // 1밀리초 동안 스레드를 일시 중지합니다.
}
}
}
class MainApp
{
static void Main(string[] args)
{
Counter counter = new Counter(); // Counter 객체를 생성합니다.
Thread incThread = new Thread(new ThreadStart(counter.Increment)); // Increment 메서드를 실행할 스레드를 생성합니다.
Thread decThread = new Thread(new ThreadStart(counter.Decrement)); // Decrement 메서드를 실행할 스레드를 생성합니다.
incThread.Start(); // incThread 스레드를 시작합니다.
decThread.Start(); // decThread 스레드를 시작합니다.
incThread.Join(); // incThread 스레드가 종료될 때까지 기다립니다.
decThread.Join(); // decThread 스레드가 종료될 때까지 기다립니다.
Console.WriteLine(counter.Count); // 카운터 값을 출력합니다.
}
}
}
코드 설명
이 C# 코드는 스레드 동기화를 사용하여 카운터 값을 안전하게 증가 및 감소시키는 프로그램이다. Counter 클래스는 count 필드를 갖고 있으며, Increment() 메서드는 count를 증가시키고, Decrement() 메서드는 count를 감소시킨다.
MainApp 클래스의 Main 메서드에서는 Counter 객체를 생성하고, Increment() 메서드와 Decrement() 메서드를 각각 실행하는 두 개의 스레드를 생성한다. 두 스레드는 동시에 실행되면서 count 필드에 접근하고 변경하기 때문에, 데이터 경쟁이 발생할 수 있다.
이를 방지하기 위해 lock 문을 사용하여 count 필드에 대한 접근을 동기화한다. lock (thisLock) 문은 thisLock 객체에 잠금을 걸고, 잠금을 획득한 스레드만 count 필드에 접근할 수 있도록 한다. 다른 스레드는 잠금이 해제될 때까지 대기한다.
Thread.Sleep(1) 문은 스레드를 1밀리초 동안 일시 중지하여, 다른 스레드가 실행될 기회를 준다. 이는 데이터 경쟁을 더욱 명확하게 보여주기 위한 것이다.
incThread.Join()과 decThread.Join()은 각 스레드가 종료될 때까지 기다리는 메서드다. 모든 스레드가 종료된 후, Console.WriteLine(counter.Count); 문은 최종 카운터 값을 출력한다.
출력 결과
0
코드 해석
이 코드는 스레드 동기화의 중요성을 보여주는 예제이다. lock 문을 사용하지 않으면, 두 스레드가 동시에 count 필드에 접근하고 변경하기 때문에 데이터 경쟁이 발생하고 예상치 못한 결과가 출력될 수 있다. 하지만 lock 문을 사용하여 count 필드에 대한 접근을 동기화하면 데이터 경쟁을 방지하고 정확한 결과를 얻을 수 있다.
Monitor 클래스로 동기화Monitor 클래스Monitor 클래스는 lock 키워드와 유사한 기능을 제공하지만, 좀 더 세밀하게 스레드 동기화를 제어할 수 있도록 하는 도구이다.
Monitor 클래스는 lock 키워드와 달리, 잠금을 획득하고 해제하는 Enter() 메서드와 Exit() 메서드를 명시적으로 호출해야 한다.
또한, Monitor 클래스는 Wait() 메서드와 Pulse() 메서드를 제공하여 스레드 간의 신호 전달을 통해 더욱 정교한 동기화를 구현할 수 있도록 한다.
Monitor 클래스의 주요 기능Enter() 메서드: 지정된 객체에 잠금을 건다. Exit() 메서드: 지정된 객체의 잠금을 해제한다.TryEnter() 메서드: 지정된 객체에 잠금을 시도하고, 잠금을 획득하면 true를, 잠금을 획득하지 못하면 false를 반환한다.Wait() 메서드: 잠금을 해제하고, 다른 스레드가 Pulse() 메서드 또는 PulseAll() 메서드를 호출할 때까지 현재 스레드를 대기 상태로 만든다.Pulse() 메서드: 대기 중인 스레드 하나를 깨운다.PulseAll() 메서드: 대기 중인 모든 스레드를 깨운다.Monitor 클래스 사용 예시
using System;
using System.Threading;
class Counter
{
private int count = 0;
private readonly object lockObject = new object();
public void Increase()
{
Monitor.Enter(lockObject); // lockObject 객체에 잠금을 겁니다.
try
{
count++;
}
finally
{
Monitor.Exit(lockObject); // finally 블록에서 잠금을 해제합니다.
}
}
}
lock 키워드 vs Monitor 클래스lock 키워드는 Monitor 클래스를 사용하는 것보다 간결하고 사용하기 쉽다.Monitor 클래스는 TryEnter(), Wait(), Pulse(), PulseAll() 메서드를 사용하여 잠금을 더 세밀하게 제어할 수 있다.TryEnter() 메서드는 타임아웃을 설정하여 잠금을 시도할 수 있다. Monitor.TryEnter() 메서드를 사용하면 잠금을 획득하지 못했을 때 대기하지 않고 다른 작업을 수행할 수 있다.Wait(), Pulse(), PulseAll() 메서드를 사용하여 스레드 간의 대기 및 알림 메커니즘을 구현할 수 있다.Monitor 클래스 사용 시 주의 사항Monitor.Enter() 메서드로 잠금을 건 후에는 반드시 Monitor.Exit() 메서드로 잠금을 해제해야 한다. 잠금을 해제하지 않으면 다른 스레드가 해당 객체에 접근할 수 없어 데드락이 발생할 수 있다.try-finally 블록을 사용하여 예외 발생 시에도 잠금이 해제되도록 하는 것이 좋다.Wait() 메서드와 Pulse() 메서드는 반드시 lock 문 내부에서 호출해야 한다.Monitor 클래스는 lock 키워드보다 더욱 세밀한 제어가 가능하지만, 사용하기 복잡하고 오류 발생 가능성이 높으므로 주의해서 사용해야 한다.
Monitor 클래스 사용 예시
using System;
using System.Threading;
namespace UsingMonitor
{
class Counter
{
const int LOOP_COUNT = 1000; // 반복 횟수를 나타내는 상수이다.
readonly object thisLock; // 잠금 객체이다.
private int count; // 카운터 값을 저장하는 필드이다.
public int Count // count 필드에 접근하기 위한 프로퍼티이다.
{
get { return count; }
}
public Counter() // 생성자이다.
{
thisLock = new object(); // 잠금 객체를 초기화한다.
count = 0; // 카운터 값을 0으로 초기화한다.
}
public void Increase() // 카운터 값을 증가시키는 메서드이다.
{
int loopCount = LOOP_COUNT; // 반복 횟수를 loopCount 변수에 저장한다.
while (loopCount-- > 0) // loopCount가 0보다 클 때까지 반복한다.
{
Monitor.Enter(thisLock); // 잠금 객체를 사용하여 임계 영역을 보호한다.
try
{
count++; // 카운터 값을 1 증가시킨다.
}
finally
{
Monitor.Exit(thisLock); // 잠금을 해제한다.
}
Thread.Sleep(1); // 1밀리초 동안 스레드를 일시 중지한다.
}
}
public void Decrease() // 카운터 값을 감소시키는 메서드이다.
{
int loopCount = LOOP_COUNT; // 반복 횟수를 loopCount 변수에 저장한다.
while (loopCount-- > 0) // loopCount가 0보다 클 때까지 반복한다.
{
Monitor.Enter(thisLock); // 잠금 객체를 사용하여 임계 영역을 보호한다.
try
{
count--; // 카운터 값을 1 감소시킨다.
}
finally
{
Monitor.Exit(thisLock); // 잠금을 해제한다.
}
Thread.Sleep(1); // 1밀리초 동안 스레드를 일시 중지한다.
}
}
}
class MainApp
{
static void Main(string[] args)
{
Counter counter = new Counter(); // Counter 객체를 생성한다.
Thread incThread = new Thread(new ThreadStart(counter.Increase)); // Increment 메서드를 실행할 스레드를 생성한다.
Thread decThread = new Thread(new ThreadStart(counter.Decrease)); // Decrement 메서드를 실행할 스레드를 생성한다.
incThread.Start(); // incThread 스레드를 시작한다.
decThread.Start(); // decThread 스레드를 시작한다.
incThread.Join(); // incThread 스레드가 종료될 때까지 기다린다.
decThread.Join(); // decThread 스레드가 종료될 때까지 기다린다.
Console.WriteLine(counter.Count); // 카운터 값을 출력한다.
}
}
}
코드 설명
이 C# 코드는 Monitor 클래스를 사용하여 스레드 동기화를 구현하는 예제이다. Counter 클래스는 count 필드를 갖고 있으며, Increase() 메서드는 count를 증가시키고, Decrease() 메서드는 count를 감소시킨다.
MainApp 클래스의 Main 메서드에서는 Counter 객체를 생성하고, Increase() 메서드와 Decrease() 메서드를 각각 실행하는 두 개의 스레드를 생성한다. 두 스레드는 동시에 실행되면서 count 필드에 접근하고 변경하기 때문에, 데이터 경쟁이 발생할 수 있다.
이를 방지하기 위해 Monitor 클래스를 사용하여 count 필드에 대한 접근을 동기화한다. Monitor.Enter(thisLock) 문은 thisLock 객체에 잠금을 걸고, 잠금을 획득한 스레드만 count 필드에 접근할 수 있도록 한다. 다른 스레드는 잠금이 해제될 때까지 대기한다. try-finally 블록을 사용하여 예외 발생 시에도 Monitor.Exit(thisLock) 문이 실행되어 잠금이 해제되도록 한다.
Thread.Sleep(1) 문은 스레드를 1밀리초 동안 일시 중지하여, 다른 스레드가 실행될 기회를 줍니다. 이는 데이터 경쟁을 더욱 명확하게 보여주기 위한 것이다.
incThread.Join()과 decThread.Join()은 각 스레드가 종료될 때까지 기다리는 메서드이다. 모든 스레드가 종료된 후, Console.WriteLine(counter.Count); 문은 최종 카운터 값을 출력한다.
출력 결과
0
코드 해석
이 코드는 스레드 동기화의 중요성을 보여주는 예제이다. Monitor 클래스를 사용하지 않으면, 두 스레드가 동시에 count 필드에 접근하고 변경하기 때문에 데이터 경쟁이 발생하고 예상치 못한 결과가 출력될 수 있다. 하지만 Monitor 클래스를 사용하여 count 필드에 대한 접근을 동기화하면 데이터 경쟁을 방지하고 정확한 결과를 얻을 수 있다.
thisLock = new object();
thisLock = new object();는 thisLock이라는 변수에 새로운 object 객체를 생성하여 할당하는 코드이다.
thisLock은 Counter 클래스 내에서 readonly로 선언된 필드로, 스레드 동기화를 위한 잠금 객체로 사용된다.
object 클래스는 .NET의 '모든 클래스의 기본 클래스'이며, 잠금 객체로 사용하기에 적합하다.
new 키워드는 새로운 객체를 생성하는 데 사용되며, object()는 object 클래스의 인스턴스를 생성하는 생성자이다.
따라서 thisLock = new object();는 thisLock 필드에 새로운 object 객체를 생성하여 할당하고, 이 객체는 Increment() 메서드와 Decrement() 메서드에서 lock 문의 잠금 객체로 사용된다.