Study: Software(SW)/SW: Language

[C++] 병행 컴퓨팅: concurrency, thread...

DrawingProcess 2022. 6. 16. 17:10
반응형

병행 컴퓨팅 (concurrency)


  • 여러 개의 계산들을 연속적(하나씩 일을 마치는 것)으로가 아닌, 병행 처리하는 것을 말합니다.
  • 병행 시스템은 다른 계산들이 모두 끝날 때까지 기다리지 않고 계산을 진행할 수 있는 환경을 말하며, 즉 하나 이상의 계산은 동시에 진행이 가능합니다.
  • 프로그램 논리 구조 상에서 연산들 간의 의존 관계가 많을수록 병렬화가 어려워지고,
    반대로, 다른 연산의 결과와 관계없이 독립적으로 수행할 수 있는 구조가 많을수록 병렬화가 매우 쉬워집니다.
     
    • 쓰레드: CPU 코어에서 돌아가는 프로그램 단위. 한 프로세스 내에 있는 쓰레드끼리는 메모리 교환 가능.
    • 프로세스: 최소 한 개 쓰레드로 이루어져 있으며, 여러 개의 쓰레드로 구성(멀티 쓰레드). 프로세스끼리 메모리를 공유하지 않음.

 

std::thread


  • 이전에는 C++ 표준에 쓰레드가 없어서, 각 플랫폼 마다 다른 구현을 사용해야만 했습니다.
    • 예를 들어서 윈도우즈에서는 CreateThread 로 쓰레드를 만들지만 리눅스에서는 pthread_create 로 만듭니다.
  • 하지만 C++ 11 에서부터 표준에 thread가 추가되면서, 쓰레드 사용이 매우 편리해졌습니다.
  • 인자가 없는 함수, 인자를 가지는 함수, 람다 함수 등을 이용하여 쓰레드를 생성할 수 있습니다.
  • 참고로 리눅스에서 컴파일 하는 분은 컴파일 옵션에 -pthread 를 추가로 넣어야 합니다.
#include "spdlog/fmt/fmt.h"
#include <thread>

void func1()
{
    fmt::print("[T1] I will be dead\n");
}

void func2(int const i)
{
    fmt::print("[T2] I will be dead after {} seconds\n", i);
    std::this_thread::yield();
    std::this_thread::sleep_for(std::chrono::seconds(i));
    fmt::print("[T2] now I am dead\n", i);
}

int main()
{
    std::thread t1(func1);
    std::thread t2(func2, 5);
    std::thread t3([]()
                   { fmt::print("[T3] I am lambda thread\n"); });

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

    return 0;
}
  • join 은, 해당하는 쓰레드들이 실행을 종료하면 리턴하는 함수 입니다.
    따라서 
    t1.join() 의 경우 t1 이 종료하기 전 까지 리턴하지 않습니다.
  • detach 는 말 해당 쓰레드를 실행 시킨 후, 잊어버리는 것 이라 생각하시면 됩니다.
    대신 쓰레드는 알아서 백그라운드에서 돌아가게 됩니다.
     
    • C++ 표준에 따르면, join 되거나 detach 되지 않는 쓰레드들의 소멸자가 호출된다면 예외를 발생시키도록 명시되어 있습니다.

+ std::cout, printf

std::cout << "쓰레드 " << hex << this_id << " 에서 " << dec << *start << " 부터 "
     << *(end - 1) << " 까지 계산한 결과 : " << sum << std::endl;
  • std::cout 
    • << 를 실행하는 과정 중간 중간에 계속 실행되는 쓰레드들이 바뀌면서 결과적으로 메세지가 뒤섞여서 나타나게 됩니다.
    • 따라서, std::cout 의 경우 std::cout << A; 이렇게 선언하여, A 의 내용이 출력되는 동안 중간에 다른 쓰레드가 내용을 출력할 수 없게 해야합니다.
      • 그 사이에 컨텍스트 스위치가 되더라도 다른 쓰레드가 내용을 출력할 수 없습니다.
  • printf
    •  "..." 안에 있는 문자열을 출력할 때, 컨텍스트 스위치가 되더라도 다른 쓰레드들이 그 사이에 메세지를 넣지 못하게 막습니다.
  •  

std::mutex, lock


  • 영어의 상호 배제 (mutual exclusion) 라는 단어에서 따온 단어 입니다.
  • m.lock()  뮤텍스 m의 사용권한을 갖는 것이며, 한 번에 한 쓰레드에서만 m 의 사용 권한을 갖습니다.
  • 이때 m 을 소유한 쓰레드가 m.unlock() 을 통해 사용권한을 반환할 수 있습니다.
    • 이때 m.lock()  m.unlock() 사이에 한 쓰레드만이 실행할 수 있는 코드 부분을 임계 영역(critical section) 이라고 부릅니다.
#include <iostream>
#include <mutex>  // mutex 를 사용하기 위해 필요
#include <thread>
#include <vector>

void worker(int& result, std::mutex& m) {
  for (int i = 0; i < 10000; i++) {
    m.lock();
    result += 1;
    m.unlock();
  }
}

int main() {
  int counter = 0;
  std::mutex m;  // 우리의 mutex 객체

  std::vector<std::thread> workers;
  for (int i = 0; i < 4; i++) {
    // std::ref() 는 특정 타입을 참조하는 객체를 만들며, &와 다르게 타입만 같다면 참조대상 교체 가능.
    // 주로 thread의 인자 또는 bind의 인자로 넘겨줄 때 사용함.
    workers.push_back(std::thread(worker, std::ref(counter), std::ref(m)));
  }

  for (int i = 0; i < 4; i++) {
    workers[i].join();
  }

  std::cout << "Counter 최종 값 : " << counter << std::endl;
}
  • 이때 뮤텍스를 취득한 쓰레드가 unlock 을 하지 않는다면? 다른 모든 쓰레드들이 기다리게 되므로 '데드락(deadlock) 상태'가 됩니다.따라서 취득한 뮤텍스는 반드시 반환해야 합니다.
    • 이는 포인터를 반드시 delete 해야하는 것과 유사하며, unique_ptr과 비슷한 해제패턴(lock_guard)으로 처리할 수 있습니다.
  • lock_guard 객체는 뮤텍스를 인자로 받아서 생성하면, lock_guard 가 소멸될 때 알아서 lock 했던 뮤텍스를 unlock 하게 됩니다.
std::mutex g_mutex;
// 락을 걸어야 할 장소
{ // scope 한정으로 락 범위를 좁힌다.
  std::lock_guard<std::mutex> lock(g_mutex);
  // 락이 필요한 연산
}
  • 이 외의 데드락 상황을 피하기 위한 가이드라인
    • 중첩된 Lock 을 사용하는 것을 피해라
    • Lock 을 소유하고 있을 때 유저 코드를 호출하는 것을 피해라
    • Lock 들을 언제나 정해진 순서로 획득해라

+ lock 사용을 위한 표준 클래스

  • std::lock_guard
  • std::unique_lock
  • std::shared_lock

 

생산자(Producer) 와 소비자(Consumer) 패턴


  • 생산자의 경우, 무언가 처리할 일을 받아오는 쓰레드를 의미합니다.
  • 소비자의 경우, 받은 일을 처리하는 쓰레드를 의미합니다.
  • TODO...(복잡쓰...)

 

쓰레드 간 통신


  • conditional_variable, atomic, future 등을 통해 쓰레드 간 통신, 쓰레드 간 리턴값 반환 등을 할 수 있습니다.

참고



반응형