본문 바로가기
GD's IT Lectures : 기초부터 시리즈/C, C++ 기초부터 ~

[C/C++ 프로그래밍 : 중급] 13. 스레드

by GDNGY 2023. 6. 14.

Chapter 13. 스레드

스레드는 어떤 문제를 해결하기 위해 동시에 여러 작업을 수행하는 데 사용됩니다. 스레드의 개념과 필요성부터 시작해, 스레드의 생명주기와 동기화 기술에 대해 배웁니다. 멀티 스레드와 멀티 프로세스의 차이, 스레드의 우선순위 등을 이해하게 됩니다. 또한, 락과 데드락, 그리고 스레드 풀과 조건 변수를 통한 효율적인 스레드 관리 방법에 대해서도 배울 수 있습니다. 뿐만 아니라, C++11에서 제공하는 스레드 라이브러리와 스레드 로컬 저장소, 그리고 스레드 안전성에 대한 고려 사항까지 다루게 됩니다.

 

반응형

 


[Chapter 13. 스레드]

 

13.1. 스레드 이해하기

13.1.1. 스레드란 무엇인가

13.1.2. 스레드의 필요성

13.1.3. 멀티 스레드와 멀티 프로세스 비교

13.1.4. 스레드와 코어

13.1.5. 멀티 스레드의 장단점

 

13.2. 스레드의 생성과 종료

13.2.1. 스레드 생성하기

13.2.2. 스레드 종료하기

13.2.3. 스레드의 생명주기

13.2.4. 스레드 우선 순위

13.2.5. 스레드 스케줄링

13.2.6. 스레드 상태 변화

 

13.3. 스레드 동기화 기술

13.3.1. 뮤텍스와 세마포어 이해하기

13.3.2. 뮤텍스를 사용한 스레드 동기화

13.3.3. 세마포어를 사용한 스레드 동기화

13.3.4. 락 (Lock)과 데드락 (Deadlock)

13.3.5. 락의 종류와 사용법

13.3.6. 락의 활용

 

13.4. 조건 변수와 스레드 풀

13.4.1. 조건 변수란 무엇인가

13.4.2. 조건 변수를 이용한 스레드 관리

13.4.3. 스레드 풀과 효율적인 스레드 관리

13.4.4. 스레드 풀의 성능 튜닝

13.4.5. 스레드 풀의 최적화

 

13.5. C++11에서의 스레드

13.5.1. C++11 스레드 라이브러리 소개

13.5.2. C++11에서의 스레드 생성과 관리

13.5.3. C++11에서의 스레드 동기화

13.5.4. C++11에서의 락과 컨디션 변수

13.5.5. C++11에서의 조건 변수와 스레드 풀

 

13.6. 스레드 로컬 저장소

13.6.1. 스레드 로컬 저장소의 개념과 필요성

13.6.2. C++에서의 스레드 로컬 저장소 사용하기

13.6.3. 스레드 로컬 저장소의 주의점

13.6.4. 스레드 로컬 저장소와 성능

13.6.5. 스레드 로컬 저장소와 동시성

13.6.6. 스레드 로컬 저장소의 활용

 

13.7. 스레드 안전성

13.7.1. 스레드 안전성이란 무엇인가

13.7.2. 스레드 안전성 유지를 위한 기법

13.7.3. 스레드 안전한 코드 작성하기

13.7.4. 스레드 안전성에 대한 고려 사항

13.7.5. 스레드 안전한 클래스 설계

13.7.6. 스레드 안전성 검증 방법


13.1. 스레드 이해하기

스레드는 프로그램 내에서 가장 작은 실행 단위로, 동시에 여러 작업을 수행하기 위해 사용됩니다. 이 섹션에서는 스레드의 개념, 그리고 멀티 스레딩의 중요성에 대해 배울 것입니다. 스레드는 멀티 코어 프로세서를 최대한 활용하도록 해주며, 이를 이해하고 활용하는 것은 현대의 복잡하고 효율을 요구하는 프로그래밍 환경에서 매우 중요합니다. 이번 섹션을 통해 스레드와 그 활용에 대한 첫걸음을 내딛어보세요.

13.1.1. 스레드란 무엇인가

스레드는 프로그램에서 가장 작은 실행 단위로, 컴퓨터가 명령어를 수행하는 순서를 결정합니다. 한 프로그램 내에서 동시에 여러 작업을 수행하게 만드는 역할을 합니다. 프로세스 내에서 실행되는 각 스레드는 자신만의 레지스터 세트와 프로그램 카운터를 가지지만, 같은 프로세스 내의 다른 스레드와 힙 메모리, 전역 변수 등을 공유합니다.

 

스레드는 프로세스보다 더 적은 리소스를 사용하여 생성하거나 제거할 수 있기 때문에, 동일한 프로세스 내에서 다양한 태스크를 빠르게 전환할 수 있습니다. 이는 특히 사용자 인터페이스가 있는 응용 프로그램에서 중요한데, 이런 프로그램에서는 작업을 동시에 처리하거나, 한 작업이 완료될 동안 다른 작업을 계속 진행해야 할 때가 많습니다.

 

간단한 예제로, 다음의 C++ 코드는 병렬로 두 개의 스레드를 생성하는 방법을 보여줍니다:

 

[예제]

#include <iostream>
#include <thread>

void function_1() {
    std::cout << "Function 1 has been called." << std::endl;
}

void function_2() {
    std::cout << "Function 2 has been called." << std::endl;
}

int main() {
    std::thread thread_1(function_1); // 첫 번째 스레드 생성
    std::thread thread_2(function_2); // 두 번째 스레드 생성

    thread_1.join(); // 첫 번째 스레드가 끝날 때까지 대기
    thread_2.join(); // 두 번째 스레드가 끝날 때까지 대기

    return 0;
}

이 코드는 두 개의 별도 함수, function_1과 function_2를 동시에 실행하는 두 개의 스레드를 생성합니다. 각 함수는 간단한 메시지를 콘솔에 출력합니다. join() 함수는 주 스레드가 해당 스레드가 종료될 때까지 기다리게 만듭니다.

 

이 예제를 통해 여러분은 스레드를 생성하고, 그것들이 동시에 실행되는 방식을 확인할 수 있습니다. 하지만 실제 프로그램에서는 많은 수의 스레드가 동시에 실행되며, 이러한 스레드들은 공유 자원에 대한 동시 접근을 필요로 하기 때문에 복잡한 상황이 발생할 수 있습니다. 이러한 상황을 다루기 위해 우리는 스레드 동기화라는 개념을 도입해야 합니다.

 

예를 들어, 두 개의 스레드가 동일한 메모리 위치에 쓰려고 할 때, 어떤 스레드가 먼저 접근해야 하는지 결정할 수 있는 메커니즘이 필요합니다. 이를 해결하기 위한 방법 중 하나가 '뮤텍스(Mutex)'입니다. 뮤텍스는 공유 자원에 대한 동시 접근을 제어하는 데 사용되는 도구입니다.

 

다음은 C++에서 뮤텍스를 사용하는 간단한 예입니다.

 

[예제]

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

std::mutex mtx; // 뮤텍스 객체 생성

void print_block(int n, char c) {
    mtx.lock(); // 뮤텍스 잠금
    for (int i=0; i<n; ++i) { 
      std::cout << c; 
    }
    std::cout << '\n';
    mtx.unlock(); // 뮤텍스 해제
}

int main() {
    std::thread th1(print_block, 50, '*');
    std::thread th2(print_block, 50, '$');

    th1.join();
    th2.join();

    return 0;
}

 

이 코드에서는 두 개의 스레드가 동시에 실행되고, 각 스레드는 print_block 함수를 호출하여 문자를 출력합니다. 이 함수는 뮤텍스를 사용하여 출력 작업을 동기화합니다. 즉, 한 번에 하나의 스레드만 출력을 수행할 수 있습니다.

 

이처럼 스레드는 동시성을 높이는 강력한 도구이지만, 잘못 사용하면 데이터 불일치, 데드락(deadlock) 등의 문제를 일으킬 수 있습니다. 그래서 이후 섹션에서는 스레드 동기화와 관련된 다양한 기법과 테크닉들을 살펴보겠습니다. 이를 이해하면, 여러분은 더욱 효과적인 동시성 프로그래밍을 구현할 수 있게 될 것입니다.

 

13.1.2. 스레드의 필요성

스레드는 우리가 동시에 여러 작업을 수행할 수 있도록 해주는 강력한 도구입니다. 스레드를 사용하면 한 프로세스 내에서 여러 가지 연산을 동시에 실행할 수 있어, CPU 사용률을 극대화하고 프로그램의 반응성을 향상할 수 있습니다. 이는 특히 복잡한 계산이 필요한 프로그램, 사용자 인터페이스가 있는 프로그램, 네트워크 서버와 같은 상황에서 매우 유용합니다. 

 

예를 들어, 복잡한 계산을 하는 프로그램에서 스레드를 사용하지 않는다면, CPU의 여러 코어 중 하나만 이용하게 됩니다. 이는 CPU의 나머지 코어가 공짜로 남아 있으면서, 프로그램이 느리게 동작하게 만듭니다. 반면에, 이러한 작업을 여러 스레드로 분할하면, 여러 코어에서 동시에 실행될 수 있으므로 전체 작업 시간이 줄어들고 CPU 사용률이 향상됩니다. 

 

[예제]

#include <iostream>
#include <thread>

// 복잡한 계산을 수행하는 함수
void complexCalculation() {
    // 복잡한 계산 수행...
}

int main() {
    std::thread t1(complexCalculation); // 첫 번째 스레드 생성
    std::thread t2(complexCalculation); // 두 번째 스레드 생성

    t1.join(); // 첫 번째 스레드가 끝날 때까지 기다림
    t2.join(); // 두 번째 스레드가 끝날 때까지 기다림

    return 0;
}

 

위의 코드에서는 복잡한 계산을 수행하는 complexCalculation 함수를 각각 다른 스레드에서 동시에 실행합니다. 이를 통해 두 번의 계산을 동시에 처리할 수 있게 되어 전체 실행 시간을 줄일 수 있습니다.

 

또한, 사용자 인터페이스가 있는 프로그램에서는 사용자 입력을 받아 처리하는 동안에도 다른 작업을 계속해서 수행해야 할 수 있습니다. 이 경우에도 스레드를 사용하여 사용자 인터페이스와 백그라운드 작업을 동시에 처리할 수 있습니다. 

 

따라서, 스레드는 프로그램의 동시성을 높이고, CPU 사용률을 최적화하며, 더 나은 사용자 경험을 제공하는데 필요한 필수적인 도구입니다. 이러한 이유로 많은 현대적인 프로그래밍 언어와 운영 체제는 스레드를 지원하고 있습니다. 그러나 스레드는 적절히 관리되지 않으면 복잡한 문제를 일으킬 수 있으므로, 스레드의 동작 방식과 이를 적절히 사용하는 방법을 이해하는 것이 중요합니다. 

 

13.1.3. 멀티 스레드와 멀티 프로세스 비교

멀티스레딩과 멀티프로세싱은 컴퓨터 시스템에서 동시성을 구현하는 두 가지 주요 방법입니다. 각 방법은 특정 시나리오에서 장점을 가지며, 그들의 차이점을 이해하는 것은 프로그램의 성능과 효율성을 최적화하는 데 도움이 됩니다.

 

멀티프로세싱은 두 개 이상의 CPU 또는 CPU 코어를 사용하여 여러 프로세스를 동시에 실행하는 기법입니다. 각 프로세스는 자체적으로 메모리 공간을 가지며, 다른 프로세스와 정보를 공유하기 위해서는 별도의 프로세스 간 통신(IPC) 기술을 사용해야 합니다. 이렇게 독립적인 메모리를 사용함으로써 프로세스 간에 서로 영향을 주지 않고 안정적으로 작동할 수 있습니다. 그러나, 프로세스를 생성하고 제거하는데 드는 오버헤드와 프로세스 간 통신의 비용이 높다는 단점이 있습니다. 

 

[예제]

#include <sys/types.h>
#include <unistd.h>
#include <iostream>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        std::cout << "This is child process." << std::endl;
    } else {
        std::cout << "This is parent process." << std::endl;
    }

    return 0;
}

 

이 코드는 fork() 함수를 사용하여 새로운 프로세스를 생성하는 간단한 예제입니다. 부모 프로세스는 fork()를 호출한 후, 자식 프로세스의 PID를 받게 되고, 자식 프로세스는 0을 반환받습니다. 이를 통해 프로세스가 자신이 부모인지 자식인지를 판단할 수 있습니다. 

 

반면에, 멀티스레딩은 하나의 프로세스 내에서 여러 스레드를 동시에 실행하는 기법입니다. 각 스레드는 프로세스의 메모리 공간을 공유하므로 스레드 간의 통신이 더 쉽고 빠릅니다. 이로 인해 스레드의 생성과 제거에 필요한 리소스가 더 적습니다. 그러나 스레드 간의 메모리 공유는 데이터 경쟁 조건(race condition)이나 데드락(deadlock) 등의 복잡한 동기화 문제를 초래할 수 있습니다. 

 

[예제]

#include <iostream>
#include <thread>

void workerThread() {
    std::cout << "This is a worker thread." << std::endl;
}

int main() {
    std::thread t(workerThread);
    t.join();

    return 0;
}

 

이 코드는 C++11의 스레드 라이브러리를 사용하여 새로운 스레드를 생성하고, 생성된 스레드에서 workerThread 함수를 실행하는 간단한 예제입니다. std::thread 객체를 생성할 때 함수 이름을 전달하면, 그 함수가 새로운 스레드에서 실행되게 됩니다. join() 함수는 새로 생성한 스레드가 종료될 때까지 기다리는 역할을 합니다.

 

결론적으로, 프로그램의 요구사항과 특성에 따라 멀티스레드와 멀티프로세스 중 적절한 방법을 선택해야 합니다. 복잡한 동기화 문제를 피하려면 멀티프로세싱을, 효율적인 자원 사용과 빠른 응답 시간을 원한다면 멀티스레딩을 선택할 수 있습니다. 

 

추가적으로, 멀티스레드와 멀티프로세스 모두 운영 체제의 스케줄러에 의해 제어되며, 이 스케줄러는 실행 가능한 스레드나 프로세스를 결정하고 CPU의 시간을 할당하는 역할을 합니다. 이에 대한 더 깊은 이해를 위해 운영체제에 대한 기본적인 이해가 필요하게 됩니다. 

 

13.1.4. 스레드와 코어

스레드와 코어의 관계를 이해하기 위해서는 먼저 컴퓨터의 중앙 처리 장치(CPU)의 구조를 알아야 합니다. CPU는 프로그램의 명령어를 해석하고 실행하는 컴퓨터의 두뇌라고 할 수 있습니다. CPU 내에는 하나 이상의 '코어'가 있고, 이 코어가 실제로 명령어를 실행합니다. 여러 코어를 갖는 CPU를 '멀티코어' CPU라고 부릅니다.  

 

멀티코어 CPU는 여러 개의 작업을 동시에 처리할 수 있습니다. 예를 들어, 4 코어 CPU는 한 번에 최대 4개의 작업을 동시에 처리할 수 있습니다. 이는 멀티태스킹을 더 효율적으로 수행할 수 있게 합니다. 

 

스레드는 CPU가 작업을 수행하는 기본 단위로, 프로세스 내에서 실행되는 작업의 흐름입니다. 스레드는 프로세스의 자원을 공유하면서 동시에 실행될 수 있습니다. 

 

하나의 코어에서는 한 번에 하나의 스레드만 실행할 수 있습니다. 그러나 운영 체제의 스케줄링에 의해 여러 스레드가 빠르게 전환되면서 마치 여러 스레드가 동시에 실행되는 것처럼 보일 수 있습니다. 이를 '콘텍스트 스위칭'이라고 합니다.

 

멀티코어 CPU에서는 코어의 수에 따라 실제로 여러 스레드를 동시에 실행할 수 있습니다. 이를 '병렬 처리'라고 합니다.

 

예를 들어, 다음은 C++에서 멀티스레딩을 활용하는 간단한 코드입니다:

[예제]

#include <iostream>
#include <thread>

void printThreadID(int id) {
    std::cout << "Thread ID: " << id << std::endl;
}

int main() {
    std::thread t1(printThreadID, 1);
    std::thread t2(printThreadID, 2);
    std::thread t3(printThreadID, 3);
    std::thread t4(printThreadID, 4);

    t1.join();
    t2.join();
    t3.join();
    t4.join();

    return 0;
}

 

이 코드는 4개의 스레드를 생성하고 각각의 스레드에서 printThreadID 함수를 병렬로 실행합니다. 이 코드를 실행하면 4개의 스레드가 동시에 실행되는 것을 볼 수 있습니다.

 

이렇게 멀티스레딩과 멀티코어를 활용하면 동시에 여러 작업을 처리하여 프로그램의 성능을 향상할 수 있습니다. 그러나 병렬 처리는 프로그램의 복잡도를 증가시키므로 동기화 문제를 주의해야 합니다. 또한, 모든 작업이 병렬로 처리될 수 있는 것은 아니므로 알맞은 병렬화 전략을 선택해야 합니다.

 

13.1.5. 멀티 스레드의 장단점

멀티 스레딩은 프로그램의 성능을 향상하는 강력한 도구이지만, 적절하게 사용되지 않으면 예상치 못한 문제를 일으킬 수 있습니다. 따라서 멀티 스레딩의 장점과 단점을 잘 이해하고 사용해야 합니다.

 

장점
  • 동시성: 멀티 스레드를 이용하면 CPU 사용률을 최대화하고, I/O 바운드 작업(파일 입출력이나 네트워크 통신 등)에서는 블록된 스레드가 있는 동안 다른 스레드가 작업을 계속할 수 있어 동시성을 확보할 수 있습니다.
  • 효율적인 자원 활용: 스레드는 같은 프로세스 내에서 메모리와 자원을 공유하기 때문에, 자원을 효율적으로 활용할 수 있습니다. 이는 프로세스를 생성하는 것보다 메모리와 CPU 시간을 절약할 수 있습니다.
  • 사용자 반응성 향상: 멀티 스레드 애플리케이션에서는 하나의 스레드가 블록 되거나 지연되는 동안에도 다른 스레드가 사용자와 상호작용을 계속할 수 있습니다. 예를 들어, 웹 브라우저에서는 하나의 탭이 로딩되는 동안 다른 탭을 사용할 수 있습니다.
단점
  • 동기화 문제: 멀티 스레드는 메모리와 자원을 공유하므로 스레드 간에 데이터를 공유하거나 접근할 때 동기화 문제가 발생할 수 있습니다. 이를 해결하기 위해 뮤텍스, 세마포어 등의 동기화 기법을 사용해야 합니다.
  • 디버깅 어려움: 멀티 스레드 프로그램은 디버깅이 어렵습니다. 스레드는 실행 순서가 예측 불가능하기 때문에 동일한 입력에도 다른 출력을 내놓을 수 있습니다.
  • 설계와 구현 복잡도 증가: 멀티 스레드 프로그램을 작성하려면 프로그램을 분할하고, 스레드 간의 통신을 관리하고, 동기화 이슈를 처리하는 등의 복잡한 작업이 필요합니다.

예를 들어, 다음 C++ 코드는 멀티 스레딩에서 공유 자원에 접근할 때 동기화를 보여줍니다.

 

[예제]

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

std::mutex mtx; // 공유 자원에 대한 뮤텍스 선언
int counter = 0; // 공유 자원 

void increment(int num) {
    for(int i=0; i<num; i++) {
        std::lock_guard<std::mutex> guard(mtx); // 뮤텍스로 공유 자원에 접근 
        ++counter;
        std::cout << std::this_thread::get_id() << ": " << counter << "\n";
    }
}

int main() {
    std::thread t1(increment, 5000);
    std::thread t2(increment, 5000);

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

    std::cout << "Final counter: " << counter << "\n";

    return 0;
}

 

