Coding Memo

Atomic, Lock, Critical Section 본문

Game Server (C++)

Atomic, Lock, Critical Section

minttea25 2023. 5. 28. 15:59

각 용어의 정의를 간단히 설명 하자면... (요약)

 

Atomic: 말그대로 원자성이란 뜻으로 Atomic 명령 실행 중에는 다른 어떤 간섭 없이 끝까지 실행됨을 보장

Lock: Multi-Thread 환경에서 여러 쓰레드가 공유하는 어떤 리소스(변수 등)에 접근하는 방법에 대한 메커니즘

Critical Section: Multi-Thread 환경에서 상호 배제를 보장하는 특정 구역(section)

 


공유 리소스 접근 컨트롤이 필요한 이유

 

아래 코드를 살펴보자.

int n = 0;

void Thread1()
{
	for (int i = 0; i < 100000; i++)
	{
		n++;
	}
}

void Thread2()
{
	for (int i = 0; i < 100000; i++)
	{
		n--;
	}
}

int main()
{
	thread t1(Thread1);
	thread t2(Thread2);

	t1.join();
	t2.join();

	cout << n << endl;

    return 0;
}

간단히 생각해서 쓰레드1에서는 10만번 n에 1을 더하고 쓰레드2에서는 10만번 n에 1을 빼니까 출력 결과는 0이 될 것처럼 보인다.

 

그러나 실제 실행을 시켜보면 그렇지 않고 엉뚱한 값이 출력되는 것을 알 수 있을 것이다.

 

그 이유는 무엇일까?

 

n++은 n += 1과 같은 의미로 '변수 n에 1을 더하라'라는 의미이다. 이 코드가 돌아가는 방식을 좀 더 생각해보자.

VS에서 n++ 코드에 중단점을 걸고 디버깅실행을 한 후, 디스 어셈블리를 확인하면 n++코드가 정확히 어떻게 돌아가는지 알 수 있다.

n++ 디스어셈블리

간단히 말하면 3가지 과정이 일어난다.

1. n을 읽고 임시 변수 t에 저장한다.(t = n)

2. t에 1을 더한다. (t += 1)

3. n에 t값을 저장한다. (n =t)

 

 

여기서 이 1~3 과정에서 다른 쓰레드에서 똑같이 n을 읽고 쓰려고하면 문제가 발생한다는 것이다.

A 쓰레드에서는 1을 더하기 전에는 n값이 0 이었고, B쓰레드에서도 1을 빼기 전에 읽은 n값이 0이라고 한다면, 최종적으로 n값은 -1이 될 것이다. (A쓰레드에서는  n에 1을 저장하지만, B쓰레드에서도 n에 -1을 저장할 것이다.)

 

 

 

공유 자원인 n 에 대해 동시에 읽고 쓰려고 하기 때문에 발생했다.

 

따라서 여러 쓰레드가 동시에 공유 리소스를 수정하는 경우 문제가 발생한다.

 

이 문제를 해결하기 위한 여러가지 방법이 있다.

 

Note: 공유 리소스를 수정하는 경우 문제가 발생한다고 했는데, 이를 좀 더 유연하게 생각하면 공유 리소스라 할지라도 다른 쓰레드가 쓰고(write) 있지 않다면 많은 쓰레드가 접근해서 동시에 값을 읽어도 상관 없다! -> Read/Writer Lock 구현


Atomic

 

atomic은 원자성이란 뜻으로 어떤 atomic operation을 수행할 때 동안은 다른 쓰레드나 인터럽트의 간섭을 받지 않음을 보장한다. C++에서는 atomic 클래스 템플릿을 지원하고 있고 Java에서는 몇몇 리터널에 대한 자체 atomic 클래스를, C#에서는 atomic관련 실행 보장을 위한 Interlocked클래스를 지원하고 있다.

 

공유 리소스인 n을 atomic으로 만들어보자.

다음과 같이 n을 선언하면 된다.

std::atomic<int> n(0); // or std::atomic<int> n = 0;

그리고 atomic인 n값에 대해 ++나 --를 수행 할때는 다음과 같이 사용한다.

n.fetch_add(1); // same as n++

n.fetch_sub(1); // same as n--

Note: fetch_~~ 함수의 반환 값은 값을 더하기 전의 original value이다.

 

코드를 위와 같이 수정하여 다시 실행 시키면 최종 출력 값이 0으로 나오는 것을 알 수 있다.

 

Atomic 변수의 장단점

Pros Cons
- 단순성: 명시적인 lock구현이나 동기화 관련 로직이 필요 없고 간단하고 직접적으로 사용할 수 있음

- 성능: context 전환이나 thread 차단을 포함하지 않기 때문에 lock보다는 조금 빠름

