[14장] 동시성과 멀티스레딩: C++에서의 병렬 처리

이번 포스트에서는 C++에서의 동시성과 멀티스레딩을 다룹니다. 병렬 처리의 기초부터 고급 기술까지, C++에서 효과적으로 다중 스레딩을 구현하는 방법을 탐구하며, 성능 개선과 최적화 전략에 대해 알아봅니다.

클릭하면 큰 이미지로 볼 수 있습니다.

개요

윤성우의 열혈 C++ 프로그래밍, 오렌지미디어

C++에서의 동시성과 멀티스레딩은 소프트웨어의 성능을 향상시키기 위해 중요합니다. 동시성은 프로그램이 여러 작업을 동시에 처리할 수 있게 하며, 멀티스레딩은 이러한 작업들을 동시에 실행하는 기술입니다.

동시성의 중요성

  • 성능 향상: 여러 코어를 사용하여 작업을 병렬로 수행함으로써 프로그램의 실행 시간을 단축시킬 수 있습니다.
  • 자원 효율적 사용: CPU 자원을 최대한 활용하여, 대기 시간을 줄이고 처리량을 증가시킬 수 있습니다.

멀티스레딩의 도전

  • 데이터 경쟁과 동기화: 여러 스레드가 동시에 데이터에 접근할 때 발생하는 문제를 해결해야 합니다.
  • 데드락: 서로 다른 스레드가 서로를 기다리며 작업이 중단되는 현상을 방지해야 합니다.
  • 스레드 관리: 스레드 생성, 실행, 종료 등을 효과적으로 관리해야 합니다.

C++의 멀티스레딩 지원

C++11부터 표준 라이브러리에서 <thread>, <mutex>, <future> 등을 통해 멀티스레딩을 지원합니다.

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Thread function is running\n";
}

int main() {
    std::thread t(threadFunction); // 스레드 생성
    t.join(); // 메인 스레드가 t 스레드의 종료를 기다림
    std::cout << "Main thread continues after thread function\n";
    return 0;
}

이 코드는 기본적인 스레드 생성과 실행을 보여줍니다. std::thread를 사용하여 스레드를 생성하고, join() 함수로 메인 스레드가 새로 생성된 스레드가 종료될 때까지 기다리도록 합니다.

고급 주제

영재사고력 수학 1031 중급, 시매쓰, C단계, 초등3학년
  • 스레드 풀: 미리 정의된 수의 스레드를 생성하여 작업을 효율적으로 관리합니다.
  • 비동기 프로그래밍: std::asyncstd::future를 사용하여 결과를 나중에 받을 수 있는 비동기 작업을 수행합니다.
  • 락과 뮤텍스: 공유 자원에 대한 동시 접근을 관리하기 위해 락(lock)과 뮤텍스(mutex)를 사용합니다.

C++에서 동시성과 멀티스레딩을 활용하면 프로그램의 성능을 크게 향상시킬 수 있지만, 데이터 경쟁, 데드락과 같은 문제를 주의 깊게 관리해야 합니다. C++의 멀티스레딩 관련 기능을 숙지하고, 실제 문제에 적용함으로써 효과적인 병렬 프로그래밍 기술을 키울 수 있습니다.

동시성의 중요성

성능 향상을 위한 병렬 처리

  • 여러 코어 활용: 현대 컴퓨터는 대부분 멀티코어 프로세서를 사용합니다. 동시성을 통해 이러한 멀티코어의 잠재력을 최대한 활용할 수 있습니다. 각 코어가 독립적인 작업을 수행함으로써, 전체적인 작업 처리 속도가 상당히 향상됩니다.
  • 작업 분할: 복잡한 작업을 여러 하위 작업으로 나누고, 이를 병렬로 처리함으로써 전체 실행 시간을 단축할 수 있습니다. 이는 특히 데이터 처리, 이미지 렌더링, 과학적 계산 등에서 유용합니다.
  • 스케줄링 최적화: 동시성을 통해 시스템은 CPU 시간을 보다 효율적으로 스케줄링할 수 있습니다. 이는 특히 I/O 작업이 많은 애플리케이션에서 중요하며, CPU 대기 시간을 줄이고 전체적인 성능을 향상시킵니다.