이 예제에서는 두 스레드가 공유 변수 counter에 동시에 접근하는 상황을 생성합니다. 이때 std::mutex와 std::lock_guard를 이용해 동기화를 보장하고 있습니다. 각 스레드는 뮤텍스를 잠그고(counter를 증가시키는 작업), 작업이 끝나면 자동으로 뮤텍스를 해제합니다(스코프 벗어나는 순간).

 

[예제]

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

std::mutex mtx; // 공유 자원에 대한 뮤텍스 선언
int counter = 0; // 공유 자원 

void increment(int num) {
    for(int i=0; i<num; i++) {
        std::lock_guard<std::mutex> guard(mtx); // 뮤텍스로 공유 자원에 접근 
        ++counter;
        std::cout << std::this_thread::get_id() << ": " << counter << "\n";
    }
}

int main() {
    std::thread t1(increment, 5000);
    std::thread t2(increment, 5000);

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

    std::cout << "Final counter: " << counter << "\n";

    return 0;
}

13.2. 스레드의 생성과 종료

C/C++에서 스레드의 생성과 종료는 <thread> 라이브러리를 사용합니다. 스레드 생성은 std::thread 객체를 만들며, 스레드에 실행할 함수와 인수를 전달합니다. 스레드는 바로 시작되며, join() 메서드를 호출하여 스레드가 끝날 때까지 기다립니다. detach() 메서드는 스레드를 백그라운드로 보내어 독립적으로 실행되게 합니다. 이렇게 종료된 스레드의 리소스는 자동으로 회수됩니다. 이 방법으로 다수의 작업을 병렬로 수행할 수 있습니다.

13.2.1. 스레드 생성하기

스레드를 만드는 것은 프로그램에서 동시에 여러 작업을 수행하기 위한 강력한 방법입니다. C++11부터는 <thread>라는 라이브러리를 제공하여 스레드를 쉽게 생성할 수 있습니다. 기본적으로, std::thread 객체를 생성하고, 실행할 함수와 필요한 매개변수를 전달함으로써 스레드를 생성합니다. 

 

스레드 생성의 가장 간단한 예제로, 다음의 코드를 살펴봅시다.

 

[예제]

#include <iostream>
#include <thread>

// 스레드에서 실행할 함수
void myFunction() {
    std::cout << "Hello from Thread!\n";
}

int main() {
    // 스레드 생성 및 실행
    std::thread t(myFunction);

    // 스레드가 끝날 때까지 기다림
    t.join();

    return 0;
}

 

위 코드에서는 myFunction이라는 함수를 새로운 스레드에서 실행하는 std::thread 객체 t를 생성했습니다. t.join()은 해당 스레드가 종료될 때까지 메인 스레드가 기다리게 만듭니다. 만약 join()을 호출하지 않으면, 프로그램이 종료될 때 자동으로 모든 스레드는 종료됩니다. 그런데 이는 종료되지 않은 스레드가 실행 중인 상황에서 프로그램이 종료되는 문제를 야기할 수 있습니다. 

 

그리고 주의할 점은, 생성된 스레드의 실행 순서는 운영체제에 의해 결정되므로, 여러 스레드를 생성했을 때 각 스레드가 정확히 언제 실행될지는 예측할 수 없습니다. 

 

이제 매개변수를 가진 함수를 스레드에서 실행하는 방법을 알아보겠습니다. 이 경우, std::thread 객체를 생성할 때 함수와 함께 매개변수를 전달하면 됩니다. 

 

[예제]

#include <iostream>
#include <thread>

// 스레드에서 실행할 함수
void printNum(int num) {
    std::cout << "Number: " << num << "\n";
}

int main() {
    int num = 42;

    // 스레드 생성 및 실행
    std::thread t(printNum, num);

    // 스레드가 끝날 때까지 기다림
    t.join();

    return 0;
}

 

이 예제에서는 printNum이라는 함수에 정수 num을 인자로 전달하여 스레드를 생성했습니다. std::thread 생성자는 가변 인자를 받으므로 여러 개의 매개변수를 전달할 수 있습니다. 

 

스레드 프로그래밍을 할 때는 데이터 경쟁 조건(race condition)을 주의해야 합니다. 이는 여러 스레드가 동시에 같은 데이터에 액세스 할 때 발생하는 문제입니다. 

 

13.2.2. 스레드 종료하기

스레드를 안전하게 종료하는 것은 프로그램의 안정성을 위해 매우 중요합니다. 스레드를 생성하고 제어하는 방법을 이해했다면, 이제 어떻게 스레드를 종료하는지 알아보겠습니다. 스레드는 주로 두 가지 방법으로 종료됩니다. 첫째, 스레드 함수가 리턴하면 자동으로 스레드가 종료됩니다. 둘째, 메인 스레드에서 join()을 호출하여 특정 스레드가 종료될 때까지 기다릴 수 있습니다. 

 

다음은 첫 번째 방법에 대한 간단한 예시입니다.

 

[예제]

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Hello, World! From a thread.\n";
    // 이 함수가 끝나면, 스레드도 종료됩니다.
}

int main() {
    std::thread t(threadFunction);
    t.join();  // 메인 스레드는 t 스레드가 종료될 때까지 대기합니다.
    return 0;
}

 

위의 예제에서, threadFunction 함수가 종료되면 생성된 스레드도 자동으로 종료됩니다. 이때, 메인 스레드는 join()을 통해 해당 스레드가 종료될 때까지 기다립니다. 

 

그런데 여기서 주의할 점이 있습니다. std::thread객체가 소멸될 때, 해당 스레드가 아직 종료되지 않았다면 std::terminate()가 호출되어 프로그램이 강제로 종료됩니다. 이를 방지하기 위해 join() 또는 detach()를 호출해야 합니다. join()은 이미 설명했으니, detach()에 대해 알아보겠습니다. 

[예제]

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Hello, World! From a thread.\n";
    // 이 함수가 끝나면, 스레드도 종료됩니다.
}

int main() {
    std::thread t(threadFunction);
    t.detach();  // t 스레드를 백그라운드로 보내 메인 스레드의 관리를 받지 않습니다.
    return 0;
}

 

이 예제에서는 detach()를 호출하여 메인 스레드가 종료되더라도 백그라운드에서 해당 스레드가 계속 실행되도록 만들었습니다. detach()된 스레드는 프로그램이 종료될 때까지 계속 실행되며, 스레드 함수가 종료되면 자동으로 스레드도 종료됩니다. detach()는 특정 스레드를 "daemon" 스레드로 만들어 주는 것과 비슷합니다. 

 

이렇게 스레드의 종료는 스레드의 수명 주기를 제어하고 프로그램의 안정성을 보장하는 데 중요한 역할을 합니다. 

 

13.2.3. 스레드의 생명주기

스레드의 생명주기를 이해하는 것은 멀티스레딩 프로그래밍에서 핵심적인 요소입니다. 스레드의 생명주기는 대체로 다음과 같이 정의될 수 있습니다.

 

  • 생성(Created): 스레드가 프로그램에 의해 생성되면, 생성 상태로 정의됩니다. 이 상태에서 스레드는 실행되지 않지만, 실행을 준비하는 단계로, CPU에 의해 선택되어 실행 상태로 넘어갈 수 있습니다.
  • 실행(Running): 스레드가 CPU에 의해 선택되어 실행 중인 상태를 의미합니다. 이 상태에서 스레드는 코드를 실행하고 있으며, 필요한 데이터에 액세스 할 수 있습니다.
  • 대기(Waiting): 스레드가 잠시 실행을 멈추고 다른 스레드가 CPU를 사용하도록 내버려 둔 상태입니다. 이는 일반적으로 I/O 작업이나 특정 이벤트의 발생을 기다리는 등의 이유로 발생합니다.
  • 종료(Terminated): 스레드가 작업을 완료하고 종료된 상태입니다. 스레드의 실행 코드가 끝나면 스레드는 종료 상태로 이동하며, 해당 스레드를 위해 할당된 모든 리소스가 시스템에 반환됩니다.

 

이제 C++에서 스레드의 생성부터 종료까지의 과정을 살펴보겠습니다.

 

[예제]

#include <iostream>
#include <thread>

void hello() {
    std::cout << "Hello, World!" << std::endl;
}

int main() {
    std::thread t(hello);  // 스레드 생성
    t.join();              // 스레드가 종료될 때까지 기다림
    return 0;
}

위 예제에서, std::thread t(hello);는 스레드를 생성하는 코드입니다. 이는 새로운 스레드를 생성하고, hello() 함수를 해당 스레드에서 실행합니다. 이때, 생성된 스레드는 실행 상태로 넘어갑니다. 

 

t.join();은 스레드가 종료될 때까지 기다리는 코드입니다. 이는 현재 스레드(메인 스레드)를 대기 상태로 만들며, t 스레드가 종료되면 메인 스레드는 다시 실행 상태로 돌아옵니다. 

 

hello() 함수의 모든 코드가 실행되면, t 스레드는 종료 상태가 됩니다. 이는 스레드의 실행이 완료되었음을 의미하며, 해당 스레드를 위해 할당된 모든 시스템 리소스가 반환됩니다. 

 

이렇게 각 상태 간의 전환은 스레드 스케줄러에 의해 관리되며, 프로그래머는 이러한 생명주기를 이해하고 적절히 코드를 작성함으로써 효과적인 멀티스레딩 프로그램을 작성할 수 있습니다. 

 

13.2.4. 스레드 우선순위

스레드 우선순위란 시스템이 여러 스레드를 동시에 관리할 때, 어떤 스레드를 먼저 실행할 것인지 결정하는 기준을 말합니다. 우선순위가 높은 스레드는 우선 순위가 낮은 스레드보다 먼저 실행되며, 만약 두 스레드의 우선순위가 같다면 스케줄러에 따라 라운드-로빈 방식 등으로 교대로 실행됩니다. 

 

스레드 우선순위 설정은 작업의 중요도나 긴급성에 따라 유연하게 프로그램의 성능을 조절하는데 유용합니다. 예를 들어, 실시간으로 빠른 반응이 요구되는 작업을 수행하는 스레드는 우선순위를 높게 설정하여 다른 스레드보다 먼저 처리되도록 할 수 있습니다. 

 

C++에서는 <thread> 라이브러리를 통해 스레드를 생성하고 관리할 수 있지만, C++ 표준에서는 스레드 우선순위 설정을 직접적으로 지원하지 않습니다. 대신, 플랫폼 종속적인 방식으로 우선순위를 설정할 수 있는데, 윈도우와 리눅스에서는 각각 다른 방식을 사용해야 합니다. 

 

윈도우에서는 SetThreadPriority() 함수를, 리눅스에서는 pthread_setschedparam() 함수를 사용하여 스레드의 우선순위를 설정할 수 있습니다. 아래는 리눅스에서 스레드 우선순위를 설정하는 예제입니다.

 

[예제]

#include <pthread.h>

void *threadFunc(void *arg) {
    // 스레드 코드
}

int main() {
    pthread_t thread;
    pthread_attr_t attr;
    struct sched_param param;

    pthread_attr_init(&attr);
    pthread_attr_setschedpolicy(&attr, SCHED_FIFO);  // 우선순위 스케줄링 정책 설정
    param.sched_priority = 50;  // 우선순위 설정
    pthread_attr_setschedparam(&attr, &param); 

    pthread_create(&thread, &attr, threadFunc, NULL);  // 스레드 생성

    pthread_join(thread, NULL);  // 스레드가 종료될 때까지 기다림
    return 0;
}

pthread_attr_setschedpolicy(&attr, SCHED_FIFO);는 스레드의 스케줄링 정책을 설정하는 함수이며, SCHED_FIFO는 우선순위에 따라 스레드를 실행하는 정책입니다. param.sched_priority = 50;로 스레드의 우선순위를 설정할 수 있습니다. 이 값은 1에서 99까지이며, 높을수록 우선순위가 높습니다. 

 

하지만 이러한 방법은 플랫폼에 종속적이기 때문에 이식성이 떨어집니다. 따라서 가능하면 작업을 적절히 분리하고, 필요한 경우 동기화 기법을 사용하여 프로그램의 동작을 제어하는 것이 좋습니다. 스레드 우선순위는 꼭 필요한 경우에만 사용해야 합니다.

 

13.2.5. 스레드 스케줄링

스레드 스케줄링은 여러 스레드를 동시에 실행할 때 어떤 스레드를 먼저 실행할 것인지, 얼마 동안 실행할 것인지 결정하는 방법을 말합니다. 이는 프로그램의 효율성과 반응성을 결정하는 중요한 요소입니다. 운영체제마다 제공하는 스레드 스케줄링 알고리즘은 다르지만 대표적인 방식에는 라운드 로빈, 우선순위 기반, 공정 스케줄링 등이 있습니다. 

 

라운드 로빈은 각 스레드에게 동일한 시간 할당량을 주고, 그 시간이 끝나면 다음 스레드에게 제어권을 넘기는 방식입니다. 우선순위 기반은 각 스레드에게 우선순위를 부여하고, 우선순위가 높은 스레드부터 처리하는 방식입니다. 공정 스케줄링은 실행 대기 중인 스레드 중에서 가장 오랫동안 대기한 스레드부터 실행하는 방식입니다. 

 

C++에서는 <thread> 라이브러리를 사용하여 스레드를 생성하고, 실행합니다. 하지만 C++ 표준에서는 스레드 스케줄링을 직접 제어하는 기능을 제공하지 않습니다. 이는 대부분 운영체제가 자동으로 관리하기 때문입니다. 하지만 운영체제에 따라 스레드 우선순위를 설정하거나, 특정 스레드에게 더 많은 CPU 시간을 할당하는 등의 세부적인 스케줄링을 조정할 수 있습니다. 

 

아래는 C++로 작성한 간단한 스레드 생성 및 실행의 예제입니다.

 

[예제]

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Hello from thread " << std::this_thread::get_id() << std::endl;
}

int main() {
    std::thread t(threadFunction);  // 스레드 생성
    t.join();  // 스레드가 끝날 때까지 대기
    return 0;
}

 

위 코드는 threadFunction을 별도의 스레드에서 실행합니다. std::this_thread::get_id() 함수는 현재 실행 중인 스레드의 ID를 반환합니다. 스레드는 생성 순서에 따라 운영체제에 의해 자동으로 스케줄링되어 실행됩니다. 

 

하지만 다시 말하면, 이러한 스케줄링을 직접 제어하려면 플랫폼에 종속적인 코드를 작성해야 할 수 있습니다. 따라서 가능하면 스레드를 동일한 우선순위로 생성하고, 작업을 균등하게 분할하여 스레드 스케줄링에 크게 의존하지 않도록 프로그래밍하는 것이 좋습니다. 

 

참고로 스레드의 우선순위나 스케줄링을 조정하는 기능은 실시간 시스템이나 고성능 컴퓨팅과 같이 특정 작업에 대한 높은 제어력이 필요한 경우에 주로 사용됩니다. 이러한 기능을 사용할 때는 세밀한 테스트와 튜닝이 필요하므로, 이에 대한 깊은 이해와 경험이 필요합니다. 

 

13.2.6. 스레드 상태 변화

스레드의 상태 변화를 이해하려면, 먼저 스레드가 가질 수 있는 다양한 상태를 알아야 합니다. 스레드는 크게 '새로 생성된 상태', '실행 대기 상태', '실행 상태', '중지 상태', '종료 상태' 등의 상태를 가질 수 있습니다. 

 

  • 새로 생성된 상태: 스레드가 생성되고 실행이 시작되기 전의 상태를 말합니다. 스레드를 생성하면 이 상태가 됩니다.
  • 실행 대기 상태: 스레드가 CPU를 할당받아 실행될 수 있도록 준비된 상태를 말합니다. 스레드 스케줄러에 의해 실행될 차례를 기다리고 있는 상태입니다.
  • 실행 상태: 스레드가 CPU를 할당받아 코드를 실행하고 있는 상태를 말합니다.
  • 중지 상태: 일시적으로 실행을 중지한 상태를 말합니다. 스레드는 특정 조건이 만족될 때까지 실행을 중지하고 다시 실행 대기 상태로 돌아갑니다.
  • 종료 상태: 스레드가 작업을 완료하고 모든 자원을 반환한 상태를 말합니다. 종료된 스레드는 다시 실행되지 않습니다.


이러한 상태들은 스레드의 생명주기를 나타냅니다. 스레드가 생성되어 실행되고, 종료될 때까지 여러 상태를 거치게 됩니다. 

 

C++에서는 <thread> 라이브러리를 사용하여 스레드의 생성과 실행, 종료를 관리합니다. 아래의 코드는 스레드 생성, 실행, 종료의 기본적인 예를 보여줍니다. 

 

[예제]

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

void threadFunction() {
    std::cout << "Thread ID: " << std::this_thread::get_id() << " started.\n";
    std::this_thread::sleep_for(std::chrono::seconds(3));  // 스레드를 3초간 중지 상태로 만듭니다.
    std::cout << "Thread ID: " << std::this_thread::get_id() << " ended.\n";
}

int main() {
    std::thread t(threadFunction);  // 새로운 스레드 생성(새로 생성된 상태)
    t.detach();  // 스레드를 백그라운드로 보내 실행되도록 함(실행 상태)
    // 스레드 t는 이제 독립적으로 실행되며, main 함수는 t의 종료를 기다리지 않고 계속 진행합니다.
    // 이 시점에서 스레드 t는 실행 상태, 실행 대기 상태, 중지 상태를 오가게 됩니다.
    // 스레드가 작업을 완료하면 자동으로 종료 상태가 됩니다.
    
    std::this_thread::sleep_for(std::chrono::seconds(5));
    return 0;
}


위의 코드에서 threadFunction은 각 스레드가 실행할 작업을 정의한 함수입니다. std::this_thread::get_id()는 현재 스레드의 고유 ID를 가져오는 함수이며, std::this_thread::sleep_for()는 현재 스레드를 일정 시간 동안 중지 상태로 만드는 함수입니다. 

 

std::thread t(threadFunction); 문장은 새로운 스레드를 생성하고 threadFunction을 실행합니다. t.detach(); 문장은 스레드 t를 백그라운드로 보내 실행되도록 합니다. 이 시점에서 스레드 t는 실행 상태, 실행 대기 상태, 중지 상태를 오가며 작업을 수행하게 됩니다. 작업이 완료되면 스레드는 종료 상태가 됩니다. 

 

이렇게 스레드는 여러 상태를 거치며 작업을 수행하게 됩니다. 이는 CPU의 사용을 효율적으로 관리하고, 여러 작업을 동시에 처리할 수 있게 해주는 매우 중요한 메커니즘이므로 잘 이해하고 사용하는 것이 중요합니다. 


13.3. 스레드 동기화 기술

스레드 동기화는 여러 스레드가 공유 자원에 동시에 접근할 때 데이터 불일치 문제를 방지하는 기술입니다. 동기화 기법에는 뮤텍스(mutex), 세마포어(semaphore), 데드락(deadlock) 처리 등이 있습니다. 뮤텍스는 한 번에 하나의 스레드만 공유 자원에 접근하도록 제어하는 기술이며, 세마포어는 여러 스레드가 접근 가능한 자원의 수를 제어합니다. 데드락은 두 스레드가 서로의 자원을 기다리며 무한히 대기하는 상황을 말합니다. 

13.3.1. 뮤텍스와 세마포어 이해하기

뮤텍스(mutex)와 세마포어(semaphore)는 멀티 스레딩 환경에서 주로 사용되는 동기화 기법들입니다.  

 

뮤텍스는 Mutual Exclusion의 줄임말로, 한 번에 한 스레드만 특정 코드 블록(임계 영역)을 실행할 수 있게 합니다. 뮤텍스를 통해 임계 영역에 들어가는 스레드의 수를 제한하여 자원에 대한 동시 접근을 방지합니다.  

 