- lock-free관련 알고리즘에 필수적으로 들어가는 요소이고 높은 성능을 발휘
- 제한적인 사용: 단일 변수이거나 적은 변수 집합에 대해만 적합하고 복잡한 데이터 구조에는 적합하지 않음

- no-blocking: blocking으로 동작하지 않으므로 event call등의 특정 동기화 패턴을 구현하기 어려움

- 유연성 부족: 다양한 기능을 제공하지 않음(CAS이나 fetch_add 등만을 제공)

 

Atomic 외에도 lock을 이용하여 critical section을 만드는 방법이 있다.


Lock

 

lock은 다른 쓰레드들이 방해하지 못하도록 문을 잠군다고 생각하면 쉽다.

C++에서는 mutex를 이용하여 lock을 구현할 수 있다.

 

mutex는 mutual exclusive(상호 배제)의 줄임말로, 여러 쓰레드의 동시 접근을 컨트롤 하기 위해 사용되는 동기화 방법이다. 말 그대로 상호 배제를 보장한다. 

 

mutex라는 열쇠가 하나 있고 그 열쇠로 열수 있는 방이 있는데 어떤 쓰레드가 열쇠를 가지고 방에 들어가 방문을 잠군다.(lock, critical section에 진입한다.) 할일을 마치면 열쇠를 가지고 나온다.(unlock) 이렇게 하면 다른 쓰레드들은 열쇠가 없어서 그 방에 들어갈 수 없고 기다려야 한다.

 

int n = 0;
mutex m;

void Thread1()
{
	for (int i = 0; i < 100000; i++)
	{
		m.lock();
		n++;
		m.unlock();
	}
}

void Thread2()
{
	for (int i = 0; i < 100000; i++)
	{
		m.lock();
		n--;
		m.unlock();
	}
}

 

Note: (C++) 위에서는 lock과 unlock을 명시적으로 구현하였지만, lock을 하고 unlock을 잊는 문제가 발생할 수도 있다. 따라서 lock과 unlock을 자동으로 해주는 lock_gurad가 있다.

더보기
void Thread1()
{
	for (int i = 0; i < 100000; i++)
	{
		lock_guard<mutex> lock(m);
		n++;
	}
}

void Thread2()
{
	for (int i = 0; i < 100000; i++)
	{
		lock_guard<mutex> lock(m);
		n--;
	}
}

lock의 장단점

Pros Cons
-유연성: mutex를 이용해 여러가지 변수나 데이터에 대한 critical section을 제어하고 설정할 수 있음

-blocking: blocking 메커니즘을 지원하여 특정 조건을 기다릴 수 있음

-세분화 가능: 필요한 경우에만 lock을 사용할 수 있음 (예 - Read만 하고 있을 때는 여러 쓰레드 접근 허용)
-성능 오버헤드: lock은 기본적으로 context switching, mutex 해제, mutex 획득 등의 오버헤드가 들어가므로 느리고 자원이 많이 필요함

-교착상태: lock을 잘못 사용하면 교착상태에 빠지고 이는 찾기도 어렵고 해결도 어려울 수 있음

-복잡성: lock 순서, race condition 방지, 예외 처리에 대한 lock 처리 등의 고려할 사항이 많음

 

lock을 사용할 때 주의 점은 lock으로 잠궜다면 반드시 볼일을 마치고 난 후에는 unlock을 해주어야 한다는 것이다.

그렇지 않다면 다른 쓰레드들은 열쇠가 없어 영원히 대기 상대가 될 것이다.

바로 이런상태가 상호배제에 의한 교착 상태(dead lock)이다.

 

교착 상태는 프로그램에 치명적인 문제를 발생한다.

 

Note: Dead Lock은 위에서 언급된 원인 이외에도 좀 더 다양한 원인으로 나타나기도 한다. 


Summary

멀티 쓰레드 환경에서 여러 쓰레드가 공유 리소스를 수정하면 문제가 발생할 수 있다. 따라서 Atomic operation을 활용하거나 lock을 구현하여 critical section을 형성한다.

이들은 멀티 쓰레드 환경에서 공유 리소스에 대한 접근을 동기화하는 데 사용하는 메커니즘이다. 

각각 방법에는 장단점이 존재하고 현재 구현하려는 프로그램의 요구 사항과 리소스에 맞게 적절하게 활용해야 한다.

'Game Server (C++)' 카테고리의 다른 글

Asynchronous Socket IO - IOCP  (0) 2022.11.29
Asynchronous Socket IO - Overlapped (event, callback)  (0) 2022.11.26
WSAEventSelect  (0) 2022.11.23
Socket IO - Select  (0) 2022.11.18
Non-blocking Socket  (0) 2022.11.18