자원의 효율적 사용

  • CPU 자원 활용: 동시성을 활용하면 CPU가 유휴 상태에 머무는 시간을 최소화하고, 자원을 보다 효율적으로 사용할 수 있습니다. 이는 특히 서버와 같은 환경에서 중요한데, 여러 요청을 동시에 처리할 수 있기 때문입니다.
  • 대기 시간 최소화: 병렬 처리를 통해 다른 프로세스가 입출력 작업을 기다리는 동안 다른 프로세스가 계산 작업을 수행할 수 있습니다. 이는 시스템의 전체적인 반응 시간과 처리량을 개선합니다.
  • 리소스 공유 및 균형: 멀티스레딩을 통해 여러 자원에 대한 접근을 균형 있게 분배할 수 있습니다. 예를 들어, 네트워크 요청, 파일 시스템 접근 등을 다양한 스레드에서 동시에 처리함으로써 시스템 리소스의 사용을 최적화할 수 있습니다.
#include <iostream>
#include <vector>
#include <thread>
#include <functional>

void processPart(std::vector<int>& data, int start, int end) {
    for (int i = start; i < end; ++i) {
        // 데이터 처리
        data[i] *= 2; // 예시: 각 요소를 두 배로 증가
    }
}

int main() {
    const int dataSize = 10000;
    std::vector<int> data(dataSize, 1); // 크기가 10000인 벡터를 1로 초기화

    int numThreads = std::thread::hardware_concurrency(); // 사용 가능한 코어 수
    std::vector<std::thread> threads(numThreads);
    int partSize = dataSize / numThreads;

    for (int i = 0; i < numThreads; ++i) {
        threads[i] = std::thread(processPart, std::ref(data), i * partSize, (i + 1) * partSize);
    }

    for (std::thread& t : threads) {
        t.join(); // 모든 스레드의 종료를 기다림
    }

    // 결과 확인 (예시로 처음 10개 요소 출력)
    for (int i = 0; i < 10; ++i) {
        std::cout << data[i] << " ";
    }
    std::cout << std::endl;

    return 0;
}

이 코드는 데이터 세트를 여러 부분으로 나누어 각 부분을 별도의 스레드에서 병렬로 처리합니다. 이렇게 함으로써 데이터 처리 작업을 빠르게 완료할 수 있으며, 멀티코어 시스템의 이점을 최대한 활용할 수 있습니다.

멀티스레딩의 도전과 해결 방안

C++ 기초 플러스:최신 C++11 버전 포함, 성안당

데이터 경쟁과 동기화

  • 문제 설명: 여러 스레드가 같은 데이터에 동시에 접근할 때, 일관성이 없는 데이터 상태를 초래할 수 있습니다. 이를 ‘데이터 경쟁(Race Condition)’이라 합니다.
  • 해결 방안:
    • 뮤텍스(Mutex): 공유 데이터에 대한 접근을 동기화하기 위해 뮤텍스를 사용합니다. 뮤텍스는 한 번에 하나의 스레드만 데이터에 접근하도록 제한합니다.
    • 락(Lock): 스레드가 특정 코드 섹션에 접근하기 전에 락을 얻고, 접근이 끝난 후에 락을 반환합니다.
    • 조건 변수(Condition Variables): 특정 조건이 만족될 때까지 스레드를 대기시키고, 조건이 충족되면 스레드를 깨웁니다.

데드락

  • 문제 설명: 두 개 이상의 스레드가 서로의 작업 완료를 기다리며 영원히 대기하는 상태를 ‘데드락(Deadlock)’이라 합니다.
  • 해결 방안:
    • 데드락 방지: 자원 할당 순서를 고정하여 데드락 발생을 예방합니다.
    • 데드락 회피: 자원 요청 시 데드락 발생 가능성을 검사하고 회피합니다.
    • 데드락 탐지 및 회복: 데드락을 탐지하고, 일부 스레드를 종료하거나 자원을 강제로 회수하여 해결합니다.

