본문 바로가기

CS/멀티쓰레딩

스레드 경합 해결하기(lock) -1

https://basaeng.tistory.com/12

 

멀티스레드 프로그래밍 시작하기

개요현재 CPU 코어의 성능이 좋아졌지만 CPU의 성능을 온전히 사용하기 위해서는 멀티 태스킹, 멀티 스레딩 등 프로그램의 최적화가 필요합니다.  특히 많은 연산이 필요한 서버 등의 경우 이러

basaeng.tistory.com

 

이전 포스트에서 언급한 것 처럼 스레드 사이에서 경합 상황이 생길 수 있기 때문에 해결하는 방법들이 필요하다.

따라서 경합상황이 생길 수 있는 코드에 진입하기 전에 lock을 획득하고, 빠져나갈 때 lock을 반납해 경합을 막는다.

 

락을 획득하는 방법

락을 획득하는 방법에 따라 프로그램이 달라진다.

현재 진행중인 스레드가 락을 획득하려고 시도하는데, 이미 다른 스레드가 락을 가지고 있다면 어떻게 할까?

이제 알아보자

 

원자적(atomic) 연산

만약 락을 아예 획득할 필요가 없다면 어떨까?

연산이 여러 cpu의 연산이 아닌 단 하나의 연산으로 이루어지는 원자적인 연산이라면 락을 획득할 필요가 없을 것이다.

프로그래밍 언어에서 이러한 원자적 연산을 위한 간단한 기능들을 제공한다.

 

C#의 경우 Interlocked 계열 함수들을 제공한다.

 

 

C++의 경우 atomic을 사용한다

 

이 함수들을 사용하면 해당 연산은 원자적 연산임을 보장한다. 

 

다만 원자적 연산을 실행한다면, 메모리에 직접 접근이 필요해 오버헤드가 발생하고 실행 시간이 느려진다.

#include <iostream>
#include <atomic>
#include <chrono>

int regular_counter = 0;
std::atomic<int> atomic_counter(0);

void test_regular() {
    for (int i = 0; i < 100000000; i++) {
        regular_counter++;  // 일반적인 증가 연산
    }
}

void test_atomic() {
    for (int i = 0; i < 100000000; i++) {
        atomic_counter.fetch_add(1, std::memory_order_relaxed);  // 원자적 증가 연산
    }
}

int main() {
    auto start = std::chrono::high_resolution_clock::now();
    test_regular();
    auto end = std::chrono::high_resolution_clock::now();
    std::cout << "Regular increment: " << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << " ms\n";

    start = std::chrono::high_resolution_clock::now();
    test_atomic();
    end = std::chrono::high_resolution_clock::now();
    std::cout << "Atomic increment: " << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << " ms\n";

    return 0;
}

 

이렇게 일반적인 연산과 원자적 연산의 실행시간을 비교해보면

 

내 PC의 경우 10배가 넘는 시간 차이가 났다. 

1초도 안되는 시간일지라도 정말 많은 연산을 하는 서버의 경우 이러한 시간이 누적되어 큰 지연이 발생할 수 있다.

따라서 원자적 연산은 정말 필요할 때만 사용해야 할 것이다.

 

 

 

lock 사용

lock을 사용하는 가장 일반적인 방법은 lock을 획득하지 못한 경우 대기 큐에서 대기하는 것이다.

실행중이던(lock을 점유한) 스레드가 lock을 반환한다면 대기중인 스레드 중 OS의 스케쥴러에 의해 결정된 스레드가 실행된다. 

 

lock을 획득하지 못했을 때 커널 영역으로 넘어가는 부분이기 때문에 프로그래머가 코드에서 직접 제어하는 부분은 아니다. 넘겨주는 것은 대부분 라이브러리로 구현되어있는 것을 사용한다.

 

c#에서는 lock을 통해서 해당 코드 영역을 잠군다.(내부적으로 Monitor로 구현)

namespace Program
{
    class Program
    {
        static object _lock = new object();

        static int sum = 0;
        static void Add()
        { 
            lock (_lock)
            {
                for (int i = 0; i < 100000; i++)
                {
                    sum++;
                }
            }
        }       
        static void Sub()
        { 
            lock (_lock)
            {
                for (int i = 0; i < 100000; i++)
                {
                    sum--;
                }
            }
        }

        static void Main(string[] args)
        {
            Task task1 = new Task(Add);
            Task task2 = new Task(Sub);
            task1.Start();
            task2.Start();
            Task.WaitAll(task1, task2);

            Console.WriteLine(sum);
        }
    }
}

 

 

c++에서는 mutex를 사용해서 unlock 전까지의 코드 영역을 잠군다. 

int sum = 0;
mutex _mutex;
void Add() {
	for (int i = 0; i < 1000000; i++) 
	{
		_mutex.lock();
		sum++;
		_mutex.unlock();
	}
}

void Sub() {
	for (int i = 0; i < 1000000; i++)
	{
		_mutex.lock();
		sum--;
		_mutex.unlock();
	}
}

int main() 
{
	thread t1(Add);
	thread t2(Sub);

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

	cout << sum << endl;

	return 0;
}

 

 

다만 mutex는 단점이 있다. c#의 lock의 경우 내부적으로 Monitor와 try, finally문으로 구성되어있어 lock을 획득하고 진행되는 코드 사이에서 exception이 발생하면 빠져나가며 lock을 반환하지만, mutex의 경우 그렇지않다.

 

이를 해결하기 위해 c++의 RAII 패턴을 적용한 lock_guard를 사용한다. lock_guard<mutex> 와 같이 사용하고 해당 유효 범위 밖으로 벗어난다면 unlock이 자동으로 된다.

 

 

추가적으로

스핀 락, 이벤트, 데드락 고려, 세마포는 이번 글이 너무 길어져 다음 포스트로