세마포어는 뮤텍스와 비슷한 역할을 하지만, 세마포어는 여러 스레드가 임계 영역에 동시에 접근할 수 있도록 해 줍니다. 세마포어의 값은 동시에 접근 가능한 최대 스레드 수를 나타냅니다.  

 

이제 C++에서 뮤텍스를 사용하는 방법을 보겠습니다.  

 

[예제]

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

std::mutex mtx; // 전역 변수로 뮤텍스 정의  

void print_block(int n, char c) {
    mtx.lock();
    for (int i = 0; i < n; ++i) {
        std::cout << c;
    }
    std::cout << '\n';
    mtx.unlock();
}  

int main() {
    std::thread th1(print_block, 50, '*');
    std::thread th2(print_block, 50, '$');  

    th1.join();
    th2.join();  

    return 0;
}

위의 예제 코드에서 뮤텍스는 mtx.lock()으로 잠그고 mtx.unlock()으로 풉니다. lock()이 호출되면 해당 스레드는 뮤텍스가 잠긴 상태에서 다른 스레드가 unlock()을 호출할 때까지 대기합니다. 뮤텍스를 통해 두 개의 스레드가 동시에 print_block 함수를 실행하지 않도록 하였습니다. 

 

C에서 세마포어를 사용하는 방법은 다음과 같습니다.  

 

[예제]

#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>  

sem_t sem; // 전역 변수로 세마포어 정의  

void *thread_function(void *arg) {
    sem_wait(&sem); // 세마포어 감소
    printf("Entered..\n");  

    //critical section
    sleep(4);
    
    printf("Exiting..\n");
    sem_post(&sem); // 세마포어 증가
}  

int main() {
    sem_init(&sem, 0, 1); // 세마포어 초기화
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread_function, NULL);
    sleep(2);
    pthread_create(&t2, NULL, thread_function, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    sem_destroy(&sem); // 세마포어 삭제
    return 0;
}

 

위의 예제 코드에서는 세마포어를 sem_wait(&sem)으로 감소시키고, sem_post(&sem)로 증가시킵니다. sem_wait(&sem)이 호출되면 세마포어가 0이 될 때까지 스레드가 대기합니다. 세마포어를 통해 두 개의 스레드가 동시에 critical section을 실행하지 않도록 하였습니다. 

 

뮤텍스와 세마포어는 동기화 기법의 한 부분으로, 공유 자원에 대한 동시 접근을 제어하여 데이터 일관성을 유지합니다. 그러나 이러한 동기화 기법을 잘못 사용하면 데드락 같은 문제를 일으킬 수 있으므로 주의해야 합니다.  

 

스레드의 동기화 문제를 해결하기 위해 뮤텍스와 세마포어 외에도 다양한 동기화 메커니즘이 존재합니다. 이들 중 일부는 뮤텍스나 세마포어보다 복잡하지만, 특정 상황에서 더 효과적인 동기화를 제공합니다. 

 

C++의 뮤텍스와 같이 동기화를 위한 다른 기법은 condition variable과 future입니다. condition variable은 뮤텍스와 함께 사용되어 특정 조건이 충족될 때까지 스레드의 실행을 차단하는 데 사용되며, future는 비동기 작업의 결과를 저장하고 접근하는 데 사용됩니다. 

 

C의 세마포어와 같이 동기화를 위한 다른 기법은 데드락 회피(deadlock avoidance)와 데드락 예방(deadlock prevention)입니다. 데드락 회피는 시스템이 데드락 상태에 빠지지 않도록 작업을 동적으로 스케줄링하는 기법이며, 데드락 예방은 데드락이 발생할 수 있는 조건을 미리 방지하는 기법입니다. 

 

다양한 동기화 메커니즘을 이해하고 적절히 사용하는 것은 멀티스레드 프로그래밍에서 중요한 기술입니다. 각 동기화 기법은 특정 상황에서 더 적합하며, 그러므로 어떤 기법을 사용할지 결정하기 위해서는 상황에 따라 장단점을 고려해야 합니다.  

 

스레드 동기화는 복잡하고 섬세한 주제이며, 이를 잘못 관리하면 데이터 무결성을 손상시키거나 성능 저하를 초래할 수 있습니다. 따라서 동기화를 사용할 때는 해당 기법이 주어진 문제에 적합한지, 그리고 해당 기법을 올바르게 사용하고 있는지에 대해 주의 깊게 고려해야 합니다. 스레드 동기화에 관한 깊은 이해는 시간과 경험을 통해 축적됩니다. 이로서 멀티스레드 환경에서 안정적이고 효율적인 애플리케이션을 구축할 수 있는 기초를 마련하게 됩니다. 

 

13.3.2 뮤텍스를 사용한 스레드 동기화

스레드 동기화를 위한 강력한 도구 중 하나가 바로 '뮤텍스(Mutex)'입니다. 뮤텍스는 '상호 배제(mutual exclusion)'의 줄임말로, 한 번에 하나의 스레드만 공유 자원에 접근할 수 있게 해줍니다. 뮤텍스는 스레드가 공유 자원에 접근하기 전에 먼저 잠그고(lock), 사용 후에는 해제(unlock)합니다. 이로 인해 다른 스레드들은 뮤텍스가 해제될 때까지 기다리게 됩니다. 

 

이제 C와 C++에서 뮤텍스를 사용하는 방법에 대해 알아보겠습니다. 

 

[예제]

#include <stdio.h>
#include <pthread.h>  

pthread_mutex_t lock;
int counter;  

void* thread_function(void* arg)
{
    pthread_mutex_lock(&lock);
    counter += 1;
    printf("Thread %d started\n", counter);
    pthread_mutex_unlock(&lock);  

    return NULL;
}  

int main()
{
    pthread_t thread1, thread2;
    counter = 0;  

    pthread_mutex_init(&lock, NULL);
    pthread_create(&thread1, NULL, thread_function, NULL);
    pthread_create(&thread2, NULL, thread_function, NULL);
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    pthread_mutex_destroy(&lock);  

    return 0;
}

 

위 예제에서는 두 개의 스레드가 'counter'라는 공유 변수에 접근하려고 시도합니다. 뮤텍스를 사용해 한 번에 하나의 스레드만이 변수를 수정하도록 보장합니다. 

 

C++에서는 <mutex> 헤더 파일을 사용해 뮤텍스를 사용할 수 있습니다.  

 

[예제]

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

std::mutex mtx;
int counter;  

void thread_function()
{
    mtx.lock();
    counter += 1;
    std::cout << "Thread " << counter << " started" << std::endl;
    mtx.unlock();
}  

int main()
{
    std::thread thread1(thread_function);
    std::thread thread2(thread_function);
    thread1.join();
    thread2.join();  

    return 0;
}

 

위의 C++ 예제도 마찬가지로 뮤텍스를 사용하여 'counter'라는 공유 변수에 한 번에 하나의 스레드만 접근하도록 제한합니다. 

 

뮤텍스를 사용하는 것은 매우 중요하지만, 잘못 사용하면 데드락이 발생할 수 있습니다. 데드락은 두 개 이상의 스레드가 서로가 보유한 자원을 기다리며 진행을 멈추는 상황을 말합니다. 따라서 뮤텍스를 사용할 때에는 항상 주의를 기울여야 합니다. 

 

뮤텍스의 잠금과 해제는 반드시 쌍을 이루어야 합니다. 잠금을 해제하지 않으면 다른 스레드들이 영원히 기다리게 될 수 있습니다. 가능한 한 뮤텍스의 범위를 최소화하십시오. 너무 많은 연산을 뮤텍스로 보호하려고 하면 성능이 저하될 수 있습니다. 뮤텍스로 보호되는 코드 영역에서는 가능한 한 예외를 발생시키지 않도록 하십시오. 그렇지 않으면 예외가 발생했을 때 뮤텍스를 해제하지 못해 데드락이 발생할 수 있습니다. 

 

C++에서는 이러한 문제를 예방하기 위해 RAII(Resource Acquisition Is Initialization) 원칙을 따르는 std::lock_guard나 std::unique_lock 같은 도구를 제공합니다. 이들은 생성자에서 뮤텍스를 잠그고, 소멸자에서 자동으로 뮤텍스를 해제하여 예외가 발생하더라도 뮤텍스가 안전하게 해제되도록 합니다. 

 

아래는 std::lock_guard를 사용하는 C++ 코드 예제입니다.  

 

[예제]

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

std::mutex mtx;
int counter;  

void thread_function()
{
    std::lock_guard<std::mutex> guard(mtx);
    counter += 1;
    std::cout << "Thread " << counter << " started" << std::endl;
}  

int main()
{
    std::thread thread1(thread_function);
    std::thread thread2(thread_function);
    thread1.join();
    thread2.join();  

    return 0;
}

 

이렇게 뮤텍스를 사용하면 여러 스레드가 동시에 데이터를 변경하거나 접근하는 것을 막을 수 있습니다. 하지만 기억해야 할 것은, 뮤텍스를 사용하면 스레드 간의 동기화를 보장할 수 있지만, 동시에 여러 스레드가 작업을 수행하는 병렬 처리의 이점을 다소 상쇄시킬 수 있다는 점입니다. 따라서 뮤텍스 사용은 꼭 필요한 곳에서만 적절히 사용하는 것이 좋습니다. 

 

그럼 이제 뮤텍스가 어떻게 동작하는지, 어떻게 사용하는지에 대해 알게 되었습니다. 뮤텍스는 상당히 강력한 동기화 도구지만, 잘못 사용하면 데드락 같은 문제를 야기할 수 있습니다. 따라서 사용할 때는 반드시 주의를 기울여야 합니다. 또한, 뮤텍스는 필요할 때만 사용하고, 그 범위를 최소화하는 것이 좋습니다. 이제 세마포어에 대해서도 알아보도록 하겠습니다. 

 

13.3.3. 세마포어를 사용한 스레드 동기화 

세마포어는 뮤텍스와 유사한 동기화 기법으로, 여러 스레드 또는 프로세스가 공유 자원에 대한 접근을 제어합니다. 하지만 뮤텍스와 다르게 세마포어는 동시에 여러 스레드가 공유 자원에 접근할 수 있게 해주는 점에서 차이가 있습니다. 세마포어는 일종의 카운터로, 이 값은 동시에 접근할 수 있는 스레드의 최대 수를 의미합니다. 

 

세마포어를 사용하는 스레드는 세마포어 값을 감소시키고(잠금), 사용이 끝나면 다시 증가시킵니다(해제). 만약 세마포어 값이 0이면, 다른 스레드들은 세마포어 값이 증가할 때까지 기다려야 합니다. 

 

세마포어는 C와 C++에서 어떻게 사용하는지 살펴보겠습니다. 

 

C에서는 <semaphore.h> 헤더 파일을 사용하여 세마포어를 사용할 수 있습니다.

세마포어는 sem_init, sem_wait, sem_post, sem_destroy 등의 함수를 사용하여 제어합니다. 

 

[예제]

#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>  

sem_t sem;
int counter;  

void* thread_function(void* arg)
{
    sem_wait(&sem);
    counter += 1;
    printf("Thread %d started\n", counter);
    sem_post(&sem);  

    return NULL;
}  

int main()
{
    pthread_t thread1, thread2;
    counter = 0;  

    sem_init(&sem, 0, 1);
    pthread_create(&thread1, NULL, thread_function, NULL);
    pthread_create(&thread2, NULL, thread_function, NULL);
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    sem_destroy(&sem);  

    return 0;
}

 

위 예제에서, sem_init 함수는 세마포어를 초기화하며, sem_wait 함수는 세마포어 값을 감소시키고, sem_post 함수는 세마포어 값을 증가시킵니다.  

 


C++에서는 표준 라이브러리에 세마포어가 없기 때문에, 다양한 외부 라이브러리를 사용하거나 직접 구현해야 합니다. 

하지만 std::condition_variable과 std::mutex를 함께 사용하여 세마포어와 유사한 동작을 만들 수 있습니다.  

 

[예제]

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

class Semaphore {
public:
    Semaphore(int count = 0) : count_(count) {}
    
    void Signal() {
        std::unique_lock<std::mutex> lock(mutex_);
        ++count_;
        cv_.notify_one();
    }
    
    void Wait() {
        std::unique_lock<std::mutex> lock(mutex_);
        while(count_ == 0) {
            cv_.wait(lock);
        }
        --count_;
    }  

private:
    std::mutex mutex_;
    std::condition_variable cv_;
    int count_;
};  

int counter = 0;
Semaphore sem(1);  

void thread_function() {
    sem.Wait();
    counter += 1;
    std::cout << "Thread " << counter << " started\n";
    sem.Signal();
}  

int main() {
    std::thread thread1(thread_function);
    std::thread thread2(thread_function);
    thread1.join();
    thread2.join();  

    return 0;
}

 

세마포어를 사용할 때는 아래의 점들을 기억하세요:  

 

세마포어 값이 0이 되면, 세마포어를 사용하려는 다른 스레드들은 대기 상태가 됩니다. 세마포어를 사용하면 여러 스레드가 동시에 공유 자원에 접근할 수 있지만, 과도한 병렬화는 성능을 저하시킬 수 있습니다. 뮤텍스와 마찬가지로, 세마포어도 잠금과 해제가 쌍을 이뤄야 합니다.  

 

세마포어를 사용할 때는 잘못된 사용으로 인해 발생할 수 있는 경쟁 상태(race condition), 데드락(deadlock) 등의 문제를 주의해야 합니다. 세마포어는 뮤텍스와 비교할 때 훨씬 더 유연한 동기화 기법인데, 이는 세마포어가 여러 스레드를 동시에 수용할 수 있기 때문입니다. 그러나 이러한 유연성은 추가적인 복잡성을 동반하며, 개발자는 세마포어를 사용하면서 발생할 수 있는 복잡한 상황에 대비해야 합니다. 

 

그럼에도 불구하고, 잘 사용된 세마포어는 성능과 효율성을 크게 향상할 수 있습니다. 세마포어를 통해 개발자는 여러 스레드가 특정 작업을 분산하여 수행하도록 조정할 수 있으며, 이는 애플리케이션의 전반적인 성능에 긍정적인 영향을 미칩니다. 

 

결론적으로, 세마포어는 뮤텍스와 함께 중요한 동기화 도구 중 하나이며, 다양한 멀티스레딩 환경에서 광범위하게 사용됩니다. 다만, 세마포어를 효과적으로 사용하려면 그 사용 방법과 잠재적 문제를 이해하는 것이 중요합니다. 

 

그래서 스레드 동기화와 세마포어에 대한 깊은 이해는 멀티스레드 프로그래밍에서 중요한 역량 중 하나입니다. 스레드 동기화는 복잡할 수 있지만, 세마포어와 뮤텍스와 같은 도구를 잘 이해하고 사용하면, 공유 리소스에 대한 안전한 접근을 보장하면서 애플리케이션의 성능과 효율성을 향상시킬 수 있습니다. 

 

13.3.4. 락 (Lock)과 데드락 (Deadlock)  

락은 동시에 여러 스레드가 동일한 데이터를 수정하는 것을 막기 위해 사용되는 프로그래밍 기법입니다. 뮤텍스나 세마포어와 같은 도구들이 이를 구현하는 예시들이며, 이들을 통해 공유 데이터에 대한 동기화를 수행하게 됩니다. 

 

그럼 락을 좀 더 자세히 살펴볼까요? 락은 기본적으로 두 가지 상태를 가집니다: 잠긴 상태(locked)와 잠기지 않은 상태(unlocked). 락이 잠긴 상태라면 해당 락을 소유한 스레드만이 데이터에 접근할 수 있습니다. 다른 스레드들은 그 스레드가 락을 풀 때까지 대기해야 합니다. 이렇게 함으로써 동시에 여러 스레드가 같은 데이터를 수정하는 일을 방지합니다. 

 

[예제]

#include <pthread.h>  

pthread_mutex_t lock; 
int shared_data;  

void *thread_function(void *arg)
{
    pthread_mutex_lock(&lock);
    // 공유 데이터 수정
    pthread_mutex_unlock(&lock);
    return NULL;
}

 

위의 C 코드에서는 pthread_mutex_lock() 함수를 사용하여 락을 잠그고, pthread_mutex_unlock() 함수를 사용하여 락을 푸는 과정을 보여줍니다. 이렇게 함으로써 shared_data라는 공유 데이터에 대한 동시 접근을 막고 있습니다. 

 

그런데, 이렇게 여러 락을 사용할 경우 데드락이라는 문제가 발생할 수 있습니다. 데드락은 두 개 이상의 스레드가 서로가 소유한 락을 기다리며 영원히 대기하는 상태를 말합니다. 예를 들어, 스레드 A가 락 X를 가지고 있고 락 Y를 얻기 위해 대기하고 있을 때, 동시에 스레드 B가 락 Y를 가지고 있고 락 X를 얻기 위해 대기한다면, 이 두 스레드는 서로가 소유한 락을 얻을 수 없어 영원히 대기하게 되는 상황이 발생합니다. 이것이 바로 데드락입니다. 

 

데드락은 프로그램의 실행을 멈추게 하므로, 이를 방지하거나 해결하는 것이 중요합니다. 데드락을 해결하는 방법 중 하나는 락의 순서를 정하는 것입니다. 모든 스레드가 같은 순서로 락을 요청하고 해제하면 데드락을 피할 수 있습니다. 다른 방법으로는, 락을 얻지 못했을 때 해당 스레드가 모든 락을 해제하고 다시 대기하는 것입니다. 

 

아래는 C++에서 데드락을 피하는 코드의 예시입니다: 

 

[예제]

#include <mutex>  

std::mutex mutex1, mutex2;  

void thread_a()
{
    std::lock(mutex1, mutex2); // 락들을 한꺼번에 잠근다
    std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
    std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
    // 공유 데이터 수정
}  

void thread_b()
{
    std::lock(mutex1, mutex2); // 락들을 한꺼번에 잠근다
    std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
    // 공유 데이터 수정
}

 

위 코드에서는 std::lock() 함수를 사용하여 두 락을 한꺼번에 잠그고 있습니다. 이렇게 하면 두 락을 한꺼번에 얻으려는 시도가 서로 블록 되는 것을 방지할 수 있습니다. 또한 std::lock_guard는 스코프 베이스의 락 관리를 제공하여, 생성자에서 락을 잠그고 소멸자에서 락을 자동으로 해제하므로 코드가 더 간결해집니다. 

 

스레드 프로그래밍에서 락과 데드락은 꼭 이해해야 할 개념이며, 이들을 올바르게 사용하면 여러 스레드가 동시에 동일한 데이터에 접근하는 문제를 효과적으로 해결할 수 있습니다. 

 

13.3.5. 락의 종류와 사용법  

스레드 동기화에서 '락'은 중요한 개념입니다. 락은 주로 뮤텍스와 세마포어라는 형태로 구현되며, 이 외에도 다양한 락 기법이 존재합니다. 이번 섹션에서는 뮤텍스 외에 스핀락, 리더-라이터 락, 컨디션 변수와 같은 다른 락 기법들을 살펴보겠습니다. 

 

  • 스핀락 (Spinlock): 스핀락은 뮤텍스와 비슷한 동작을 하지만, 락이 해제될 때까지 능동적으로 대기하는 방식으로 동작합니다. 이는 CPU 시간을 많이 사용하므로, 락이 잠시간 동안만 필요할 때 사용하는 것이 좋습니다. 스핀락은 아래와 같이 C언어에서 구현할 수 있습니다. 

[예제]

#include <atomic>  

std::atomic_flag lock = ATOMIC_FLAG_INIT;  

void lock_func()
{
    while(lock.test_and_set(std::memory_order_acquire));
}  

void unlock_func()
{
    lock.clear(std::memory_order_release);
}

 