스레드 관리

  • 문제 설명: 스레드의 생성, 실행, 종료 등을 관리하는 것은 복잡할 수 있으며, 잘못 관리될 경우 리소스 누수, 성능 저하 등을 초래할 수 있습니다.
  • 해결 방안:
    • 스레드 풀(Thread Pool): 스레드를 미리 생성하고 풀에서 관리하여 필요 시 재사용합니다. 이는 스레드 생성과 종료에 드는 비용을 줄여줍니다.
    • 스레드 라이프사이클 관리: 스레드의 상태를 체계적으로 관리하고, 필요에 따라 안전하게 종료합니다.
#include <iostream>
#include <thread>
#include <mutex>

int sharedResource = 0;
std::mutex mtx; // 뮤텍스 선언

void incrementResource() {
    mtx.lock(); // 뮤텍스 잠금
    ++sharedResource;
    mtx.unlock(); // 뮤텍스 해제
}

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

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

    std::cout << "Shared Resource Value: " << sharedResource << std::endl;
    return 0;
}

이 코드에서는 std::mutex를 사용하여 공유 자원에 대한 접근을 동기화하고 있습니다. 두 스레드 t1t2incrementResource 함수를 통해 공유 자원의 값을 변경하려고 할 때, 뮤텍스를 통해 동기화를 수행하여 데이터 경쟁 문제를 방지합니다. 이러한 접근 방식은 데이터 일관성을 유지하고, 멀티스레딩 환경에서 발생할 수 있는 문제들을 방지하는 데 중요합니다.

누구나 쉽게 즐기는 C언어 콘서트(누구나 쉽게 즐기는), 생능출판

고급 주제: 멀티스레딩과 동시성

스레드 풀(Thread Pool)

  • 설명: 스레드 풀은 미리 정의된 수의 스레드를 생성하여 풀에 보관하고, 작업이 요청되면 이 풀에서 스레드를 할당합니다. 작업이 완료되면 스레드는 다시 풀로 반환됩니다. 이 방식은 스레드 생성과 소멸에 드는 비용을 줄이고, 자원 사용을 최적화합니다.
  • 소스코드 예시: C++ 표준 라이브러리에는 직접적인 스레드 풀 구현이 포함되어 있지 않기 때문에, 사용자 정의 스레드 풀 클래스를 구현해야 합니다. 하지만 여기서는 코드의 복잡성을 고려하여 예시를 생략합니다.
  • C++ 표준 라이브러리에는 직접적인 스레드 풀 구현이 포함되어 있지 않습니다. 그러나, 간단한 스레드 풀을 구현하는 것은 가능합니다. 아래 코드는 기본적인 스레드 풀 구현의 예시를 제공합니다. 이 코드는 여러 작업을 스레드 풀에 할당하고, 각 스레드가 대기열에서 작업을 가져와 실행하는 방식으로 동작합니다.
#include <iostream>
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>

