컴퓨터 공학/멀티쓰레드

멀티쓰레드 프로그래밍 입문

hhzn 2024. 10. 16. 18:48

 

멀티쓰레드는 C++ 프로그래밍 언어 내에 멀티스레드 라이브러리에 존재한다. C++11 에 추가되었으며 표준으로 존재한다.

 

C++ 11에서 지원되기 전에는 각 OS 마다 사용할 수 있는 방법이 달랐다.

windows에서는 WIN32 라는 라이브러리에서 지원하는 API를 사용해서 프로그래밍 해야했고, Linux는 pthread API를 사용해서 프로그래밍 했다. 현재는 C++11 에 공개된 <thread> 라이브러리를 이용하면 된다.

 

 

thread 를 지원하는 방법은 각 OS 마다 달랐다.

먼저 windows 에서 쓰레드를 어떻게 지원했냐 하면

  • windows 에서 쓰레드는 프로세스를 구성하는 원소이다.
  • 모든 프로세스는 시작 시 한 개의 쓰레드를 갖고 실행된다.
  • 운영체제가 직접 쓰레드를 스케줄링.
  • 멀티 CPU(또는 core)라면 여러 개의 쓰레드를 동시에 실행시켜준다.

 

반면에 Linux에서는

  • 쓰레드 개념이 없다. 프로세스이다. 
  • 프로세스를 만들 때 옵션을 부여하여 쓰레드를 사용할 수 있다.
    예를 들면, CODE, DATA, HEAP을 공유하는 프로세스를 생성하는 방법이다. 이는 다른 운영체제의 쓰레드와 같다.


이제는 C++의 <thread> 를 이용해서 OS를 구분짓지 않고 코딩할 수 있다.
(다른 언어에서도 thread를 지원하는 언어가 있다. 나는 C++로 공부했기 때문에 C++을 사용할 것이다.)

 


 

멀티스레드 프로그래밍을 시작해보자.

 

#include <iostream>
#include <thread>
#include <vector>

void f(int a)
{
    std::cout << "Hello MultiThread! - " << a << std::endl;
}

int main()
{
    std::vector<std::thread>v;
    for (int i = 0; i < 10; ++i) {
        v.push_back(std::thread{f, i});
    }

    for (auto& t : v)
        t.join();
}

 

실행 결과

 

 

이 코드와 결과에서 주목할 점이 두 가지가 있다.

 

첫 번째는 분명 코드 안의 for문에서는 0부터 9까지 i가 증가하는데, 실제 출력 결과는 출력된 수들이 차례대로 증가하지 않는 점이다.

이는 스레드 객체들이 어떤 순서로 실행될지 보장된 바가 전혀 없기 때문이다. 이 코드를 다시 실행한다면 또 다른 결과가 나올 것이다. 

 

 

두 번째는 join() 이라는 멤버 함수이다.

위의 코드에서

    for (auto& t : v)
        t.join();

해당 부분을 빼고 실행해보자.

이와 같은 오류 창이 뜰 것이다. 

 

문제가 발생하는 이유는 스레드가 실행중인데 main() 함수(메인 쓰레드)가 종료되었기 때문이다. 이를 방지하려면 join() 함수를 호출해서 스레드를 인스턴스한 함수에서 기다려주어야 한다. 선택이 아닌 필수이다.

 

 

join()을 비롯한 멀티쓰레드 프로그래밍을 하기 위해 알아야 할 여러 함수들이 있다. 다음과 같다.

 

  • join() : thread의 종료를 기다리는 함수. 쓰레드를 기다려주지 않고 메인 쓰레드가 먼저 종료된다면 오류가 발생한다. 
  • std::therad::hardware_concurrency() : 논리(가상) 코어의 개수를 return 한다.
    작업 관리자의 성능 탭에서 이 수를 확인할 수도 있다. 여기서 확인할 수 있는 점은 실제 코어 개수와 논리 코어의 개수는 다르다는 것이다. 하이퍼 스레딩이 가능한 코어를 카운팅 한 것인데.. 인텔도 버렸다고 선언한 기술이다; 
    아래는 해당 함수를 호출한 코드이고 출력 결과를 확인할 수 있다. 작업 관리자의 논리 프로세서의 수와 같은 값이 출력된다. 
std::cout << "논리 프로세서의 개수 - " << std::thread::hardware_concurrency() << std::endl;

std::thread::hardware_concurrency() 출력 결과
작업 관리자의 성능 탭

  • joinable() : 쓰레드의 종료를 확인하는 함수로 thread_id 가 0이 아닐 때 true 를 return 한다.
  • get_id() : 커널에서 다루는 쓰레드의 id. 순차적인 값은 아니나, 쓰레드끼리 겹치지 않음이 보장된다.
  • detach() : 쓰레드 객체에서 쓰레드를 분리하는 함수. 쓰레드의 종료와 상관없이 쓰레드 객체는 소멸할 수 있다.
    그러나 쓰레드의 종료를 join()이나 joinable() 로 알 수 없다.

 

 