위의 코드에서 `test_and_set` 함수는 atomic flag를 설정하고 이전 값을 반환합니다. 따라서 락이 해제될 때까지 무한히 대기하는 스핀락의 동작을 구현할 수 있습니다. 

 

  • 리더-라이터 락 (Reader-Writer Lock): 이 락은 데이터를 읽는 스레드와 쓰는 스레드 간의 동기화에 사용됩니다. 여러 스레드가 동시에 데이터를 읽을 수 있지만, 쓰는 스레드는 동시에 단 하나만 데이터를 쓸 수 있도록 제한합니다.  

[예제]

#include <pthread.h>  

pthread_rwlock_t rwlock;  

void *reader(void *arg)
{
    pthread_rwlock_rdlock(&rwlock);
    // 데이터 읽기
    pthread_rwlock_unlock(&rwlock);
}  

void *writer(void *arg)
{
    pthread_rwlock_wrlock(&rwlock);
    // 데이터 쓰기
    pthread_rwlock_unlock(&rwlock);
}

 

위의 코드에서 `pthread_rwlock_rdlock`은 읽기 락을, `pthread_rwlock_wrlock`은 쓰기 락을 잠그는 함수입니다.  

 

  • 컨디션 변수 (Condition Variable): 컨디션 변수는 특정 조건이 충족될 때까지 스레드를 대기상태로 만드는 기능을 합니다. 예를 들어, 스레드가 데이터를 처리할 수 있는 상태가 될 때까지 대기하게 만들 수 있습니다.  

[예제]

#include <pthread.h>  

pthread_mutex_t mutex;
pthread_cond_t cond;
bool ready = false;  

void *producer(void *arg)
{
    pthread_mutex_lock(&mutex);
    // 데이터 생성
    ready = true;
    pthread_cond_signal(&cond);
    pthread_mutex_unlock(&mutex);
}  

void *consumer(void *arg)
{
    pthread_mutex_lock(&mutex);
    while(!ready)
        pthread_cond_wait(&cond, &mutex);
    // 데이터 사용
    pthread_mutex_unlock(&mutex);
}

 

위 코드에서 `pthread_cond_wait` 함수는 조건 변수를 기다리는 동안 뮤텍스를 자동으로 해제하며, 조건이 충족되면 다시 뮤텍스를 잠근다는 특징이 있습니다.  

 

각 락 기법은 특정 상황에 적합하므로, 자신의 코드에서 어떤 락이 가장 적절한지 고민해 보시는 것이 중요합니다.  

 

13.3.6. 락의 활용  

락은 동시성 문제를 해결하기 위한 가장 기본적인 도구입니다. 락을 사용하면 여러 스레드가 동시에 공유 데이터에 접근하는 것을 막을 수 있습니다. 락을 사용하면 공유 데이터에 대한 동시 접근을 막을 수 있지만, 사용 방법에 따라 성능에 큰 영향을 미칠 수 있습니다. 가장 중요한 것은 필요한 최소한의 시간 동안만 락을 보유하는 것입니다. 이를 '락의 범위를 최소화한다'라고 합니다.  

 

[예제]

#include <pthread.h>  

pthread_mutex_t lock;
int shared_data;  

void *thread_function(void *arg)
{
    pthread_mutex_lock(&lock);
    // 공유 데이터 수정
    pthread_mutex_unlock(&lock);
    // 공유 데이터에 접근하지 않는 작업
    return NULL;
}

 

위의 C 코드에서, 공유 데이터에 대한 접근이 끝나면 락을 즉시 해제합니다. 이렇게 해야만 다른 스레드가 락을 얻어 공유 데이터에 접근할 수 있기 때문입니다. 

 

또 다른 중요한 원칙은 '락 경쟁을 최소화한다'입니다. 락 경쟁은 여러 스레드가 동시에 같은 락을 획득하려고 할 때 발생합니다. 락 경쟁이 심하면 스레드들이 락을 기다리느라 시간을 낭비하게 되므로, 가능하면 각 스레드가 다른 락을 사용하도록 설계하는 것이 좋습니다. 

 

하지만, 이런 원칙들을 지키는 것이 쉽지 않을 수 있습니다. 예를 들어, 복잡한 프로그램에서는 여러 스레드가 여러 자원을 동시에 접근해야 할 수도 있습니다. 이런 경우, 락을 잘 설계하고 사용하는 것이 중요합니다. 

 

다음은 C++에서 `std::lock_guard`와 `std::unique_lock`을 사용하여 락의 범위를 최소화하는 예입니다:  

 

[예제]

#include <mutex>
#include <iostream>  

std::mutex mutex;  

void do_something()
{
    std::unique_lock<std::mutex> lock(mutex);
    // 공유 데이터 수정
    lock.unlock();
    // 공유 데이터에 접근하지 않는 작업
    lock.lock();
    // 공유 데이터 수정
}

 

위 코드에서 `std::unique_lock`은 락을 소유하는 객체로, 생성자에서 락을 잠그고, `unlock()` 함수를 호출하면 락을 해제합니다. 이후 `lock()` 함수를 호출하면 다시 락을 잠글 수 있습니다. 이런 방식으로 락의 범위를 정확하게 조절할 수 있습니다.  

std::lock_guard는 더 단순한 경우에 사용할 수 있는 락을 소유하는 객체입니다. std::lock_guard 객체가 생성되면서 자동으로 락이 잠기고, 객체가 파괴될 때 락이 해제됩니다. 이렇게 해서 락의 생명주기를 객체의 생명주기와 일치시킵니다. 

 

[예제]

#include <mutex>
#include <iostream>  

std::mutex mutex;  

void do_something()
{
    std::lock_guard<std::mutex> lock(mutex);
    // 공유 데이터 수정
    // ...
}

 

이렇게 하면 함수가 반환하거나 예외가 발생해도 std::lock_guard 객체가 자동으로 파괴되면서 락이 해제됩니다. 이것을 RAII(Resource Acquisition Is Initialization)이라고 부르는 프로그래밍 기법의 한 예입니다. 

 

하지만 모든 경우에 락을 사용할 수 있는 것은 아닙니다. 락은 잘못 사용하면 데드락(deadlock)이라는 현상을 일으킬 수 있습니다. 데드락은 두 개 이상의 스레드가 서로 다른 스레드가 소유한 락을 기다리면서 무한히 대기하는 상황을 말합니다. 이런 상황을 방지하기 위해서는 락을 획득하는 순서 등을 잘 조절해야 합니다. 

 

락의 사용은 강력한 도구지만, 이를 잘못 사용하면 성능 문제나 데드락 등의 문제를 일으킬 수 있습니다. 따라서 락을 사용할 때에는 주의가 필요합니다. 이번 섹션에서는 락의 기본적인 사용법과 주의점을 알아보았습니다. 다음 섹션에서는 락을 더 효과적으로 사용하기 위한 다양한 기법에 대해 알아보겠습니다. 


13.4. 조건 변수와 스레드 풀  

조건 변수와 스레드 풀은 스레드 프로그래밍에서 중요한 개념입니다. 조건 변수는 특정 조건이 충족될 때까지 스레드를 대기 상태로 만드는 도구입니다. 뮤텍스와 함께 사용되며, 보통 공유 데이터에 대한 접근을 동기화하는 데 사용됩니다. 스레드 풀은 미리 생성된 스레드의 집합으로, 동시에 수행할 작업을 스레드에 할당합니다. 작업을 빠르게 수행하려면 스레드를 미리 만들어 두는 것이 좋습니다. 이런 방식은 스레드 생성과 삭제에 따른 오버헤드를 줄이고, 자원을 효율적으로 사용하는 데 도움이 됩니다.  

13.4.1. 조건 변수란 무엇인가  

조건 변수란 무엇일까요? 특정 조건이 만족되면 스레드를 깨우는 방법으로, 여러 스레드 간의 동기화를 위해 사용됩니다. 조건 변수는 뮤텍스와 함께 사용하여 공유 데이터에 대한 접근을 동기화합니다. 

 

조건 변수의 사용은 세 가지 단계로 이루어집니다.  

  • 뮤텍스를 획득합니다.
  • 특정 조건을 검사합니다.
  • 조건이 충족되지 않으면, 뮤텍스를 해제하고 스레드를 잠시 멈춥니다.

아래의 코드 예제를 통해 이해해 봅시다.  

 

[예제]

#include <pthread.h>
#include <stdio.h>  

pthread_mutex_t count_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t condition_var = PTHREAD_COND_INITIALIZER;  

void *functionCount1();
void *functionCount2();
int count = 0;
#define COUNT_DONE 10  

int main() {
    pthread_t thread1, thread2;  

    pthread_create(&thread1, NULL, &functionCount1, NULL);
    pthread_create(&thread2, NULL, &functionCount2, NULL);  

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);  

    printf("Final count: %d\n", count);  

    return 0;
}  

// 상호 배제로 count 변수를 증가시키고 조건 변수를 통해 스레드를 깨웁니다.
void *functionCount1() {
    for(;;) {
        pthread_mutex_lock(&count_mutex);
        count++;
        printf("Counter value functionCount1: %d\n", count);
        pthread_cond_signal(&condition_var);
        pthread_mutex_unlock(&count_mutex);  

        if(count >= COUNT_DONE) return(NULL);
    }
}  

// 조건 변수를 통해 스레드를 잠시 멈추고, 조건이 충족되면 스레드를 다시 깨웁니다.
void *functionCount2() {
    for(;;) {
        pthread_mutex_lock(&count_mutex);  

        while(count < COUNT_DONE) {
            pthread_cond_wait(&condition_var, &count_mutex);
        }  

        pthread_mutex_unlock(&count_mutex);
        printf("Counter value functionCount2: %d\n", count);
        if(count >= COUNT_DONE) return(NULL);
    }
}

 

이 예제에서, functionCount1은 count를 증가시키고, functionCount2는 count가 COUNT_DONE (10)이 될 때까지 기다립니다. pthread_cond_signal()은 functionCount2가 대기하고 있는 조건 변수를 신호로 깨웁니다. 이렇게 함으로써 두 스레드는 count라는 공유 자원에 대해 상호 배제적으로 접근하며, 동시에 조건 변수를 통해 동기화를 달성합니다. 

 

13.4.2. 조건 변수를 이용한 스레드 관리  

조건 변수를 사용하는 또 다른 흥미로운 케이스는 스레드 관리입니다. 스레드가 특정 상태에 도달하면 다른 스레드에게 알릴 수 있어야 합니다. 예를 들어, 작업자 스레드가 작업을 완료하면 이를 관리자 스레드에게 알려야 할 수 있습니다. 이를 위해 조건 변수를 사용하면 관리자 스레드는 특정 조건이 만족될 때까지 대기하고, 작업자 스레드는 작업 완료 후 조건 변수를 신호로 전달하여 관리자 스레드를 깨울 수 있습니다. 

 

아래는 이러한 상황을 잘 나타내는 예제입니다. 

 

[예제]

#include <iostream>
#include <thread>
#include <condition_variable>  

std::condition_variable cv;
std::mutex cv_m;
int i = 0;
bool done = false;  

void worker_thread()
{
    // Simulate work.
    std::this_thread::sleep_for(std::chrono::seconds(1));  

    // Notify that we're done.
    std::unique_lock<std::mutex> lk(cv_m);
    i = 1;
    done = true;
    lk.unlock();
    cv.notify_one();
}  

int main()
{
    std::thread t(worker_thread);  

    // Wait for the worker.
    std::unique_lock<std::mutex> lk(cv_m);
    cv.wait(lk, []{return done;});  

    std::cout << "Worker thread is done, value: " << i << std::endl;  

    t.join();  

    return 0;
}

 

위 예제에서 작업자 스레드는 1초 동안 "작업"을 수행하고, 그 후에 done을 true로 설정하고, 조건 변수를 통해 main 스레드에게 알립니다. cv.wait()는 람다 함수를 통해 done이 true가 될 때까지 대기하며, 이렇게 스레드 간의 동기화를 달성합니다. 이는 스레드 관리에서 매우 유용한 패턴입니다. 이러한 방식은 스레드 풀 관리, 작업 큐 관리 등 다양한 상황에서 활용될 수 있습니다. 

 

13.4.3. 스레드 풀과 효율적인 스레드 관리  

스레드 관리는 멀티스레딩 프로그래밍의 중요한 요소입니다. 잘 관리되지 않은 스레드는 성능 저하, 자원 낭비, 데드락 등의 문제를 유발할 수 있습니다. 이를 해결하는 한 가지 방법이 바로 '스레드 풀'입니다. 

 

스레드 풀은 일반적으로 동시에 실행되는 스레드의 수를 제한하여 시스템 자원을 효과적으로 사용하도록 설계된 것입니다. 스레드 풀을 사용하면 스레드 생성과 제거에 대한 오버헤드를 줄일 수 있으며, 동시에 실행되는 스레드 수를 제한함으로써 시스템의 부하를 관리할 수 있습니다. 

 

스레드 풀의 핵심 아이디어는 미리 정의된 수의 스레드를 생성하고 이를 재사용하는 것입니다. 이를 통해 스레드를 필요에 따라 빠르게 할당하고 회수할 수 있습니다. 스레드 풀을 사용하면 각 작업에 대해 새 스레드를 생성하고 제거하는 비용을 피할 수 있습니다. 이는 특히 많은 수의 짧은 작업이 있는 경우에 유용합니다. 

 

다음은 스레드 풀의 기본적인 작동 방식을 보여주는 C++ 코드입니다. 

 

[예제]

#include <iostream>
#include <thread>
#include <vector>
#include <queue>
#include <condition_variable>  

std::queue<int> jobs;
std::mutex m;
std::condition_variable cv;
bool shutdown = false;  

void worker_thread(int id)
{
    while (true)
    {
        std::unique_lock<std::mutex> lock(m);
        cv.wait(lock, []{ return !jobs.empty() || shutdown; });
        if (shutdown && jobs.empty()) return;
        int job = jobs.front();
        jobs.pop();
        lock.unlock();
        std::cout << "Thread " << id << " processed job " << job << std::endl;
    }
}  

int main()
{
    std::vector<std::thread> workers;
    for (int i = 0; i < 4; ++i)
        workers.push_back(std::thread(worker_thread, i));
    
    for (int i = 0; i < 100; ++i)
    {
        std::unique_lock<std::mutex> lock(m);
        jobs.push(i);
        cv.notify_one();
    }
    
    for (auto &t : workers)
        t.join();
    
    return 0;
}

 

위 코드는 4개의 작업자 스레드를 생성하고 100개의 작업을 스레드에 할당하는 간단한 스레드 풀을 구현한 것입니다. cv.wait()는 스레드가 작업을 할당받을 때까지 대기하도록 합니다. cv.notify_one()은 스레드에 작업이 할당되었음을 알리고, cv.notify_all()은 모든 스레드에 작업을 종료하라는 신호를 보냅니다. 이런 방식으로 스레드 풀은 효과적으로 스레드를 관리하고 자원을 절약할 수 있습니다. 

 

13.4.4. 스레드 풀의 성능 튜닝  

스레드 풀을 이용한 멀티스레딩 프로그래밍은 프로그램의 성능을 크게 향상할 수 있습니다. 그러나, 그냥 스레드 풀을 사용하는 것만으로는 충분하지 않을 수 있습니다. 효율적인 멀티스레딩을 위해서는 스레드 풀의 성능 튜닝이 필요합니다. 

 

스레드 풀의 성능을 튜닝하는 가장 중요한 요소 중 하나는 풀에 있는 스레드의 수입니다. 스레드의 수가 너무 많으면 컨텍스트 스위칭(context switching)으로 인한 오버헤드가 증가하며, 스레드의 수가 너무 적으면 CPU를 충분히 활용하지 못할 수 있습니다. 따라서, 스레드 풀의 크기를 적절히 설정하는 것이 중요합니다. 

 

스레드 풀의 크기는 다양한 요인에 따라 달라질 수 있습니다. 예를 들어, CPU의 코어 수, 작업의 복잡성, I/O 대기 시간 등을 고려해야 합니다. 일반적으로, CPU-bound 작업의 경우, 스레드 풀의 크기를 코어 수와 같거나 약간 더 크게 설정하는 것이 좋습니다. 반면, I/O-bound 작업의 경우, 스레드 풀의 크기를 더 크게 설정하여 I/O 대기 시간 동안 다른 스레드가 CPU를 사용할 수 있도록 하는 것이 좋습니다. 

 

다음은 스레드 풀 크기를 설정하는 간단한 예입니다.  

 

[예제]

#include <thread>  

unsigned int n = std::thread::hardware_concurrency();
std::cout << n << " concurrent threads are supported.\n";

 

위의 C++ 코드는 시스템에서 지원하는 동시 스레드의 최대 수를 확인하고 출력합니다. 이 값은 플랫폼과 하드웨어에 따라 다르며, 이 값을 기반으로 스레드 풀의 크기를 결정할 수 있습니다. 

 

그러나 이것은 단순히 시작점일 뿐, 최적의 성능을 얻기 위해서는 프로그램의 동작을 반복적으로 측정하고 스레드 풀의 크기를 조정해야 합니다. 이 과정은 프로파일링(profiling)이라고 하며, 이를 통해 스레드 풀의 성능을 튜닝할 수 있습니다. 이것은 복잡하고 시간이 많이 걸리는 작업일 수 있지만, 잘 수행된다면 프로그램의 성능을 크게 향상할 수 있습니다. 

 

13.4.5. 스레드 풀의 최적화  

스레드 풀의 성능을 최적화하기 위해서는 여러 가지 요소들을 고려해야 합니다. 스레드 풀의 크기 설정은 당연히 중요한 부분이지만, 더 많은 요소들이 함께 고려되어야 최적의 성능을 얻을 수 있습니다. 

 

스레드 풀의 작업 큐 관리는 최적화에서 중요한 부분입니다. 스레드 풀에서는 작업들이 큐에 쌓이고, 스레드들은 이 큐에서 작업을 가져와 처리합니다. 큐의 관리 방식에 따라 성능이 크게 달라질 수 있습니다. 예를 들어, 큐가 너무 길어지면 스레드들이 작업을 가져오는데 오래 걸릴 수 있습니다. 반면, 큐가 너무 짧으면 스레드들이 작업을 찾지 못하고 대기하는 시간이 늘어날 수 있습니다. 따라서, 적절한 큐 관리 전략이 필요합니다. 

 

또한, 작업의 종류와 특성에 따라 최적의 스레드 풀 구성이 달라질 수 있습니다. CPU-bound 작업과 I/O-bound 작업의 특성은 크게 다르므로, 이에 따른 다른 전략이 필요합니다. CPU-bound 작업은 대체로 CPU 사용률이 높고 I/O 대기 시간이 짧기 때문에, 스레드 수를 CPU 코어 수에 맞추는 것이 일반적입니다. 반면에 I/O-bound 작업은 I/O 대기 시간이 길기 때문에, 대기 시간 동안 다른 작업을 처리할 수 있도록 스레드 수를 늘리는 것이 효율적일 수 있습니다. 

 

다음은 간단한 스레드 풀 구현의 일부입니다.  

 

[예제]

#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>  

class ThreadPool {
private:
    std::queue<std::function<void()>> tasks;
    std::vector<std::thread> workers;
    std::mutex queue_mutex;
    std::condition_variable condition;
    bool stop;  

public:
    ThreadPool(size_t threads) : stop(false) {
        for(size_t i = 0; i < threads; ++i)
            workers.emplace_back([this] {
                for(;;) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(this->queue_mutex);
                        this->condition.wait(lock,
                            [this]{ return this->stop || !this->tasks.empty(); });
                        if(this->stop && this->tasks.empty())
                            return;
                        task = std::move(this->tasks.front());
                        this->tasks.pop();
                    }
                    task();
                }
            });
    }  

    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
            stop = true;
        }
        condition.notify_all();
        for(std::thread &worker: workers)
            worker.join();
    }  

    template<class F>
    void enqueue(F&& f) {
        if(!stop) {
            std::unique_lock<std::mutex> lock(queue_mutex);
            tasks.emplace(std::forward<F>(f));
        }
        condition.notify_one();
    }
};

 