class ThreadPool {
public:
    ThreadPool(size_t threads) : stop(false) {
        for(size_t i = 0; i < threads; ++i)
            workers.emplace_back([this] {
                while(true) {
                    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, class... Args>
    auto enqueue(F&& f, Args&&... args) 
        -> std::future<typename std::result_of<F(Args...)>::type> {
        using return_type = typename std::result_of<F(Args...)>::type;

        auto task = std::make_shared< std::packaged_task<return_type()> >(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...)
        );

        std::future<return_type> res = task->get_future();
        {
            std::unique_lock<std::mutex> lock(queue_mutex);

            // 스레드가 종료될 때 작업을 추가하는 것을 방지
            if(stop)
                throw std::runtime_error("enqueue on stopped ThreadPool");

            tasks.emplace([task](){ (*task)(); });
        }
        condition.notify_one();
        return res;
    }

private:
    // 스레드들을 관리할 벡터
    std::vector<std::thread> workers;
    // 작업 대기열
    std::queue<std::function<void()>> tasks;

    // 동기화를 위한 뮤텍스와 조건 변수
    std::mutex queue_mutex;
    std::condition_variable condition;
    bool stop;
};

int main() {
    ThreadPool pool(4); // 4개의 작업 스레드를 가진 스레드 풀 생성

    // 스레드 풀에 작업 추가
    auto result = pool.enqueue([]{ std::cout << "Hello from ThreadPool!" << std::endl; });
    result.get(); // 작업이 완료될 때까지 대기

    return 0;
}
모던 C:전문가를 위한 C 작성법!, 길벗

이 구현에서는 std::thread를 사용하여 스레드를 관리하고, std::queue를 이용하여 작업 대기열을 관리합니다. 각 작업은 std::function<void()> 타입으로 저장되며, 스레드 풀은 이 작업들을 하나씩 가져와 실행합니다. 또한, std::mutexstd::condition_variable을 사용하여 작업 대기열에 대한 스레드 간의 동기화를 처리합니다.

이 예제는 간단한 스레드 풀 구현을 보여줍니다. 실제 프로덕션 환경에서 사용할 때는 더 많은 오류 처리, 예외 처리 및 성능 최적화가 필요할 수 있습니다.

비동기 프로그래밍

  • 설명: std::asyncstd::future를 사용하여 비동기 작업을 수행합니다. std::async는 비동기적으로 함수를 실행하고, std::future는 나중에 그 결과를 받는 데 사용됩니다.
#include <iostream>
#include <future>

int performComputation(int x) {
    return x * 2; // 예시 계산
}

int main() {
    std::future<int> result = std::async(performComputation, 10);

    // 다른 작업 수행 가능
    std::cout << "Doing other work.\n";

    // 결과가 필요할 때까지 기다린 후 가져옴
    std::cout << "Result: " << result.get() << std::endl;

    return 0;
}
  • 이 코드는 performComputation 함수를 비동기적으로 실행하고, 그 결과를 std::future 객체를 통해 나중에 받습니다.

락과 뮤텍스

  • 설명: 락과 뮤텍스는 멀티스레딩 환경에서 공유 자원에 대한 동시 접근을 관리하기 위해 사용됩니다. 뮤텍스는 한 번에 하나의 스레드만 특정 자원에 접근하도록 보장합니다.
#include <iostream>
#include <mutex>
#include <thread>

int sharedResource = 0;
std::mutex resourceMutex;

void accessResource() {
    std::lock_guard<std::mutex> lock(resourceMutex);
    ++sharedResource; // 공유 자원에 접근
    std::cout << "Resource value: " << sharedResource << std::endl;
}

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

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

    return 0;
}
  • 이 코드에서는 std::lock_guard를 사용하여 resourceMutex 뮤텍스를 자동으로 잠그고 해제합니다. 이 방법은 스레드가 함수를 빠져나갈 때 예외가 발생하더라도 뮤텍스가 안전하게 해제됨을 보장합니다.
게임으로 배우는 C++, 생능출판

각 고급 주제에 대해 상세한 설명과 함께 예시 코드를 제공하였습니다. 스레드 풀의 경우, 표준 라이브러리에서 직접적으로 제공하지 않기 때문에 복잡한 사용자 정의 클래스 구현이 필요합니다. 비동기 프로그래밍과 락/뮤텍스 사용은 C++에서 흔히 사용되는 패턴으로, 멀티스레딩과 동시성 관련 프로그래밍에 있어 핵심적인 부분입니다.

결론

C++에서 멀티스레딩과 동시성을 다루는 것은 프로그램의 성능을 극대화하고, 자원을 효율적으로 사용하는 데 필수적입니다. 멀티스레딩은 프로그램이 여러 코어를 활용하여 작업을 병렬로 수행할 수 있게 하며, 이는 실행 시간 단축과 CPU 자원의 최적화를 가능하게 합니다. 그러나, 멀티스레딩을 구현할 때는 데이터 경쟁, 데드락, 스레드 관리 등의 문제를 해결해야 합니다. 이를 위해 뮤텍스, 락, 조건 변수 등을 사용하여 동기화를 관리하고, 스레드 풀을 통해 스레드 생성과 소멸의 비용을 최소화하며 자원 사용을 최적화할 수 있습니다. 또한, std::async와 std::future를 활용한 비동기 프로그래밍을 통해 프로그램의 반응성과 효율성을 높일 수 있습니다. C++에서 제공하는 이러한 도구들을 이해하고 적절히 사용함으로써, 멀티스레딩과 동시성이 요구되는 복잡한 프로그램을 효과적으로 구현할 수 있습니다.