또한 this_thread 로 현재 실행하는 스레드에 대한 함수를 호출할 수 있는 namespace 가 제공된다. 

  • std::this_thread::get_id() : 자기 자신의 쓰레드 id를 return 한다.
  • this_thread::sleep_for() : 정해진 시간동안 쓰레드의 실행을 멈춘다. busy wating 방지에 쓰인다.
    예를 들어, 1초의 대기가 필요한 상황에서 아무것도 하지 않고 해당 쓰레드가 실행되는 것은 낭비이다. sleep_for()를 사용해서 이러한 낭비를 방지한다.
  • this_thread::sleep_until() : 정해진 시간까지 쓰레드의 실행을 멈춤. 정확한 시간을 적어 깨어나게 하는 기능이다. 이 함수 또한 busy wating을 방지할 수 있다.
  • this_thread::yield() : 다른 쓰레드에게 실행 시간을 양보한다. 쓰레드에서 수행하는 작업의 우선순위가 낮다면 바로 실행할 필요가 없기 때문에 yield()를 사용해서 다른 쓰레드에게 양보한다. do while 문으로 특정 조건이 성립될 때 까지 yield()를 호출하는 방식으로 사용할 수 있다.

 

 


쓰레드의 여러가지 생성 방법을 알아보자.

#include <iostream>
#include <thread>

void f() {};

class F {
public:
    void operator()() {};
};

int main()
{
    std::thread t1{ f };	// 함수
    std::thread t2{ F() };	// 함수 객체
    std::thread t3;			// 객체 생성 후
    t3 = std::thread{ f };	// 나중에 함수 할당
}

 

쓰레드 생성 시에 함수를 인자로 넘겨 생성할 수 있다. 함수 객체 또한 인자로 넘겨 생성 가능하다.

아무런 인자도 넘기지 않고 생성 후, 나중에 함수를 할당할 수도 있다. 이 때는 함수를 할당하기 전 까지는 실제 쓰레드가 생성되지는 않고, 함수가 할당된 후에 실제 쓰레드가 생성된다.

 

 


 

 

이제 진짜 멀티쓰레드 프로그래밍으로 성능을 향상 시켜보자.

1을 5천만번 더한 함수를 두 개의 쓰레드에 할당시키면 1억을 빠르게 만들 수 있겠지?! 이얏호!~

std:C++20 / Release / x64 환경으로 실행했다.

#include <iostream>
#include <thread>

int res;

void sum()
{
    for (int i = 0; i < 50000000; ++i) {
        res += 1;
    }
}

int main()
{
    std::thread t1{ sum };
    std::thread t2{ sum };

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

    std::cout << "합계 - " << res << std::endl;
}

실행 결과


Volatile

엥? 이 결과는 뭘까? 1억이 나와야 하는데.. 5천만이 나왔다!

사실 이 프로그램은 Release 모드로 실행했기 때문에 컴파일러가 자동으로 최적화를 수행한다. 
for 문의 내용을 보고 한 번에 5천만씩 더했기 때문에 이런 결과가 나온 것이다.
(그래두 1억 나와야 하는 거 아닌가...? 라는 생각이 든다면 님 말이 맞는데 일단지금은volatile 얘기하고있으니깐조금만기다려주세요)

 

volatile 은 변수를 컴파일러 최적화에서 제외할 때 사용한다. 

 

volatile 의 주요 특징은 반드시 메모리를 읽고 쓴다는 점이다.

변수가 읽힐 때 마다 매번 실제 메모리에서 값을 읽고, 쓸 때마다 값을 실제 메모리에 기록한다.

 

위의 문제는 최적화가 이루어져 발생한 문제이므로 

아래와 같이 전역변수 int res 에 volatile을 키워드를 선언해서 최적화를 막아보자! 그럼 1억이 나올 것이다!

volatile int res;

실행 결과

volatile로 선언해서 컴파일러가 최적화를 하진 않았지만 원하는 값인 1억이 나오지 않았다.

사실 위의 volatile로 선언하지 않은 코드의 실행 결과 또한 같은 문제로 인해 1억이 나오지 않고 5천만이 나왔다. 

이 문제는 DataRace가 발생했기 때문에 일어난 문제이다.

 

 


 

Data Race

Data Race란 복수 개의 쓰레드가 하나의 메모리에 동시에 접근하면서 적어도 한 개는 write를 할 때 일어날 수 있는 멀티쓰레드 프로그래밍 문제이다.

 

풀어서 이야기 하자면, 공유 메모리를 여러 쓰레드에서 읽고 쓰기 때문에 발생한다. 이로 인해서 각 쓰레드가 읽고 쓰는 순서에 따라 실행결과가 달라진다.

res += 1; 의 어셈블리어