이 예제 코드는 C++로 작성된 간단한 스레드 풀입니다. 이 스레드 풀에서는 std::thread를 이용하여 여러 개의 작업 스레드를 생성하고, std::queue를 이용하여 작업을 관리합니다. 또한, std::mutex와 std::condition_variable을 이용하여 작업 큐에 대한 동기화를 수행합니다. 작업을 추가할 때는 enqueue 함수를 이용하며, 스레드 풀을 소멸시킬 때는 모든 스레드가 완료될 때까지 대기한 후에 스레드를 종료합니다. 이러한 방식으로 스레드 풀의 동작을 제어하며, 이를 통해 효율적인 스레드 관리가 가능합니다. 

 

스레드 풀의 최적화는 간단하지 않은 작업이지만, 잘 수행된다면 효율적인 멀티스레딩을 통해 프로그램의 성능을 크게 향상시킬 수 있습니다. 이를 위해 필요한 다양한 전략과 기법을 알아보았습니다. 이들을 이해하고 적절히 활용함으로써, 복잡한 멀티스레딩 환경에서도 효율적인 프로그램을 작성할 수 있을 것입니다. 


13.5. C++11에서의 스레드  

C++11에서는 스레드를 포함한 동시성 프로그래밍을 위한 풍부한 기능을 제공합니다. <thread> 헤더에는 std::thread 클래스가 포함되어 있으며, 이를 이용해 쉽게 스레드를 생성하고 관리할 수 있습니다. 스레드 생성은 std::thread 객체를 만들면서 함수 이름과 인자를 넘기는 것으로 가능하며, 스레드 종료는 std::thread::join이나 std::thread::detach를 이용합니다. 또한 <mutex>, <condition_variable>, <future>, <atomic> 등의 헤더에는 동기화를 위한 다양한 도구들이 있어, 락 기반 동기화부터 비락 기반 동기화까지 다양한 방식의 동시성 제어가 가능합니다. 

13.5.1. C++11 스레드 라이브러리 소개  

C++11은 이전 C++ 버전에서 복잡하고 어려웠던 동시성 제어를 좀 더 쉽고 안정적으로 할 수 있는 기능을 제공합니다. 이를 가능하게 한 것은 바로 C++11의 스레드 라이브러리인데요, 이 라이브러리는 스레드 생성, 동기화, 조건 변수, future, promise 등 다양한 기능을 제공합니다. 

 

먼저, std::thread 클래스를 이용해 스레드를 생성할 수 있습니다. std::thread 클래스는 생성자에 스레드로 실행할 함수를 인자로 넘기는 방식으로 스레드를 생성합니다. 예를 들어, 아래와 같은 코드로 새로운 스레드를 생성할 수 있습니다. 

 

[예제]

#include <thread>  

void my_func() {
    // 스레드로 실행할 코드
}  

int main() {
    std::thread t(my_func); // 스레드 생성
    t.join(); // 스레드 종료 대기
}

여기서 std::thread::join 함수는 스레드가 종료될 때까지 기다리는 역할을 합니다. 이외에도 스레드를 백그라운드로 보내려면 std::thread::detach 함수를 사용할 수 있습니다. 

 

다음으로 동기화에 대해 알아봅시다. 동기화를 위해 C++11에서는 <mutex> 헤더에 정의된 std::mutex 클래스를 제공합니다. std::mutex는 스레드 간에 공유 데이터에 대한 동시 접근을 제어하는 락 기능을 제공합니다. 아래와 같이 사용할 수 있습니다. 

 

[예제]

#include <thread>
#include <mutex>  

std::mutex mtx; // 뮤텍스 생성  

void my_func() {
    std::lock_guard<std::mutex> lock(mtx); // 락을 걸고, 함수 종료 시 자동으로 락 해제
    // 공유 데이터 사용
}  

int main() {
    std::thread t1(my_func);
    std::thread t2(my_func);
    t1.join();
    t2.join();
}

 

이렇게 std::mutex와 std::lock_guard를 이용하면 공유 데이터에 대한 동시 접근을 제어하면서 데드락을 방지할 수 있습니다. 이 외에도 std::unique_lock, std::recursive_mutex 등 다양한 동기화 도구를 제공합니다. 

 

이어서, 조건 변수에 대해 알아봅시다. C++11에서는 std::condition_variable 클래스를 통해 조건 변수를 지원합니다. 이 클래스는 스레드가 특정 조건을 만족할 때까지 대기하도록 하는 기능을 제공합니다. std::unique_lock과 함께 사용하여 특정 조건이 만족될 때까지 스레드를 대기 상태로 만들 수 있습니다. 

 

[예제]

#include <thread>
#include <mutex>
#include <condition_variable>  

std::mutex mtx;
std::condition_variable cv;
bool ready = false;  

void print_id(int id) {
    std::unique_lock<std::mutex> lck(mtx);
    while (!ready) cv.wait(lck); // ready가 true가 될 때까지 대기
    // print thread id
    std::cout << "thread " << id << '\n';
}  

void go() {
    std::unique_lock<std::mutex> lck(mtx);
    ready = true; // 조건 변경
    cv.notify_all(); // 대기 중인 모든 스레드에게 알림
}  

int main() {
    std::thread threads[10];
    for (int i=0; i<10; ++i)
        threads[i] = std::thread(print_id, i);  

    std::cout << "10 threads ready to race...\n";
    go(); // go!  

    for (auto& th : threads) th.join();  

    return 0;
}

 

마지막으로, future와 promise에 대해서 설명하겠습니다. C++11에서는 std::future와 std::promise를 제공하여 스레드간에 값의 전달 및 상태 정보 공유를 가능하게 합니다. std::promise 객체는 어떤 값을 저장하고, std::future 객체는 std::promise 객체를 통해 값을 받아옵니다. 이를 통해 스레드 간에 결과 값을 안전하게 전달할 수 있습니다. 

 

[예제]

#include <future>
#include <thread>
#include <iostream>  

void do_the_work(std::promise<int> &p) {
    p.set_value(42); // promise에 값을 설정합니다.
}  

int main() {
    std::promise<int> p; // promise 객체 생성
    std::future<int> f = p.get_future(); // future 객체를 얻습니다.  

    std::thread t(do_the_work, std::ref(p)); // 스레드에 promise 객체를 전달합니다.
    t.join();  

    std::cout << "The answer is " << f.get() << '\n'; // future 객체로 값을 얻어옵니다.  

    return 0;
}

 

이처럼 C++11의 스레드 라이브러리는 다양하고 강력한 동시성 제어 기능을 제공하며, 이를 이용하면 복잡한 동시성 제어 문제를 보다 쉽게 해결할 수 있습니다. 다음 섹션에서는 이러한 도구들을 어떻게 사용하는지, 그리고 어떻게 효과적으로 사용하는지에 대해 더 자세히 알아보겠습니다. 

 

13.5.2. C++11에서의 스레드 생성과 관리  

C++11에서는 std::thread를 통해 스레드 생성 및 관리를 매우 간편하게 수행할 수 있습니다. std::thread는 생성자에 함수와 그 함수에 전달될 인자를 제공하면, 이 함수를 별도의 스레드에서 실행시키는 객체입니다. 스레드 생성은 다음과 같이 매우 간단합니다. 

 

[예제]

#include <iostream>
#include <thread>  

void foo() {
    std::cout << "Hello from foo\n";
}  

int main() {
    std::thread t1(foo); // foo 함수를 별도의 스레드에서 실행
    t1.join(); // t1 스레드가 종료될 때까지 대기
    return 0;
}

 

이 코드는 foo 함수를 별도의 스레드에서 실행시킵니다. std::thread::join() 함수는 해당 스레드가 종료될 때까지 현재 스레드를 대기 상태로 만듭니다. 

 

함수에 인자를 전달하려면 std::thread의 생성자에 인자를 추가적으로 제공하면 됩니다.

 

[예제]

#include <iostream>
#include <thread>  

void foo(int x) {
    std::cout << "Hello from foo, x = " << x << '\n';
}  

int main() {
    std::thread t1(foo, 10); // foo 함수를 인자 10과 함께 별도의 스레드에서 실행
    t1.join(); // t1 스레드가 종료될 때까지 대기
    return 0;
}

 

또한 C++11은 스레드 간의 동기화를 위해 다양한 도구를 제공합니다. std::mutex와 std::unique_lock은 공유 자원에 대한 동기화를 수행하는 데 사용됩니다. std::mutex는 상호 배제를 제공하여 한 시점에 하나의 스레드만이 특정 코드를 실행하도록 보장합니다. std::unique_lock은 std::mutex를 보다 편리하게 사용하기 위한 RAII-style 래퍼입니다. 

 

다음은 두 스레드가 공유 변수에 접근하는 경우를 예로 든 코드입니다.  

 

[예제]

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

std::mutex mtx; // 공유 자원에 대한 상호 배제를 위한 mutex
int shared_var = 0;  

void increment(int num) {
    for (int i = 0; i < num; ++i) {
        std::unique_lock<std::mutex> lock(mtx);
        ++shared_var;
        lock.unlock(); // unlock 명시적 호출 가능하나, unique_lock은 자동으로 unlock됨
    }
}  

int main() {
    std::thread t1(increment, 50000);
    std::thread t2(increment, 50000);  

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

    std::cout << "Shared variable = " << shared_var << '\n'; // 100000 출력
    return 0;
}

 

이 예제에서는 두 스레드가 동일한 shared_var에 접근하며 값을 증가시킵니다. std::mutex와 std::unique_lock을 사용하여 각 스레드가 순차적으로 shared_var에 접근하도록 동기화하였습니다. 

 

13.5.3. C++11에서의 스레드 동기화  

C++11에서는 std::mutex, std::lock_guard, std::unique_lock, std::condition_variable 등의 도구들을 제공하여 스레드 간의 동기화를 쉽게 수행할 수 있습니다. 이러한 동기화 도구들을 적절히 활용함으로써, 다수의 스레드가 공유 자원에 안전하게 접근하도록 할 수 있습니다. 

 

먼저 std::mutex에 대해 알아보겠습니다. std::mutex는 상호 배제(Mutual Exclusion)의 약자로, 한 번에 하나의 스레드만이 특정 코드를 실행하도록 하는 메커니즘입니다. 아래는 std::mutex를 사용하는 예시입니다. 

 

[예제]

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

std::mutex mtx;
int i = 0;  

void print_block(int n, char c) {
    mtx.lock();
    for (int j=0; j<n; ++j) { std::cout << c; }
    std::cout << '\n';
    mtx.unlock();
}  

int main() {
    std::thread th1(print_block,50,'*');
    std::thread th2(print_block,50,'$');  

    th1.join();
    th2.join();  

    return 0;
}

 

이 예제에서 두 스레드는 동일한 출력 스트림에 쓰려고 시도하며, std::mutex를 사용하여 이 작업을 동기화합니다.  

 

다음으로 std::lock_guard에 대해 살펴봅시다. std::lock_guard는 RAII(Resource Acquisition Is Initialization) 관리 방식을 사용하여 std::mutex를 락하는 도구입니다. std::lock_guard 객체가 생성되면서 자동으로 락이 걸리고, 객체가 소멸될 때 락이 자동으로 해제됩니다. 이로 인해 개발자가 직접 락을 관리하는 것에 대한 부담을 줄일 수 있습니다. 

 

[예제]

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

std::mutex mtx;
int i = 0;  

void print_block(int n, char c) {
    std::lock_guard<std::mutex> guard(mtx);
    for (int j=0; j<n; ++j) { std::cout << c; }
    std::cout << '\n';
}  

int main() {
    std::thread th1(print_block,50,'*');
    std::thread th2(print_block,50,'$');  

    th1.join();
    th2.join();  

    return 0;
}

 

마지막으로, std::condition_variable은 특정 조건이 충족될 때까지 스레드를 대기 상태로 만들 수 있게 합니다. std::condition_variable은 std::unique_lock과 함께 사용되며, wait(), notify_one(), notify_all() 등의 메서드를 제공합니다  

 

아래는 std::condition_variable을 사용하는 예시입니다.  

 

[예제]

#include <iostream>
#include <thread>
#include <condition_variable>  

std::condition_variable cv;
std::mutex cv_m;
int i = 0;
bool done = false;  

void worker_thread() {
    std::unique_lock<std::mutex> lk(cv_m);
    cv.wait(lk, []{return i == 1;});
    std::cout << "Worker thread is processing data\n";
    data += " after processing";
    done = true;
    lk.unlock();
    cv.notify_one();
}  

int main() {
    std::thread worker(worker_thread);  

    data = "Example data";
    i = 1;
    cv.notify_one();  

    std::unique_lock<std::mutex> lk(cv_m);
    cv.wait(lk, []{return done == true;});
    std::cout << "Back in main(), data = " << data << '\n';  

    worker.join();
    return 0;
}

 

이 예제에서 worker_thread는 cv.wait()을 통해 i가 1이 될 때까지 대기합니다. main() 함수에서 i를 1로 변경하고 cv.notify_one()을 통해 worker_thread를 깨우면, worker_thread는 데이터를 처리한 후 다시 main() 함수에 알립니다. main() 함수는 이후 데이터 처리가 완료될 때까지 대기합니다. 

 

13.5.4. C++11에서의 락과 컨디션 변수  

C++11에서 제공하는 락과 컨디션 변수는 다중 스레드 프로그래밍에서 핵심적인 요소입니다. 이들은 여러 스레드가 동일한 자원에 접근하거나, 특정 조건이 충족될 때까지 대기하는 등의 동기화 작업을 수행하는 데 사용됩니다. 

 

락에는 std::mutex, std::lock_guard, std::unique_lock 등의 여러 형태가 있습니다. std::mutex는 스레드 간의 동기화를 위해 사용되는 기본적인 락입니다. std::lock_guard와 std::unique_lock는 std::mutex를 보다 편리하게 사용하기 위해 제공되는 락입니다. std::lock_guard는 생성될 때 자동으로 락을 잠그고 소멸될 때 락을 해제하는 RAII 방식의 락입니다. std::unique_lock은 락의 잠금 상태를 보다 세밀하게 제어할 수 있게 해주는 락입니다. 

 

컨디션 변수는 std::condition_variable이라는 형태로 제공되며, 특정 조건이 충족될 때까지 스레드를 대기 상태로 만들거나 다른 스레드에게 신호를 보내는 역할을 합니다. std::condition_variable은 주로 std::unique_lock과 함께 사용되며, wait(), notify_one(), notify_all() 등의 함수를 제공합니다. 

 

이제 각각의 사용법을 예제 코드를 통해 살펴보겠습니다. 

 

[예제]

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

std::mutex mtx;
std::condition_variable cv;
bool ready = false;  

void print_id(int id) {
    std::unique_lock<std::mutex> lck(mtx);
    while (!ready) cv.wait(lck);
    std::cout << "thread " << id << '\n';
}  

void go() {
    std::unique_lock<std::mutex> lck(mtx);
    ready = true;
    cv.notify_all();
}  

int main() {
    std::thread threads[10];
    for (int i=0; i<10; ++i)
        threads[i] = std::thread(print_id,i);  

    std::cout << "10 threads ready to race...\n";
    go();  

    for (auto& th : threads) th.join();  

    return 0;
}

 

이 예제에서는 10개의 스레드가 생성되며, 각 스레드는 cv.wait(lck)를 통해 ready가 true가 될 때까지 대기합니다. go() 함수에서 ready를 true로 변경하고 cv.notify_all()를 호출하여 모든 대기 중인 스레드를 깨우면, 각 스레드는 자신의 ID를 출력하고 종료합니다. 이처럼 C++11의 락과 컨디션 변수를 이용하면 스레드 간의 복잡한 동기화 작업을 간편하게 처리할 수 있습니다.  

 

13.5.5. C++11에서의 조건 변수와 스레드 풀  

C++11에서의 스레드 풀과 조건 변수는 다중 스레드 환경에서 효율적인 리소스 관리와 동기화를 위해 사용됩니다. 스레드 풀은 여러 스레드를 사전에 생성하고 관리함으로써 스레드 생성과 파괴에 드는 비용을 줄이고, 특정 작업을 빠르게 처리할 수 있는 방법입니다. 조건 변수는 스레드가 특정 조건을 만족할 때까지 대기하거나, 다른 스레드에게 알림을 보내는 기능을 제공합니다. 

 

스레드 풀을 이용하면 서버 프로그램 등에서 동시에 여러 클라이언트의 요청을 처리하거나, 복잡한 연산을 여러 스레드에 분산시키는 등의 작업을 수행할 수 있습니다. 스레드 풀 내의 스레드는 작업 큐에서 작업을 가져와 처리하며, 작업이 완료되면 다음 작업을 대기합니다. 

 

조건 변수는 std::condition_variable이라는 클래스로 제공되며, 주로 std::mutex와 함께 사용됩니다. std::condition_variable은 wait(), notify_one(), notify_all() 등의 함수를 제공하며, 스레드의 동기화에 필요한 조건을 체크하고 스레드를 대기 상태로 만들거나 깨울 수 있습니다. 

 

이제 이를 활용한 간단한 스레드 풀 예제를 살펴보겠습니다. 

 

[예제]

#include <iostream>
#include <thread>
#include <queue>
#include <condition_variable>  

std::queue<int> tasks;
std::mutex mtx;
std::condition_variable cv;
bool finished = false;  

void worker(int id) {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, []{ return !tasks.empty() || finished; });  

        if (finished && tasks.empty()) {
            return;
        }  

        int task = tasks.front();
        tasks.pop();
        lock.unlock();  

        std::cout << "Worker " << id << " is processing task " << task << "\n";
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}  

int main() {
    std::thread workers[10];
    for (int i=0; i<10; ++i) {
        workers[i] = std::thread(worker, i);
    }  

    for (int i=0; i<50; ++i) {
        std::unique_lock<std::mutex> lock(mtx);
        tasks.push(i);
        cv.notify_one();
    }  

    {
        std::unique_lock<std::mutex> lock(mtx);
        finished = true;
        cv.notify_all();
    }  

    for (auto& worker : workers) {
        worker.join();
    }  

    return 0;
}

 

이 예제에서는 10개의 워커 스레드가 각각 작업을 처리하는 스레드 풀을 만들었습니다. tasks라는 큐에 작업이 들어오면 워커 스레드가 작업을 가져와 처리하며, 모든 작업이 끝나면 스레드는 종료됩니다. std::condition_variable과 std::mutex를 사용하여 작업 큐에 대한 동기화를 수행하였습니다. 


13.6. 스레드 로컬 저장소  

스레드 로컬 저장소(Thread Local Storage, TLS)는 각 스레드가 독립적으로 가지는 데이터 영역입니다. 각 스레드는 이 영역에 자신만의 데이터를 저장하고 접근할 수 있습니다. C++11에서는 thread_local 키워드를 통해 변수를 스레드 로컬로 선언할 수 있습니다. 이렇게 선언된 변수는 각 스레드마다 독립적인 값을 가지며, 스레드 생명주기 동안 유지됩니다. 이는 동시성 문제를 해결하는 데 도움이 될 수 있습니다.  

13.6.1. 스레드 로컬 저장소의 개념과 필요성  

'스레드 로컬 저장소(Thread Local Storage, TLS)'의 개념을 이해하기 위해선, 먼저 '스레드'와 '프로세스'의 관계에 대해 알 필요가 있습니다. 프로세스는 실행 중인 프로그램이고, 스레드는 프로세스 내에서 독립적으로 실행되는 작업 단위입니다. 프로세스는 각 스레드에 대해 독립적인 스택 영역을 제공하는데, 이는 각 스레드가 자신만의 함수 호출 스택을 가질 수 있게 합니다. 

 

그러나, 이렇게 독립적인 스택 영역을 제공하는 것 외에도, 스레드마다 독립적으로 가지는 데이터가 필요한 경우가 있습니다. 이런 데이터를 저장하는 영역이 바로 '스레드 로컬 저장소'입니다. 

 

[예제]

// C++ 예제
thread_local int thread_specific_data;


위와 같이 thread_local 키워드를 사용해 변수를 선언하면, 이 변수는 각 스레드마다 개별적인 값을 가지게 됩니다. 이 변수에 대한 변경은 해당 스레드 내에서만 유효하며, 다른 스레드에는 영향을 미치지 않습니다. 

 

스레드 로컬 저장소는 다음과 같은 상황에서 필요합니다.  

  • 각 스레드가 독립적인 상태를 유지해야 하는 경우
  • 스레드 간 데이터 충돌을 피하려는 경우
  • 전역 변수를 안전하게 사용하려는 경우

예를 들어, 랜덤 넘버 생성기의 시드 값을 스레드마다 다르게 설정하려면, 스레드 로컬 저장소를 사용해야 합니다. 이렇게 하면 각 스레드는 독립적인 랜덤 시퀀스를 생성할 수 있습니다. 

 

[예제]

// C++ 예제
#include <random>
#include <thread>  

thread_local std::mt19937 rng(std::random_device{}());  

void random_task() {
    std::uniform_int_distribution<> dist(0, 100);
    std::cout << dist(rng) << std::endl;
}  

int main() {
    std::thread t1(random_task);
    std::thread t2(random_task);  

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

    return 0;
}

 

이 예제에서 rng는 스레드 로컬 변수로, 각 스레드는 독립적인 랜덤 넘버 생성기를 가집니다. 따라서 t1과 t2 스레드는 각자 다른 랜덤 시퀀스를 생성합니다. 

 

스레드 로컬 저장소는 이처럼 독립적인 스레드 상태를 관리하고, 스레드 간 데이터 충돌을 방지하는 데 사용됩니다. 동시성 프로그래밍에서 매우 중요한 개념이므로 잘 이해하고 활용하는 것이 좋습니다. 

 

13.6.2. C++에서의 스레드 로컬 저장소 사용하기  

C++에서는 thread_local 키워드를 사용하여 스레드 로컬 저장소를 쉽게 사용할 수 있습니다. thread_local로 선언된 변수는 해당 스레드의 생명 주기 동안만 존재하고, 각 스레드에 대해 독립적인 복사본을 가지게 됩니다. 이러한 특성은 다수의 스레드에서 동시에 실행되는 코드에서 유용하게 사용할 수 있습니다. 

 

예를 들어, 각 스레드에서 실행되는 함수가 전역 변수를 변경하는 코드를 생각해 보겠습니다. 이런 상황에서 여러 스레드에서 동시에 실행될 때 동기화 문제가 발생할 수 있습니다. 하지만 thread_local을 사용하면, 이 문제를 간단하게 해결할 수 있습니다. 

 

아래는 C++에서 thread_local을 사용하는 간단한 예입니다.  

 

[예제]

#include <iostream>
#include <thread>  

// thread_local keyword를 사용하여 스레드 로컬 변수를 선언
thread_local int x = 0;  

void print_x_and_increment() {
    std::cout << "Thread ID: " << std::this_thread::get_id() << ", x = " << x << '\n';
    ++x;
}  

void thread_func() {
    print_x_and_increment();
    print_x_and_increment();
}  

int main() {
    // 두 개의 독립적인 스레드를 생성하고 각각 thread_func() 함수를 실행
    std::thread t1(thread_func);
    std::thread t2(thread_func);  

    // 스레드가 종료될 때까지 기다림
    t1.join();
    t2.join();  

    return 0;
}

 

이 프로그램을 실행하면, 두 스레드가 각각 독립적으로 x 값을 증가시키는 것을 볼 수 있습니다. 이는 x가 thread_local로 선언되어 각 스레드마다 별도의 복사본을 가지기 때문입니다. 

 

스레드 로컬 저장소는 각 스레드가 독립적인 작업 상태를 유지해야 할 때 매우 유용합니다. 단, 스레드 간 데이터 공유가 필요하거나, 데이터 일관성을 유지해야 하는 경우에는 주의해서 사용해야 합니다. 또한, 과도하게 많은 양의 스레드 로컬 데이터를 사용하면 메모리 사용량이 증가하므로 이 점도 고려해야 합니다. 

 

아래에는 C++11에서 std::thread와 thread_local을 함께 사용하는 더 복잡한 예제를 보여드리겠습니다. 이 예제에서는 각 스레드가 자신만의 난수 생성기를 가지도록 설계하였습니다. 

 

[예제]

#include <iostream>
#include <thread>
#include <random>  

// thread_local로 선언하여 각 스레드가 독립적인 난수 생성기를 가질 수 있게 함
thread_local std::mt19937 rng{std::random_device{}()};  

void print_random_number() {
    // 각 스레드의 독립적인 난수 생성기를 사용하여 0부터 9까지의 난수를 생성하고 출력
    std::cout << "Thread ID: " << std::this_thread::get_id() << ", random number: " << rng() % 10 << '\n';
}  

void thread_func() {
    print_random_number();
    print_random_number();
}  

int main() {
    std::thread t1(thread_func);
    std::thread t2(thread_func);  

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

    return 0;
}

 

이 프로그램을 실행하면, 각 스레드가 자신만의 난수 생성기를 사용하여 독립적인 난수를 생성하는 것을 볼 수 있습니다. 이는 rng 변수가 thread_local로 선언되어 각 스레드마다 별도의 std::mt19937 객체를 생성하기 때문입니다. 

 

이처럼, 스레드 로컬 저장소를 사용하면 각 스레드가 자신만의 상태를 유지하고, 다른 스레드에게 영향을 주지 않는 독립적인 연산을 수행할 수 있습니다. 이 기능은 복잡한 멀티스레드 프로그램을 작성하는 데 유용한 도구로 활용될 수 있습니다. 

 

스레드 로컬 저장소를 사용할 때 주의할 점 중 하나는, 스레드가 종료되면 그 스레드의 스레드 로컬 저장소도 함께 소멸된다는 점입니다. 따라서 스레드 간에 데이터를 공유하거나 영구적으로 저장해야 하는 경우에는 다른 방법을 고려해야 합니다. 

 

또한, thread_local 변수는 스레드마다 별도의 복사본을 가지므로 메모리 사용량에 영향을 줄 수 있습니다. 대량의 데이터를 스레드 로컬 저장소에 저장하는 것은 피해야 합니다. 

 

이번에는 스레드 로컬 저장소를 사용하는 C 코드 예제를 제공하겠습니다. C에서는 _Thread_local 키워드를 사용하여 스레드 로컬 저장소를 선언할 수 있습니다. 

 

[예제]

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>  

// 스레드 로컬 변수 선언
_Thread_local int tls_value = 0;  

void* thread_func(void* arg) {
    // 각 스레드는 tls_value에 독립적으로 접근
    tls_value = (int)(long)arg;
    printf("Thread %d: tls_value = %d\n", (int)(long)arg, tls_value);
    sleep(1);
    printf("Thread %d: tls_value = %d\n", (int)(long)arg, tls_value);
    return NULL;
}  

int main() {
    pthread_t thread1, thread2;
    pthread_create(&thread1, NULL, thread_func, (void*)1);
    pthread_create(&thread2, NULL, thread_func, (void*)2);  

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);  

    return 0;
}

 

이 프로그램은 두 개의 스레드를 생성하고, 각 스레드에서는 _Thread_local로 선언된 tls_value 변수에 접근하여 값을 변경하고 출력합니다. 출력 결과를 보면, 각 스레드가 tls_value에 독립적으로 접근하고 있음을 알 수 있습니다. 

 

스레드 로컬 저장소는 복잡한 멀티스레드 프로그램에서 스레드 사이의 데이터 격리를 달성하는 데 유용하며, 스레드가 자신만의 상태를 유지하도록 할 수 있습니다. 하지만, thread_local이나 _Thread_local로 선언된 변수는 각 스레드에 대해 별도의 메모리 공간을 차지하므로, 메모리 사용량에 주의해야 합니다. 

 

이렇게 간략하게 스레드 로컬 저장소에 대한 개념과 C/C++에서의 사용법을 살펴보았습니다. 이 기능을 활용하면 복잡한 멀티스레드 프로그램을 좀 더 효과적으로 구현할 수 있습니다. 다만, 스레드 로컬 저장소의 특성과 한계를 이해하고, 적절한 상황에서만 사용해야 합니다. 

 

13.6.3. 스레드 로컬 저장소의 주의점  

스레드 로컬 저장소를 사용할 때 주의해야 할 몇 가지 주요 점을 설명하겠습니다.  

 

메모리 사용량: 스레드 로컬 저장소를 사용하면 각 스레드에 대해 별도의 메모리 공간이 할당됩니다. 스레드의 수가 많거나 스레드 로컬 변수의 크기가 크면 메모리 사용량이 빠르게 증가할 수 있습니다. 따라서 스레드 로컬 저장소를 사용할 때는 메모리 사용량을 신중하게 관리해야 합니다. 

 

스레드 로컬 저장소의 생명주기: 스레드 로컬 변수의 생명주기는 해당 스레드의 생명주기와 동일합니다. 스레드가 종료되면 스레드 로컬 변수도 자동으로 소멸되므로, 스레드 로컬 변수의 값을 다른 스레드와 공유하거나 저장해야 할 경우에는 주의가 필요합니다.

 

동일한 스레드 로컬 변수에 대한 동시 접근: 스레드 로컬 저장소는 각 스레드에 대해 독립적인 메모리 공간을 제공하지만, 한 스레드 내에서 스레드 로컬 변수를 동시에 여러 위치에서 사용하는 경우에는 여전히 동시성 문제가 발생할 수 있습니다. 이 경우에는 추가적인 동기화 메커니즘을 사용해야 할 수 있습니다. 

 

이해를 돕기 위해 C++에서의 예제 코드를 다시 살펴보겠습니다. 이전에 언급한 바와 같이, 스레드 로컬 저장소의 사용은 스레드 간의 데이터 격리를 제공하지만, 스레드 내에서 스레드 로컬 변수를 동시에 여러 위치에서 사용하는 경우에는 여전히 동시성 문제가 발생할 수 있습니다. 이러한 상황을 처리하기 위해 추가적인 동기화 메커니즘, 예를 들어 뮤텍스를 사용해야 할 수 있습니다. 

 

[예제]

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

thread_local int tls_value = 0;
std::mutex mtx;  

void thread_func() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        tls_value += 1;
    }
    std::cout << "tls_value = " << tls_value << std::endl;
}  

int main() {
    std::thread t1(thread_func);
    std::thread t2(thread_func);  

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

    return 0;
}

 

이 코드는 두 개의 스레드를 생성하고, 각 스레드에서는 뮤텍스를 사용하여 스레드 로컬 변수 tls_value에 동기화된 방식으로 접근합니다. 이를 통해 동일한 스레드 내에서 tls_value에 동시에 접근하는 경우에 대한 동시성 문제를 해결할 수 있습니다. 

 

스레드 로컬 저장소는 매우 유용한 기능이지만, 위에서 설명한 것처럼 몇 가지 주의사항을 기억해야 합니다. 이러한 주의사항을 이해하고 스레드 로컬 저장소를 올바르게 사용하면, 멀티스레드 프로그램의 성능과 안정성을 크게 향상할 수 있습니다. 

 

마지막으로, 스레드 로컬 저장소를 사용할 때 고려해야 할 또 다른 중요한 점은 이식성입니다. 스레드 로컬 저장소는 C++11 이후로 표준에 포함되어 있지만, 모든 컴파일러나 플랫폼에서 지원되지 않을 수 있습니다. 따라서 코드의 이식성을 중요하게 생각하는 경우에는, 스레드 로컬 저장소를 사용하기 전에 해당 기능을 지원하는지 확인해야 합니다. 

 

다음 예제는 이식성을 고려한 코드입니다.  

 

[예제]

#ifdef __GNUC__
  #define THREAD_LOCAL __thread
#elif __STDC_VERSION__ >= 201112L
  #define THREAD_LOCAL _Thread_local
#elif defined(_MSC_VER)
  #define THREAD_LOCAL __declspec(thread)
#else
  #error "Cannot define thread_local"
#endif  

THREAD_LOCAL int tls_value = 0;

 

이 예제는 여러 컴파일러에서 스레드 로컬 변수를 선언하는 방법을 보여줍니다. 이처럼 매크로를 사용하면, 컴파일러나 플랫폼에 따라 다르게 정의되어야 하는 코드를 일관되게 관리할 수 있습니다. 

 

이상으로 '스레드 로컬 저장소의 주의점'에 대한 내용을 마칩니다. 이 섹션에서는 스레드 로컬 저장소를 사용할 때 고려해야 할 주요 사항, 즉 메모리 사용량, 스레드 로컬 변수의 생명주기, 동시 접근 문제, 그리고 이식성에 대해 알아보았습니다. 이러한 지식을 바탕으로 스레드 로컬 저장소를 효과적으로 사용할 수 있을 것입니다.  

 

13.6.4. 스레드 로컬 저장소와 성능  

스레드 로컬 저장소(Thread Local Storage, TLS)는 이름에서도 알 수 있듯이 각 스레드마다 개별적으로 값을 가지는 저장 공간을 말합니다. 이로 인해 여러 스레드 간의 동기화 비용을 줄일 수 있으며, 이는 멀티 스레딩 환경에서 성능을 향상하는 데 큰 도움이 됩니다. 왜냐하면 공유 데이터에 대한 동기화는 대체로 비용이 많이 드는 작업이기 때문입니다. 

 

그러나 스레드 로컬 저장소를 사용하면서 생기는 추가적인 메모리 사용량과 초기화 비용을 감안해야 합니다. 예를 들어, 스레드가 많아질수록 각각의 스레드 로컬 변수에 대한 메모리가 추가로 필요하게 됩니다. 또한, 스레드 로컬 변수는 각 스레드가 시작할 때마다 초기화되어야 합니다. 

 

아래는 스레드 로컬 변수를 사용하는 간단한 예제입니다. 

 

[예제]

#include <iostream>
#include <thread>  

thread_local int tls_var = 0;  

void workOnThread(int id) {
    tls_var = id;
    std::cout << "Thread " << id << " has tls_var = " << tls_var << "\n";
}  

int main() {
    std::thread t1(workOnThread, 1);
    std::thread t2(workOnThread, 2);  

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

    return 0;
}

 

이 코드는 두 개의 스레드에서 각각 스레드 로컬 변수 tls_var를 사용하고 있습니다. 이 변수는 각 스레드에서 독립적으로 동작하기 때문에, 각 스레드에서 출력한 tls_var의 값은 스레드의 id와 일치합니다. 

 

이처럼 스레드 로컬 저장소는 멀티 스레드 환경에서 성능을 향상시키는 데 사용할 수 있지만, 항상 그렇게 유익한 것만은 아닙니다. 스레드 로컬 저장소를 사용할 때는 그 특성과 한계를 잘 이해하고 적절하게 사용해야 합니다. 

 

변수를 스레드 로컬 저장소에 할당하는 것은 성능에 미치는 영향을 이해하는 것이 중요합니다.  

 

스레드 로컬 저장소를 이용하면, 각 스레드는 고유한 스토리지 영역을 가지게 됩니다. 이로 인해 별도의 동기화 작업 없이 동시에 접근이 가능하며, 이는 스레드 간의 동기화 비용을 크게 줄일 수 있습니다. 이런 점에서, 공유 변수에 접근할 때 발생하는 성능 문제를 피하는데 큰 도움이 될 수 있습니다. 

 

그러나 이와 동시에 스레드 로컬 저장소를 사용하면서 생길 수 있는 비용도 고려해야 합니다. 각 스레드마다 스레드 로컬 변수의 복사본을 관리하게 되므로, 스레드의 수가 많아질수록 메모리 사용량이 증가합니다. 이로 인해 메모리 부족 현상이나 가비지 컬렉션 비용이 증가하는 등의 문제가 발생할 수 있습니다. 

 

또한, 스레드 로컬 변수는 스레드가 시작될 때마다 초기화되어야 합니다. 따라서, 스레드를 자주 생성하고 제거하는 경우, 이로 인한 초기화 비용이 클 수 있습니다. 

 

따라서 스레드 로컬 저장소를 사용하는 것은 동시성 관리와 성능 사이의 균형을 찾는 것을 필요로 합니다. 동기화 비용을 줄이는 대신 메모리 사용량과 초기화 비용이 증가하는 trade-off가 있습니다. 이를 잘 고려하여 스레드 로컬 저장소를 효율적으로 사용해야 합니다. 

 

결론적으로, 스레드 로컬 저장소를 사용할지, 공유 변수와 동기화 메커니즘을 사용할지는 프로그램의 특성, 스레드의 수, 변수 접근 패턴 등 많은 요소를 고려해 결정해야 합니다. 항상 최선의 선택은 없으며, 각 경우에 따라 적절한 방법을 선택하는 것이 중요합니다. 

 

변수의 접근 패턴에 따라 스레드 로컬 저장소의 사용이 오히려 성능 저하를 가져올 수도 있습니다. 예를 들어, 스레드 간에 데이터를 공유해야 하는 경우, 각 스레드에서 해당 데이터의 복사본을 유지하고 갱신하는 비용이 높을 수 있습니다. 이런 경우에는 공유 변수와 동기화 메커니즘을 사용하는 것이 더 효율적일 수 있습니다. 

 

또한, 스레드 로컬 저장소가 어떻게 구현되었는지도 성능에 영향을 미칩니다. 일반적으로, 스레드 로컬 저장소는 스레드 별로 해시 테이블을 유지하여 변수를 저장합니다. 그러나 해시 테이블 접근 비용은 일반 메모리 접근 비용보다 높을 수 있으므로, 스레드 로컬 변수에 자주 접근하는 경우 이로 인한 비용이 커질 수 있습니다. 

 

아래는 스레드 로컬 저장소를 사용하여 각 스레드가 고유한 값을 가질 수 있도록 하는 간단한 C++ 예제입니다. 이 예제에서는 thread_local 키워드를 사용하여 각 스레드가 고유한 local_var를 가지도록 하였습니다. 

 

[예제]

#include <iostream>
#include <thread>  

thread_local int local_var;  

void thread_function(int id) {
    local_var = id;
    std::cout << "Thread " << id << " has local_var = " << local_var << std::endl;
}  

int main() {
    std::thread t1(thread_function, 1);
    std::thread t2(thread_function, 2);
    t1.join();
    t2.join();
    return 0;
}

 

이 예제를 실행하면 각 스레드가 고유한 local_var 값을 출력하는 것을 볼 수 있습니다. 하지만, local_var에 대한 접근 비용은 일반 변수 접근 비용보다 높을 수 있으므로, 이를 고려하여 스레드 로컬 저장소를 사용해야 합니다. 

 

스레드 로컬 저장소와 관련된 성능 문제는 종종 무시되지만, 실제로는 이로 인해 심각한 성능 저하가 발생할 수 있습니다. 따라서, 스레드 로컬 저장소를 사용할 때는 이러한 비용과 트레이드오프를 잘 이해하고 있어야 합니다. 

 

그럼에도 불구하고, 스레드 로컬 저장소는 프로그램의 복잡성을 줄이는 데 매우 유용하다는 점을 이해하는 것이 중요합니다. 프로그래머가 명시적으로 동기화를 처리하지 않아도 되기 때문에, 코드는 더 간단하고 이해하기 쉬워집니다. 그러나 성능에 민감한 애플리케이션에서는 스레드 로컬 저장소의 비용을 신중하게 고려해야 합니다. 

 

다음은 C++11에서 스레드 로컬 저장소의 성능에 대한 간단한 실험을 수행하는 코드입니다. 

 

[예제]

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

thread_local int local_var;  

void access_thread_local(int num_accesses) {
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < num_accesses; ++i) {
        ++local_var;
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> diff = end-start;
    std::cout << "Accessing thread_local variable " << num_accesses << " times took " << diff.count() << " seconds.\n";
}  