어셈블리어를 살펴보면 문제가 왜 발생하는지 알 수 있다. res에 1씩 더하는 문장은 한 번에 이루어지지 않고 총 세 단계로 나눠 이루어진다. 각 코어에서 작업을 실행하며 res에 대한 값을 참조할 때 다른 쓰레드에서 실행 중인 작업을 무시하고 메모리에 저장된 값을 불러와 대부분의 작업들이 말짱 도루묵이 된 것이다.

 

같은 이유로 이 프로그램을 싱글 코어로 제한해서 실행해도 문제가 발생할 것이다. 하나의 코어에서 시분할로 두 개의 쓰레드가 실행되는데 위의 코드가 실행될 때 컨텍스트 스위칭이 일어나면 Data Race가 발생한다. 

 

그렇다면, 어떻게 이를 해결해야 할까?

정답은 단순하다. 공유 메모리에 대해 동시에 복수개의 쓰레드가 접근할 수 없도록 하면 된다. 즉, 한 번에 한 개의 쓰레드만 메모리에 접근하도록 하면 된다. 

 

 


Lock

C++11에는 동시에 복수개의 쓰레드가 접근할 수 없도록 하는 Lock 또한 제공한다.

#include <mutex>

std::mutex lock;

 

<mutex> 를 include해서 전역변수로 mutex 객체를 만들 수 있다. 

(mutex는 mutual exclusion의 줄임말이다. 상호배제 라는 뜻을 갖고있다.)

 

그러면, 이 객체를 아까의 프로그램에 적용시켜 문제를 해결해보자!

#include <iostream>
#include <thread>
#include <mutex>

std::mutex lock;

volatile int res;

void sum()
{
    for (int i = 0; i < 50000000; ++i) {
        lock.lock();
        res += 1;
        lock.unlock();
    }
}

int main()
{
    std::thread t1{ sum };
    std::thread t2{ sum };

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

    std::cout << "합계 - " << res << std::endl;
}

 

실행 결과

와하하! 1억이 나왔다! 멀티쓰레드 프로그래밍 끝! (응아니야)

 

멀티쓰레드 환경에서 올바른 값이 나왔으니, 실제로 성능이 향상되었는지를 확인해보자!

쓰레드 객체를 생성하지 않고 sum() 함수를 두 번 호출한 프로그램과, 완성한 프로그램을 밀리세컨드 단위로 비교해보자.

 

바로 밑의 코드가 sum() 함수를 두 번 호출한 프로그램이고, A 코드라고 부르겠다.

#include <iostream>
#include <thread>
#include <chrono>

volatile int res;

void sum()
{
    for (int i = 0; i < 50000000; ++i) {
        res += 1;
    }
}

int main()
{
    auto start = std::chrono::high_resolution_clock::now();
    sum(); sum();

    auto elapsed_time = std::chrono::high_resolution_clock::now() - start;
    auto count = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed_time).count();
    std::cout << "합계 - " << res << std::endl;
    std::cout << "걸린 시간 - " << count << "ms" << std::endl;
}

 

 

 

이 코드가 mutex 객체로 임계 구역을 감싼 코드이다. B 코드라고 부르겠다.

#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>

std::mutex lock;

volatile int res;

void sum()
{
    for (int i = 0; i < 50000000; ++i) {
        lock.lock();
        res += 1;
        lock.unlock();
    }
}

int main()
{
    auto start = std::chrono::high_resolution_clock::now();
    std::thread t1{ sum };
    std::thread t2{ sum };

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

    auto elapsed_time = std::chrono::high_resolution_clock::now() - start;
    auto count = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed_time).count();
    std::cout << "합계 - " << res << std::endl;
    std::cout << "걸린 시간 - " << count << "ms" << std::endl;
}

 

각각 A, B 코드의 실행 결과이다.

A코드의 실행 결과
B 코드의 실행 결과

 

성능 향상은 커녕 성능이 약 10배 하락했다.

lock을 걸고 해제하는 작업도 오버헤드가 클 뿐더러, 작업의 큰 부분을 차지하는 부분에서 한 쓰레드만 접근할 수 있게 해놨기 때문에 멀티쓰레드의 이점 또한 가져갈 수 없었기 때문이다.


 

이제 멀티쓰레드 프로그래밍을 할 때 중요한 사항을 알았다.

첫 번째는 멀티쓰레드를 사용하고도 올바른 결과가 나와야 한다는 점이다. 

두 번째는 멀티쓰레드로 인한 성능 향상의 폭이 커야한다는 점이다. 

실행 결과에 오류가 있다면 그건 절대로 사용할 수 없는 프로그램이고, 성능 향상이 미미한 정도에 그친다면 그건 헛수고일 뿐이다.

 

이 두 가지를 유의하면서 프로그래밍 하기는 매우 어렵다.

멀티쓰레드 프로그래밍의 세계에 온 것을 환영한다! 

 


 

** 오류 지적은 환영입니다^^ **

'컴퓨터 공학 > 멀티쓰레드' 카테고리의 다른 글

멀티쓰레드 프로그래밍 소개  (1) 2024.10.14