void access_regular(int num_accesses) {
    int regular_var = 0;
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < num_accesses; ++i) {
        ++regular_var;
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> diff = end-start;
    std::cout << "Accessing regular variable " << num_accesses << " times took " << diff.count() << " seconds.\n";
}  

int main() {
    std::thread t1(access_thread_local, 100000000);
    std::thread t2(access_regular, 100000000);
    t1.join();
    t2.join();
    return 0;
}

 

이 예제에서는 각 스레드가 동일한 횟수로 스레드 로컬 변수와 일반 변수에 접근하며, 각 경우에 걸리는 시간을 측정합니다. 이를 통해 스레드 로컬 저장소의 추가 비용을 감안해야 한다는 점을 이해할 수 있습니다. 

 

스레드 로컬 저장소의 사용은 프로그램의 요구사항과 성능 목표에 따라 달라집니다. 스레드 로컬 저장소를 사용하면 동기화를 피할 수 있지만, 이로 인한 추가 비용을 감수해야 합니다. 따라서, 여러분의 애플리케이션에서 스레드 로컬 저장소를 사용하는 것이 적절한지 여부는 여러분 스스로 판단해야 합니다. 이러한 결정을 내리는 데는 성능 분석 및 측정이 중요한 역할을 할 것입니다. 

 

스레드 로컬 저장소는 많은 경우 프로그래밍이 훨씬 단순해질 수 있지만, 그것이 반드시 모든 상황에서 최선의 해결책이 될 수는 없습니다. 스레드 로컬 저장소가 항상 적절하지 않은 이유 중 하나는 그것이 다른 스레드 간에 데이터를 공유하는 것을 더 어렵게 만들기 때문입니다. 스레드가 자신의 스레드 로컬 변수에 작업을 수행하고 그 결과를 다른 스레드와 공유하려고 하면, 어떠한 형태의 통신 메커니즘을 설정해야 할 것입니다. 이는 종종 추가적인 동기화가 필요하므로, 이를 통해 얻은 간결성의 이점이 상쇄될 수 있습니다. 

 

또한, 스레드 로컬 저장소가 항상 적합하지 않은 또 다른 이유는 그것이 프로그램의 메모리 사용량을 증가시킬 수 있다는 것입니다. 프로그램에서 많은 수의 스레드를 생성하고 각 스레드에서 큰 스레드 로컬 변수를 사용하면, 이러한 변수들이 차지하는 메모리는 빠르게 증가할 것입니다. 

 

마지막으로, 스레드 로컬 저장소를 사용하는 것은 프로그램의 흐름을 이해하기 어렵게 만들 수 있습니다. 프로그래머가 이해해야 할 상태가 늘어나고, 각 스레드가 자신만의 상태를 가지게 되면, 프로그램의 동작을 추적하고 디버깅하는 것이 더 어렵게 될 수 있습니다. 

 

결국, 스레드 로컬 저장소의 사용은 설계 결정이며, 그 사용은 주의를 요합니다. 여러분의 애플리케이션에 가장 적합한 도구를 선택하는 것은 여러분의 몫입니다. 여러 가지 선택사항을 이해하고, 각각의 장단점을 고려하는 것이 중요합니다. 

 

13.6.5. 스레드 로컬 저장소와 동시성  

스레드 로컬 저장소(thread-local storage, TLS)는 동시성 프로그래밍에 있어 중요한 도구입니다. 기본적으로, TLS는 각 스레드가 고유한 데이터 인스턴스를 가질 수 있도록 해주며, 이로 인해 데이터 경쟁 조건(race condition)이나 데드락(deadlock)과 같은 복잡한 동기화 문제를 회피할 수 있습니다. 

 

동시성 프로그래밍에서 가장 어려운 부분 중 하나는 데이터를 여러 스레드 간에 안전하게 공유하는 것입니다. 만약 모든 스레드가 같은 데이터에 접근하려고 한다면, 우리는 해당 데이터에 대한 접근을 조정하고 동기화해야 합니다. 이를 위해 뮤텍스(mutex)와 같은 도구를 사용하지만, 이런 방식은 종종 복잡하며, 오버헤드를 초래하고, 잘못 사용될 경우 데드락을 발생시킬 수 있습니다. 

 

이런 문제를 피하는 한 가지 방법은 가능한 한 많은 데이터를 스레드 로컬로 만드는 것입니다. 각 스레드가 자신의 데이터 복사본을 가지고 있다면, 다른 스레드가 해당 데이터를 변경할 걱정 없이 그것을 자유롭게 사용할 수 있습니다. 

 

예를 들어, 아래의 C++ 코드에서는 각 스레드가 자신만의 복사본을 가진 'thread_local' 키워드를 사용합니다.  

 

[예제]

#include <iostream>
#include <thread>  

thread_local int tls_value = 0;  

void thread_function() {
    ++tls_value;
    std::cout << "TLS value in this thread: " << tls_value << std::endl;
}  

int main() {
    std::thread t1(thread_function);
    std::thread t2(thread_function);  

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

    std::cout << "TLS value in main thread: " << tls_value << std::endl;  

    return 0;
}

 

위의 코드에서, 각 스레드는 'tls_value'에 대한 자신만의 복사본을 가지고 있습니다. 이로 인해 각 스레드는 다른 스레드의 작업에 영향을 받지 않고 'tls_value'를 변경할 수 있습니다. 따라서 이 코드는 뮤텍스 없이도 여러 스레드에서 안전하게 실행될 수 있습니다. 

 

그러나 스레드 로컬 저장소를 사용할 때도 주의해야 합니다. 스레드 로컬 데이터는 해당 스레드가 존재하는 동안에만 유효하며, 스레드가 종료되면 해당 데이터도 함께 사라집니다. 따라서 스레드 간에 데이터를 공유하거나 스레드의 생명 주기를 넘어서 데이터를 유지해야 할 경우에는 스레드 로컬 저장소를 사용하는 것이 적절하지 않을 수 있습니다. 이와 같은 상황에서는 다른 동기화 메커니즘을 고려해야 합니다. 

 

또한, 스레드 로컬 저장소는 동적 메모리 할당에 대한 오버헤드를 가질 수 있습니다. 각 스레드는 자체 데이터 복사본을 가지므로, 많은 수의 스레드가 실행될 경우 이에 따른 메모리 비용이 증가하게 됩니다. 따라서 대규모 멀티스레드 애플리케이션에서는 스레드 로컬 저장소의 사용을 신중하게 고려해야 합니다. 

 

그럼에도 불구하고, 스레드 로컬 저장소는 동시성 프로그래밍에서 중요한 도구로 여겨집니다. 데이터의 동기화 문제를 간편하게 해결할 수 있으며, 이를 통해 프로그램의 성능을 향상하고 데드락과 같은 복잡한 문제를 방지할 수 있습니다. 

 

또한, 'thread_local' 키워드를 사용하는 것이 스레드 로컬 저장소를 구현하는 유일한 방법은 아닙니다. 대부분의 시스템은 API 레벨에서 스레드 로컬 저장소를 제공하며, 이를 이용하면 스레드 로컬 데이터를 보다 세밀하게 제어할 수 있습니다. 예를 들어, POSIX 스레드 라이브러리는 'pthread_key_create'와 'pthread_setspecific' 같은 함수를 제공하여 스레드 특정 데이터를 관리할 수 있게 합니다. 

 

마지막으로, 스레드 로컬 저장소는 특정 문제에 대한 해결책일 뿐, 모든 문제에 대한 만능 해결책이 아닙니다. 동시성 프로그래밍은 복잡하며, 다양한 상황과 요구 사항에 맞는 적절한 도구와 기법을 선택하는 것이 중요합니다. 스레드 로컬 저장소가 제공하는 이점과 제약 사항을 이해하고, 이를 적절하게 활용하여 프로그램의 효율성과 안정성을 높이는 데 중점을 두어야 합니다. 

 

13.6.6. 스레드 로컬 저장소의 활용  

스레드 로컬 저장소(Thread Local Storage, TLS)는 멀티스레딩 프로그래밍에서 굉장히 유용한 개념입니다. 각각의 스레드는 자신만의 데이터를 가지고 작동하고 이를 다른 스레드와 공유하지 않음으로써, 데이터의 동기화 문제를 효과적으로 해결할 수 있습니다. 

 

한 가지 간단한 활용 예로는 "랜덤 넘버 생성"을 들 수 있습니다. 각 스레드가 독립적인 난수 생성기를 가지고 있다면, 이는 각각의 스레드가 고유한 난수 시퀀스를 생성하는 데에 사용될 수 있습니다. C++에서 이를 구현하는 코드는 다음과 같습니다.

 

[예제]

#include <random>
#include <thread>
#include <iostream>  

thread_local std::mt19937 generator(std::random_device{}());  

void GenerateRandomNumbers()
{
    std::uniform_int_distribution<int> distribution(1, 100);
    for(int i = 0; i < 10; ++i)
    {
        std::cout << distribution(generator) << ' ';
    }
    std::cout << std::endl;
}  

int main()
{
    std::thread t1(GenerateRandomNumbers);
    std::thread t2(GenerateRandomNumbers);
    t1.join();
    t2.join();  

    return 0;
}

 

위의 코드에서 'thread_local' 키워드는 각 스레드에 대해 'generator'라는 독립적인 난수 생성기 객체를 만들어줍니다. 이를 통해 각 스레드는 자신만의 난수 시퀀스를 생성하게 됩니다. 이를 통해 각 스레드는 독립적으로 난수를 생성하고, 서로 다른 스레드에서 생성된 난수들 간의 상호작용을 걱정할 필요가 없습니다. 

 

또 다른 예로는 데이터베이스 연결 풀을 들 수 있습니다. 여러 스레드가 데이터베이스에 액세스 할 때, 각 스레드가 독립적인 데이터베이스 연결을 가질 수 있도록 스레드 로컬 저장소를 사용할 수 있습니다. 이렇게 하면, 다른 스레드에서 데이터베이스 연결을 사용하는 동안 스레드가 블로킹되는 것을 방지할 수 있습니다. 

 

그러나 스레드 로컬 저장소를 사용할 때에는 주의가 필요합니다. TLS를 많이 사용하면 프로그램의 메모리 사용량이 늘어나고, 성능에 영향을 미칠 수 있습니다. 또한, TLS는 자원 정리에 대한 복잡성을 증가시킬 수 있습니다. 각 스레드가 종료될 때, 해당 스레드의 스레드 로컬 데이터를 적절히 정리해야 합니다. 

 

그러므로, 스레드 로컬 저장소는 애플리케이션의 특정 요구 사항에 맞게 신중하게 사용해야 합니다. 그렇게 함으로써 스레드 간의 데이터 동기화 문제를 해결하고, 프로그램의 복잡성을 줄이며, 성능을 향상할 수 있습니다. 

 

끝으로, C/C++의 스레드 로컬 저장소를 활용하여 에러 핸들링에 대한 예를 들어보겠습니다. 에러 코드를 저장하는 전역 변수를 사용하는 전통적인 방식은 멀티스레드 환경에서 문제를 일으킵니다. 왜냐하면, 하나의 스레드가 에러 코드를 설정하고 이를 다른 스레드에서 검사하는 동안, 또 다른 스레드가 동일한 에러 코드를 변경할 수 있기 때문입니다. 이 문제를 해결하기 위해, 우리는 각 스레드가 자신의 에러 코드를 저장하는 스레드 로컬 저장소를 사용할 수 있습니다. 

 

예를 들어, 우리는 각각의 스레드가 자신만의 에러 코드를 저장할 수 있도록 스레드 로컬 저장소를 사용하는 C++ 코드를 다음과 같이 작성할 수 있습니다.

 

[예제]

#include <thread>
#include <iostream>  

thread_local int thread_error_code;  

void DoWork()
{
    thread_error_code = 1; // 에러 코드 설정
    // 작업 수행...  

    if (thread_error_code == 1) // 에러 코드 검사
    {
        std::cout << "Error occurred in thread " << std::this_thread::get_id() << std::endl;
    }
}  

int main()
{
    std::thread t1(DoWork);
    std::thread t2(DoWork);
    t1.join();
    t2.join();  

    return 0;
}

 

위의 코드에서 'thread_local' 키워드를 사용하여 'thread_error_code'라는 변수를 선언하였습니다. 이 변수는 각 스레드가 자신의 에러 코드를 저장하고, 이를 검사하는 데 사용됩니다. 이 방식을 사용하면, 각 스레드가 자신만의 에러 코드를 독립적으로 관리할 수 있습니다. 

 

이처럼 스레드 로컬 저장소는 멀티스레딩 환경에서 각 스레드가 독립적인 데이터를 관리하는 데 매우 유용합니다. 그러나 이를 사용할 때에는 각 스레드의 메모리 사용량과 리소스 정리 등의 이슈를 주의 깊게 고려해야 합니다. 이를 명심하며 스레드 로컬 저장소를 적절하게 활용하면, 멀티스레딩 프로그램의 성능과 안정성을 크게 향상시킬 수 있습니다. 

 

스레드 로컬 저장소를 활용한 다른 예는 각 스레드마다 고유한 난수 생성기를 유지하는 것입니다. 이는 게임이나 시뮬레이션에서 유용하게 사용될 수 있습니다. 아래에 이를 수행하는 C++ 코드를 작성해 봅시다. 

 

[예제]

#include <thread>
#include <random>
#include <iostream>  

thread_local std::mt19937 generator;  

void GenerateRandomNumbers()
{
    std::uniform_int_distribution<int> distribution(0, 100);
    for(int i = 0; i < 10; ++i)
    {
        int random_number = distribution(generator);
        std::cout << "Thread " << std::this_thread::get_id() << ": " << random_number << std::endl;
    }
}  

int main()
{
    std::thread t1(GenerateRandomNumbers);
    std::thread t2(GenerateRandomNumbers);
    t1.join();
    t2.join();  

    return 0;
}

 

위 코드에서는, 각 스레드는 고유한 난수 생성기(std::mt19937)를 가지며, 이를 사용하여 난수를 생성합니다. 이 방식을 통해, 각 스레드는 독립적인 난수 시퀀스를 생성하게 되므로, 이를 통해 더 복잡한 문제를 해결할 수 있습니다. 

 

그러나, 스레드 로컬 저장소를 사용할 때는 주의해야 할 사항이 있습니다. 스레드 로컬 저장소가 과도하게 사용될 경우, 각 스레드의 메모리 사용량이 늘어나며, 이는 메모리 부족 문제를 일으킬 수 있습니다. 또한, 스레드 로컬 저장소는 스레드 간에 데이터를 공유하지 않으므로, 스레드 간에 데이터를 공유해야 하는 경우에는 다른 동기화 기법을 사용해야 합니다. 


13.7. 스레드 안전성  

여러 스레드가 동시에 동일한 코드나 데이터를 사용할 때 프로그램의 정확성이 유지되는 성질을 말합니다. 이는 동시성 프로그래밍에서 중요한 개념으로, 스레드 안전성이 보장되지 않으면 데이터 경쟁(race condition)과 같은 문제가 발생할 수 있습니다. 스레드 안전성을 보장하기 위해 여러 동기화 기법(예: 뮤텍스, 세마포어, 락 등)이 사용됩니다. 이들 도구를 적절히 사용하면, 동시에 실행되는 여러 스레드가 데이터를 안전하게 공유하고, 일관성 있는 동작을 보장할 수 있습니다. 

 

13.7.1. 스레드 안전성이란 무엇인가?  

멀티스레딩 환경에서 중요한 개념으로, 여러 스레드가 동시에 같은 데이터를 접근하거나 같은 함수를 실행하더라도 프로그램의 일관성이 유지되는 것을 의미합니다. 이는 데이터의 무결성을 보장하고, 경쟁 조건(race condition)과 같은 문제를 피하기 위해 필요합니다. 이러한 문제는 두 스레드가 동시에 동일한 데이터를 읽고 쓰려고 할 때 발생합니다. 스레드 안전성은 이런 상황을 제어하고 관리하는 방법입니다. 

 

스레드 안전성을 확보하는 가장 일반적인 방법은 동기화 기법을 사용하는 것입니다. 동기화를 통해 한 시점에 하나의 스레드만 특정 섹션에 접근하도록 제어할 수 있습니다. C++에서는 std::mutex를 통해 이를 구현할 수 있습니다. 

 

아래의 예제 코드는 스레드가 동기화되지 않은 상태에서 경쟁 조건을 보여줍니다. 

 

[예제]

#include <thread>
#include <iostream>  

int shared_var = 0;  

void increase() {
    for (int i = 0; i < 100000; ++i) {
        ++shared_var;
    }
}  

int main() {
    std::thread t1(increase);
    std::thread t2(increase);  

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

    std::cout << "Shared variable: " << shared_var << std::endl;  

    return 0;
}

 

위의 코드에서는 increase() 함수가 shared_var에 동시에 접근하여 증가시킵니다. 그러나 이런 코드는 예상치 못한 결과를 가져올 수 있습니다. 왜냐하면 두 스레드가 거의 동시에 shared_var을 읽고 쓸 수 있기 때문입니다. 

 

다음은 std::mutex를 사용하여 동기화하는 예제입니다.  

 

[예제]

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

int shared_var = 0;
std::mutex mtx;  

void increase() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();
        ++shared_var;
        mtx.unlock();
    }
}  

int main() {
    std::thread t1(increase);
    std::thread t2(increase);  

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

    std::cout << "Shared variable: " << shared_var << std::endl;  

    return 0;
}

 

위의 코드에서는 std::mutex를 사용하여 한 번에 하나의 스레드만 shared_var에 접근하도록 제어합니다. 이 방법은 예상대로 200,000을 출력하며, 경쟁 조건을 방지합니다. 이처럼 동기화는 스레드 안전성을 보장하는 중요한 방법입니다. 다만, 불필요한 동기화는 성능 저하를 가져올 수 있으므로 주의해야 합니다.  

 

13.7.2. 스레드 안전성 유지를 위한 기법  

스레드 안전성을 유지하는 데는 여러 가지 기법이 있습니다. 아래에서는 몇 가지 주요 기법들을 살펴보겠습니다.  

 

뮤텍스(Mutexes): 앞서 언급한 대로 뮤텍스는 스레드가 동시에 데이터나 리소스에 접근하는 것을 제한하는 데 사용됩니다. C++에서는 std::mutex를 사용하여 이를 수행할 수 있으며, 뮤텍스는 lock()과 unlock() 함수를 통해 제어할 수 있습니다. 


[예제]

#include <thread>
#include <mutex>  

std::mutex mtx;  

void print_block(int n, char c) {
    mtx.lock();
    for (int i = 0; i < n; ++i) { std::cout << c; }
    std::cout << '\n';
    mtx.unlock();
}  

int main() {
    std::thread th1(print_block, 50, '*');
    std::thread th2(print_block, 50, '$');  

    th1.join();
    th2.join();  

    return 0;
}

 

위의 예제에서, 두 개의 스레드가 동시에 std::cout에 접근하는 것을 방지하기 위해 뮤텍스를 사용합니다. 이로 인해 두 개의 출력 라인이 서로 섞이는 것을 막을 수 있습니다. 

 

세마포어(Semaphores): 세마포어는 뮤텍스와 비슷하지만, 한 번에 여러 스레드가 리소스에 접근할 수 있게 합니다. C++에서는 std::counting_semaphore를 사용할 수 있습니다. 

 

조건 변수(Condition variables): 조건 변수는 한 스레드가 특정 조건이 충족될 때까지 대기하도록 하며, 다른 스레드가 그 조건을 알릴 때까지 대기합니다. C++에서는 std::condition_variable을 사용합니다. 

 

원자적 연산(Atomic operations): 원자적 연산은 간단한 데이터 형태에 대한 연산을 한 번에 수행합니다. 이는 중간에 다른 스레드가 개입하지 못하도록 보장하므로, 일반적으로 경쟁 조건 없이 데이터를 조작할 수 있습니다. C++에서는 std::atomic 클래스를 사용합니다. 

 

[예제]

#include <thread>
#include <atomic>
#include <iostream>  

std::atomic<int> counter(0);  

void increase() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;
    }
}  

int main() {
    std::thread t1(increase);
    std::thread t2(increase);  

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

    std::cout << "Counter: " << counter << std::endl;  

    return 0;
}

 

위의 코드에서는 두 개의 스레드가 counter를 동시에 증가시키지만, 원자적 연산 덕분에 올바른 결과를 얻을 수 있습니다. 즉, 스레드 안전성을 유지하면서 동시성을 활용한 성능 향상을 누릴 수 있습니다. 

 

락프리 프로그래밍(Lock-free programming): 락프리 프로그래밍은 원자적 연산을 기반으로 하며, 뮤텍스나 세마포어와 같은 락을 사용하지 않고도 스레드 안전성을 보장하는 방식입니다. 이 방식은 고도의 전문성을 요구하며, 일반적으로 성능이 중요한 상황에서만 사용됩니다. 


이 외에도 스레드 로컬 저장소(thread-local storage), 적절한 메모리 관리(예: 스마트 포인터의 사용), 재진입 가능 함수(reentrant functions) 등 다양한 기법이 사용됩니다. 여기서 중요한 것은 이러한 기법들이 서로 복합적으로 사용될 수 있다는 것입니다. 특정 상황에 가장 적합한 기법을 선택하는 것은 프로그래머의 역할입니다. 

 

추가로 강조하고 싶은 것은, 다른 스레드에 의해 변경될 수 있는 데이터에 대해 동시에 접근하려고 할 때는 항상 주의해야 한다는 것입니다. 이러한 상황은 "경쟁 조건(race condition)"을 초래할 수 있습니다. 

 

경쟁 조건은 두 개 이상의 스레드가 동시에 공유 데이터에 접근하려고 하고, 최종 결과가 스레드 실행 순서에 따라 달라지는 상황을 말합니다. 이는 예측할 수 없는 결과를 초래할 수 있기 때문에, 스레드 안전성을 위해 피해야 할 주요 상황 중 하나입니다. 

 

스레드 안전성을 유지하는 방법 중 하나로는 "락(lock)"을 사용하는 것입니다. 락은 한 번에 하나의 스레드만 특정 코드 블록을 실행하도록 하는 방법입니다. C++의 std::mutex 클래스는 뮤텍스(mutual exclusion) 락의 일종으로, 이를 통해 여러 스레드가 동시에 같은 리소스를 사용하는 것을 방지할 수 있습니다. 

 

[예제]

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

std::mutex mtx;   

void print_block(int n, char c) {
    // Block access to cout from other threads
    mtx.lock();
    for (int i=0; i<n; ++i) { 
        std::cout << c; 
    }
    std::cout << '\n';
    mtx.unlock();
}  

int main() {
    std::thread th1(print_block, 50, '*');
    std::thread th2(print_block, 50, '$');  

    th1.join();
    th2.join();  

    return 0;
}

 

이 예제에서, 두 스레드 th1과 th2는 print_block 함수를 동시에 호출하지만, mtx.lock()와 mtx.unlock() 사이의 코드는 한 번에 하나의 스레드만 실행할 수 있습니다. 따라서 이 두 스레드의 출력이 서로 섞이는 것을 방지할 수 있습니다. 

 

하지만 락을 사용할 때 주의해야 할 점이 있습니다. 만약 스레드가 락을 획득하고, 그 락을 해제하지 않은 채로 그 스레드가 종료되면, 다른 스레드들은 영원히 그 락을 기다리게 될 수 있습니다. 이를 '데드락(deadlock)'이라고 합니다. 따라서 락을 사용할 때는 항상 락을 정상적으로 해제하는 것이 중요합니다. 이런 이유로 C++에서는 std::lock_guard나 std::unique_lock와 같은 RAII(Resource Acquisition Is Initialization) 기법을 사용해 자동으로 락을 관리하는 것을 권장합니다. 이는 코드의 안정성을 향상하고, 데드락을 방지하는 데 도움이 됩니다. 

 

스레드 안전성은 복잡한 주제이며, 정확히 이해하고 사용하기 위해서는 많은 연습이 필요합니다. 위에서 소개한 기법들을 이해하고 적절히 활용하면, 다중 스레드 프로그램을 안전하게 작성하는 데 큰 도움이 될 것입니다. 

 

13.7.3. 스레드 안전한 코드 작성하기  

스레드 안전성을 유지하는 것은 병렬 처리에서 매우 중요한 측면입니다. 여러 스레드가 동시에 데이터에 접근하면서 발생할 수 있는 문제를 최소화하기 위해, 스레드 안전성이 보장되는 코드를 작성하는 방법을 배워보겠습니다. 

 

데이터 레이스(Data Race)를 피하세요. 두 개 이상의 스레드가 동시에 같은 메모리 위치에 접근하고, 적어도 하나의 스레드가 쓰기 작업을 수행하면, 이러한 접근은 데이터 레이스를 발생시킵니다. 이를 방지하기 위해, 각 스레드가 독립적인 데이터만 작업하도록 설계하거나, 데이터에 접근할 때 동기화 메커니즘(예: 뮤텍스 또는 세마포어)을 사용해야 합니다

 

[예제]

// C++ 예제
#include <iostream>
#include <thread>
#include <mutex>  

std::mutex mtx;
int i = 0;  

void makeACount(int num) {
    for(int j = 0; j < num; ++j) {
        mtx.lock();
        ++i;
        std::cout << i << ' ';
        mtx.unlock();
    }
}  

int main() {
    std::thread t1(makeACount, 1000);
    std::thread t2(makeACount, 1000);  

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

    return 0;
}

 

이 예제에서는 두 스레드가 동시에 i에 접근하려고 하지만, 뮤텍스 mtx를 사용하여 동시 접근을 방지하고 있습니다.  

 

락을 최소한으로 사용하세요. 락은 공유 자원을 보호하는 데 필요하지만, 너무 많은 락은 성능을 저하시키고 데드락을 유발할 수 있습니다. 락이 필요한 부분만 락을 사용하고, 가능하다면 락 없이 스레드 안전성을 보장할 수 있는 방법을 찾아보세요. 

 

재입력 가능한 함수를 사용하세요. 재입력 가능한(reentrant) 함수는 한 스레드가 함수를 실행하는 도중에 다른 스레드가 동일한 함수를 호출하더라도 각각의 결과가 올바르게 유지되는 함수입니다. 이는 함수가 전역 변수나 정적 변수, 다른 공유 리소스를 사용하지 않거나, 함수 내에서 그러한 리소스를 적절히 보호하는 경우에만 보장됩니다. 

 

스레드 로컬 저장소를 사용하세요. 이 방법은 각 스레드가 자체 복사본의 데이터를 가지게 함으로써 데이터 경쟁 상황을 방지하는 방법입니다. C++11에서는 thread_local 키워드를 사용하여 이를 수행할 수 있습니다. 

 

[예제]

// C++ 예제
#include <iostream>
#include <thread>  

thread_local int i = 0;  

void makeACount(int num) {
    for(int j = 0; j < num; ++j) {
        ++i;
        std::cout << i << ' ';
    }
}  

int main() {
    std::thread t1(makeACount, 1000);
    std::thread t2(makeACount, 1000);  

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

    return 0;
}

 

이 예제에서는 i가 스레드 로컬 변수이므로, 각 스레드는 자체 i 복사본을 사용하게 됩니다. 이로 인해 스레드 간에 i의 값에 대한 경쟁 상황이 발생하지 않습니다. 

 

원자적 연산을 이용하세요. 원자적 연산은 중단되지 않고 한 번에 완전히 수행되는 연산을 의미합니다. 이러한 연산은 멀티스레딩 환경에서 데이터 경쟁 조건을 방지하는 데 매우 유용합니다. C++11에서는 <atomic> 라이브러리를 통해 원자적 연산을 지원합니다. 

 

[예제]

// C++ 예제
#include <iostream>
#include <thread>
#include <atomic>  

std::atomic<int> i(0);  

void makeACount(int num) {
    for(int j = 0; j < num; ++j) {
        ++i;
        std::cout << i << ' ';
    }
}  

int main() {
    std::thread t1(makeACount, 1000);
    std::thread t2(makeACount, 1000);  

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

    return 0;
}

 

이 예제에서 i는 원자적 정수로 선언되어 있습니다. 따라서, 두 스레드가 i를 동시에 증가시키려고 시도하더라도, i의 값은 올바르게 증가됩니다. 

 

스레드 안전한 코드를 작성하려면, 데이터 레이스를 피하고, 락을 최소한으로 사용하며, 재입력 가능한 함수를 사용하고, 스레드 로컬 저장소를 활용하며, 원자적 연산을 이용하는 등 다양한 방법을 활용해야 합니다. 이 모든 방법은 서로 보완적이며, 특정 상황에서는 한 가지 방법이 다른 방법보다 더 적합할 수 있습니다. 따라서, 이러한 기법들을 적절히 섞어 사용하여 효율적이고 안정적인 멀티스레드 코드를 작성하게 될 것입니다. 

 

13.7.4. 스레드 안전성에 대한 고려 사항  

스레드 안전성(thread-safety)은 멀티 스레드 환경에서 코드의 정확성을 보장하는 것을 말합니다. 여러 스레드가 동시에 같은 메모리에 접근할 때 발생할 수 있는 데이터 경쟁 상황을 피하기 위해 반드시 고려해야 하는 주요 사항들을 아래에 정리하였습니다. 

 

데이터 레이스 상황 고려하기: 두 개 이상의 스레드가 동시에 같은 메모리 위치에 접근하려고 하면서, 그중 적어도 하나는 쓰기 작업을 수행하면 데이터 레이스 상황이 발생합니다. 이러한 상황은 예상치 못한 결과를 초래하므로 이를 방지하는 것이 중요합니다. 

 

뮤텍스와 락 사용하기: 데이터 레이스를 방지하기 위한 가장 일반적인 방법은 뮤텍스나 락을 사용하는 것입니다. 뮤텍스를 사용하여 특정 코드 블록을 보호하면, 한 번에 하나의 스레드만 해당 코드 블록에 접근할 수 있습니다. 

 

[예제]

// C++ 예제
#include <iostream>
#include <thread>
#include <mutex>  

std::mutex m;
int i = 0;  

void makeACount() {
    for(int j = 0; j < 100000; ++j) {
        std::lock_guard<std::mutex> lock(m); 
        ++i;
    }
}  

int main() {
    std::thread t1(makeACount);
    std::thread t2(makeACount);  

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

    std::cout << i << std::endl;  

    return 0;
}

 

이 예제에서 std::lock_guard는 생성될 때 자동으로 뮤텍스를 잠그고, 파괴될 때 자동으로 뮤텍스를 해제하는 RAII 방식을 사용합니다. 이로 인해 makeACount 함수에서 i를 증가시키는 동작은 한 번에 한 스레드만 수행할 수 있습니다. 

 

락의 오버헤드 고려하기: 락은 데이터 레이스를 방지하는 데 효과적이지만, 사용에 있어서는 주의가 필요합니다. 락은 오버헤드를 발생시키므로, 락을 너무 자주 사용하면 프로그램의 성능을 저하시킬 수 있습니다. 따라서 락을 최소한으로 사용하고, 가능한 경우 락을 회피하는 기법을 고려해야 합니다. 

 

원자적 연산 고려하기: 원자적 연산은 중간에 방해받지 않고 한 번에 완전히 수행되는 연산입니다. 이러한 연산은 데이터 레이스를 피하는 데 매우 유용하며, C++11부터는 <atomic> 라이브러리를 통해 이를 지원하게 되었습니다. 

 

이상과 같이, 스레드 안전성에 대한 고려 사항을 명확히 이해하고, 적절한 방법을 통해 문제를 해결하는 것이 중요합니다. 이렇게 하면 멀티 스레드 환경에서도 안정적이고 효율적인 프로그램을 작성할 수 있습니다. 이러한 원칙들을 지키는 것은 복잡하고 어려울 수 있지만, 이를 통해 얻는 이점은 그 어려움을 보상하는 충분한 가치가 있습니다. 

 

13.7.5. 스레드 안전한 클래스 설계  

멀티스레드 환경에서 스레드 안전성(thread-safety)을 유지하려면 클래스 설계에서도 주의해야 합니다. 클래스의 멤버 변수에 여러 스레드가 동시에 접근할 수 있기 때문에, 동기화 문제를 유발할 수 있습니다. 이 문제를 피하기 위해 클래스 설계에서 고려해야 하는 몇 가지 사항들을 살펴봅시다. 

 

멤버 변수 보호: 클래스의 멤버 변수에 접근하는 모든 함수는 멤버 변수를 변경하거나 반환하기 전에 락(lock)을 얻어야 합니다. 이는 멤버 변수에 동시에 여러 스레드가 접근하는 것을 방지하고, 데이터 레이스를 예방합니다.

 

[예제]

// C++ 예제
#include <mutex>  

class ThreadSafeCounter {
private:
    std::mutex mutex_;
    int value_;  

public:
    ThreadSafeCounter() : value_(0) {}  

    void increment() {
        std::lock_guard<std::mutex> lock(mutex_);
        ++value_;
    }  

    int get_value() {
        std::lock_guard<std::mutex> lock(mutex_);
        return value_;
    }
};

 

이 C++ 예제에서, ThreadSafeCounter 클래스는 increment 함수와 get_value 함수에서 모두 락을 얻은 후에만 value_ 멤버 변수에 접근합니다. 이로써 동시에 value_에 접근하려는 스레드가 있을 경우, 다른 스레드가 락을 해제할 때까지 기다려야 합니다. 

 

데드락 피하기: 여러 개의 락을 사용하는 클래스에서는 데드락(deadlock)이 발생할 수 있습니다. 데드락은 두 개 이상의 스레드가 서로의 락 해제를 기다리면서 무한 대기 상태에 빠지는 상황을 말합니다. 이를 방지하기 위해선, 스레드가 락을 얻는 순서를 일관되게 유지하거나, std::lock 함수 같은 기능을 사용하여 한 번에 여러 락을 얻는 것이 유용합니다. 

 

최소한의 락 사용: 락을 사용하는 동안 스레드가 대기 상태에 들어갈 수 있으므로, 락을 사용하는 구간을 최소한으로 유지해야 합니다. 이를 위해 락을 얻는 구간에서는 가능한 한 빠르게 작업을 완료하고 락을 반환해야 합니다. 

 

상태에 대한 캡슐화: 클래스 내부의 상태는 외부로부터 캡슐화되어야 합니다. 이는 클래스의 상태에 동시에 접근하는 스레드를 줄이고, 동기화를 더 쉽게 만듭니다. 

 

이와 같이 클래스 설계에서 스레드 안전성을 유지하는 것은 매우 중요합니다. 클래스가 올바르게 동기화되면, 데이터 레이스나 데드락과 같은 복잡한 문제를 피할 수 있습니다. 이러한 접근 방식은 복잡한 멀티스레드 프로그램에서도 안정성과 성능을 동시에 보장할 수 있게 합니다. 

 

13.7.6. 스레드 안전성 검증 방법  

멀티스레드 프로그램을 작성할 때, 단순히 스레드 안전성을 고려하는 것만으로 충분하지 않습니다. 실제로 작성한 코드가 스레드 안전성을 유지하고 있는지 검증하는 것이 중요합니다. 이를 위한 몇 가지 방법들을 살펴봅시다. 

 

코드 검토: 코드 검토는 스레드 안전성을 확인하는 가장 기본적인 방법 중 하나입니다. 특히, 공유 자원에 대한 접근 코드를 주의 깊게 검토해야 합니다. 모든 공유 자원에 대한 접근이 적절히 동기화되어 있는지, 데드락을 유발할 수 있는 상황이 있는지 등을 확인해야 합니다. 

 

테스트: 코드를 여러 스레드에서 동시에 실행하여 문제를 발견하는 것입니다. 이를 위해 고부하 테스트(high-load testing)와 스트레스 테스트(stress testing)를 수행합니다. 이 방법은 경쟁 상태나 데드락 같은 문제를 찾아낼 수 있지만, 모든 경우를 보장할 수는 없습니다. 

 

동적 분석 도구 사용: 동적 분석 도구는 프로그램을 실행하는 동안 문제를 발견할 수 있게 도와줍니다. 예를 들어, Helgrind와 DRD 같은 Valgrind 도구들은 C/C++ 프로그램에서 데이터 경쟁을 검출할 수 있습니다. 

 

[예제]

// C++ 예제
#include <thread>
#include <iostream>  

int shared_resource = 0;  

void worker() {
    shared_resource++;
}  

int main() {
    std::thread t1(worker);
    std::thread t2(worker);  

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

    std::cout << "Final value: " << shared_resource << std::endl;
    return 0;
}

 

위 예제 코드에서는 shared_resource라는 공유 자원에 대한 접근이 동기화되지 않았습니다. 따라서, 동적 분석 도구를 사용하면 이러한 문제를 감지할 수 있습니다. 

 

이와 같이 스레드 안전성을 검증하는 과정은 복잡할 수 있지만, 이를 통해 데이터 레이스나 데드락 등의 심각한 문제를 미리 방지할 수 있습니다. 따라서, 멀티스레드 프로그래밍에서는 코드 작성뿐 아니라 그에 따른 검증 과정도 반드시 고려해야 합니다. 

 

멀티스레딩 환경에서 오류가 발생하는 것은 매우 일반적인 일입니다. 이러한 오류는 테스트에서 항상 재현되지 않을 수 있기 때문에 검출이 더욱 어려울 수 있습니다. 그래서 스레드를 많이 사용하는 프로그램에서는 테스트 자체가 더욱 중요해집니다. 특히, 높은 부하하에서 동시성 오류를 찾아내는 것이 중요합니다. 

 

예를 들어, 여러 스레드에서 동시에 데이터베이스에 쓰는 상황을 시뮬레이션하는 테스트를 만들 수 있습니다. 그러나 이러한 테스트는 항상 완전한 해결책이 될 수는 없습니다. 왜냐하면 실제 상황에서 발생할 수 있는 모든 가능성을 시뮬레이션할 수 없기 때문입니다. 

 

이런 이유로 코드 리뷰와 동적 분석 도구의 사용이 권장됩니다. 코드 리뷰를 통해 다른 개발자들이 코드를 직접 보고, 문제가 될 만한 부분을 찾아낼 수 있습니다. 반면, 동적 분석 도구는 실행 중인 프로그램을 모니터링하고 가능한 문제점을 찾아내는 데 도움을 줍니다. 

 

스레드 안전성 검증 방법은 완벽하게 모든 문제를 찾아내는 것이 아니라 가능한 많은 문제를 찾아내는 것에 초점을 맞추어야 합니다. 따라서, 여러 가지 방법을 동시에 사용하여 가능한 많은 문제를 찾아내는 것이 좋습니다. 이를 통해 가능한 많은 문제를 찾아내고 이를 수정함으로써 프로그램의 안정성을 높일 수 있습니다. 이는 프로그램의 성능을 향상하고 사용자에게 더 나은 경험을 제공하는 데 기여합니다.

 

 

 

2023.06.13 - [GD's IT Lectures : 기초부터 시리즈/C, C++ 기초부터 ~] - [C/C++ 프로그래밍 : 중급] 12. 람다 표현식

 

[C/C++ 프로그래밍 : 중급] 12. 람다 표현식

Chapter 12. 람다 표현식 람다 표현식은 C++11에서 도입된 강력한 기능입니다. 이름이 없는 함수를 직접 정의하고 이를 변수에 저장하거나 함수 인자로 전달할 수 있습니다. 이와 같은 람다 표현식의

gdngy.tistory.com

 

반응형

댓글