C++에서는 ()를 붙여서 호출할 수 있는 모든 것을 Callable이라고 정의한다.

 

Callable 예시

#include <iostream>

struct S {
  void operator()(int a, int b) { std::cout << "a + b = " << a + b << std::endl; }
};

int main() {
  S some_obj;
  some_obj(3, 5); // some_obj는 함수가 아닌 객체 // callable
  
  auto f = [](int a, int b) { std::cout << "a + b = " << a + b << std::endl; };
  f(3, 5); // callable
  
}

 

std::function

Callable들을 객체의 형태로 보관할 수 있는 클래스 std::function

#include <functional>
#include <iostream>
#include <string>

int some_func1(const std::string& a) {
  std::cout << "Func1 호출! " << a << std::endl;
  return 0;
}

struct S {
  void operator()(char c) { std::cout << "Func2 호출! " << c << std::endl; }
};

int main() {
  std::function<int(const std::string&)> f1 = some_func1; // 일반 함수
  std::function<void(char)> f2 = S(); // Functor
  std::function<void()> f3 = []() { std::cout << "Func3 호출! " << std::endl; }; // 람다 함수

  f1("hello");
  f2('c');
  f3();
}

function 객체는 템플릿 인자로 전달 받을 함수의 타입 (리턴 값과 인자)를 받는다.

 

멤버 함수를 가지는 std::function

#include <functional>
#include <iostream>
#include <string>

class A {
    int c;

public:
    A(int c) : c(c) {}
    int some_func(char ch) {
        std::cout << "비상수 함수: " << ++c << std::endl;
        return c;
    }

    int some_const_function() const {
        std::cout << "상수 함수: " << c << std::endl;
        return c;
    }

    static void st() {}
};

int main() {
    A a(5);
    std::function<int(A&, char)> f1 = &A::some_func;
    std::function<int(const A&)> f2 = &A::some_const_function;

    f1(a,'c');
    f2(a);
}

멤버 함수를 function 객체에 담기 위해서는 함수의 원래 인자 앞에 객체를 받는 인자를 추가하면 된다.

멤버함수가 아닌 함수들의 경우 함수의 이름이 함수의 주소값으로 암시적 변환이 일어나지만, 멤버 함수의 주소는 &연산자를 통해 명시적으로 주소값을 전달해줘야 한다.

 

std::bind

원래 함수에 특정 인자를 지정할 수 있다. 첫 번째 인자에 특정한 값을 고정시킨 함수 객체를 만들거나 인자의 순서를 바꾼 함수 객체를 만들 수도 있다.

 

레퍼런스를 인자로 받는 함수에 레퍼런스를 전달하고 싶으면 인자를 std::ref()로 감싸 전달하면 된다.

 

참조:

https://modoocode.com/254

 

RAII (Resource Acquisition is initialization) (자원 획득은 초기화와 같다)

자원의 안전한 사용을 위해 객체가 쓰이는 스코프를 벗어나면 자원을 해제해주는 패턴

heap에 할당된 자원은 명시적으로 delete 해주지 않으면 해제되지 않지만 stack에 할당된 자원은 범위를 벗어나면 메모리가 해제되며 소멸자가 불리는 원리를 이용한 것이다.

 

스마트 포인터(포인터 객체)의 종류 

1. unique_ptr 

C++에서 메모리를 잘못된 방식으로 관리했을 때 생기는 두 가지 문제 

 

1. 메모리 할당 후 해제하지 않아 메모리 누수가 생기는 경우

ㄴ RAII 패턴으로 해결 가능

 

2. 이미 해제한 메모리를 참조하는 경우

ㄴ 여러 개의 포인터가 같은 객체를 두번 해제하는 경우가 발생할 수 있다.

ㄴ 이미 해제한 객체를 참조할 가능성도 있다.

ㄴ unique_ptr로 해결 가능

 

// 함수 내에 정의(스택에 정의)된 객체이기 때문에 함수가 종료될 때 소멸자가 호출되고,
// 소멸자 안에서 자신이 가리키고 있는 자원 (A)를 해제해 준다.
std::unique_ptr<A> pa(new A());

// unique_ptr은 -> 연산자를 오버로드해서 포인터를 다루는 것처럼 함수를 호출할 수 있다.
pa->some();

 

unique_ptr 객체는 복사할 수 없지만, 소유권을 이전할 수 있다.

 

ㄴ unique_ptr의 복사 생성자는 delete 키워드로 명시(사용 금지) 하였기 때문에 하나의 자원(객체)를 여러 unique_ptr이 소유하지 않도록 한다.

 

std::unique_ptr<A> pb = std::move(pa);

ㄴ unique_ptr의 이동 생성자는 정의되어 있다. (자원의 주소를 단순 복사하는 형태로?)

ㄴ 이 문장이 실행된 이후 pa는 댕글링 포인터 (해제된 메모리 영역을 가리키는 포인터)가 되므로 참조해서는 안된다.

 

unique_ptr을 함수 인자로 전달하기

unique_ptr의 레퍼런스를 전달하는 경우

ㄴ unique_ptr의 의미가 퇴색된다. (컴파일 오류는 없으나 함수 안에서는 두 개의 이름(스마트 포인터)로 자원을 가리키기 때문)

 

대신에 자원의 포인터 주소값을 전달하자

ㄴ unique_ptr의 get() 함수는 자원의 포인터 주소를 반환하는데 이것을 함수에 전달하면 된다.

 

unique_ptr을 make_unique로 생성하기

C++14 부터는 make_unique를 사용하여 unique_ptr을 만들 수 있다.

// 기존 방법
std::unique_ptr<Foo> ptr(new Foo(3, 5));

// make_unique를 사용하는 방법
auto ptr = std::make_unique<Foo>(3, 5);

 

unique_ptr를 원소로 가지는 컨테이너

std::vector<std::unique_ptr<A>> vec;
std::unique_ptr<A> pa(new A(1));


// vector의 push_back에 pa를 넘겨주면 좌측값 레퍼런스로 인식하여 복사 생성자를 호출하기 때문에
// 컴파일 에러가 난다.
vec.push_back(pa); 

// 우측값 레퍼런스를 받도록 오버로딩된 push_back을 호출하기 위해 move()를 사용한다.
vec.push_back(std::move(pa));

// emplace_back을 사용하면 벡터 안에 unique_ptr을 생성과 동시에 집어넣을 수 있다.
// vec.push_back(std::unique_ptr<A>(new A(1))); 과 동일
vec.emplace_back(new A(1));

 

2. shared_ptr

ㄴ 하나의 자원을 여러개의 객체에서 사용하는 상황에서 자원을 해제 하기 위해서는 모든 객체를 소멸시키고 자원을 해제해야 한다. (= 여러 개의 스마트포인터 객체가 같은 객체를 가리킬 수 있다)

std::shared_ptr<A> p1(new A());
std::shared_ptr<A> p2(p1);  // p2 역시 생성된 객체 A 를 가리킨다.

p1.use_count(); // 2
p2.use_count(); // 2

 

스마트 포인터의 참조 개수가 몇개 인지 어떻게 확인하는가?

ㄴ shared_ptr은 복사 생성될 때 제어 블록의 위치(참조 개수를 알고 있는 블록)을 공유한다.

 

첫 shared_ptr을 만들 때는 자원의 동적할당과 제어 블록의 동적할당이 필요하다.

 

shared_ptr을 make_shared로 생성하기

make_shared 함수는 자원과 제어 블록의 동적할당을 따로 두 번 하지 않고 한 번의 동적할당으로 처리한다.

std::shared_ptr<A> p1 = std::make_shared<A>();

 

shared_ptr 생성 시 주의 할 점

 

shared_ptr을 자원의 주소 값으로 생성하면 무조건 제어 블록을 생성한다. (첫 번째로 생성된 shared_ptr의 경우는 괜찮지만 두 번째 이상 부터는 두 개 이상의 제어 블록이 생기는 결과를 낳는다.) 첫 번째와 두 번째로 생성된 shared_ptr들 모두 제어 블록을 따로 가지며, 각자 자원에 대한 참조 개수가 1로 알고 있기 때문에 둘 중 하나의 shared_ptr이 소멸될 때 자원도 같이 소멸된다. (참조 개수가 0이 되면 자원도 소멸시키므로)

 

enable_shared_from_this

this를 사용해서 shared_ptr을 만들고 싶은 클래스가 있다면 std::enabled_shared_from_this를 상속받으면 된다.

class A : public std::enable_shared_from_this<A> {
  
  // 이미 정의되어있는 제어 블록을 사용해서 shared_ptr을 생성한다.
  // 즉 이미 이 객체에 대한 shared_ptr가 만들어져 있어야 한다.
  
  std::shared_ptr<A> get_shared_ptr() { return shared_from_this(); }
}

 

shared_ptr의 순환참조 문제와 3. weak_ptr

 

shared_ptr을 멤버로 갖는 객체를 서로 참조하고 있는 상태 : 순환 참조 상태

 

class A {
  std::string s;
  std::weak_ptr<A> other; // std::shared_ptr<A> other 순환 참조를 일으키는 shared_ptr 대신 사용
}

week_ptr은 이미 만들어진 shared_ptr나 week_ptr로부터만 생성될 수 있다. (생성자로 받는다.)

week_ptr은 참조 개수를 늘리지는 않는다.

week_ptr로 객체를 참조하기 위해서는 lock()함수를 사용하여 shared_ptr로 변환해야 한다.

week_ptr이 가리키고 있던 객체가 소멸되었다면 (shared_ptr이 가리키지 않아서) lock()은 빈 shared_ptr을 반환하고

가리키고 있던 객체가 아직 shared_ptr에 의해 가리켜지고 있다면 객체를 가리키는 shared_ptr을 반환한다.

 

객체는 해제되어도 제어 블록은 해제되면 안되는 경우

ㄴ 객체를 가리키는 shared_ptr은 0개이지만, week_ptr이 남아 있는경우

ㄴ 제어 블록을 해제하기 위해서는 week_ptr도 0개여야 한다.

 

제어 블록에는 참조 개수와 더불어 약한 참조 개수도 기록되어 있다.

 

참조:

https://modoocode.com/252

 

동적 바인딩 (virtual 키워드를 사용할 경우 vtable을 통해 구현됨)

ㄴ 런타임에 어떤 함수가 호출될지를 정함

 

정적 바인딩 (virtual 키워드 쓰지 않은 함수)

ㄴ 컴파일 타임에 어떤 함수가 호출될지를 정함

 

override 키워드

C++11 에서부터 파생 클래스에서 기반 클래스의 가상 함수를 오버라이드 하는 경우 override 키워드를 통해 명시적으로 나타낼 수 있음

 // 기반 클래스
 virtual void what() { std::cout << s << std::endl; }
 
 // 파생 클래스
 void what() override { std::cout << s << std::endl; }

 

virtual 소멸자

// 부모 클래스의 소멸자에 virtual을 붙이지 않는 경우

// parent 생성자 - child 생성자 - child 소멸자 - parent 소멸자 순으로 정상적으로 호출된다.
Child *c = new Child();
delete c;

// parent 생성자 - child 생성자 - parent 소멸자 순으로 호출된다.
// 자식 클래스의 소멸자가 호출되지 않는다.
Parent *p = new Child();
delete p;

소멸자가 호출되지 않는 문제는 Child 객체의 생성자에서 멤버 변수들을 위해 메모리를 동적 할당했을 경우 메모리 누수로 이어질 수 있다.

 

가상 함수의 구현 원리 (vtable), 가상 함수의 오버헤드

가상 함수가 하나라도 존재하는 클래스의 객체의 가장 윗 부분에는 vtable의 주소값이 저장되어 있다. (x64 에서는 8byte)

포인터가 아닌 실제로 가리키고 있는 객체(클래스)의 vtable을 참조한다. 

자식 클래스의 vtable은 부모 클래스의 vtable값이 그대로 복사되며, 오버라이딩 된 함수만 주소가 새로 업데이트된다.

vtable에는 virtual로 선언된 가상 함수만 저장된다.

vtable은 클래스 단위로 만들어진다. (만들어지는 객체의 개수와 관련이 없다)

 

일반 함수를 호출하는 경우 함수는 바로 호출되지만

가상 함수를 호출하는 경우 가상 함수 테이블을 한번 거쳐서 함수를 호출하기 때문에 시간이 더 오래 걸린다. (오버헤드)

 

순수 가상함수와 추상 클래스

순수 가상함수를 한 개 이상 가진 클래스를 추상 클래스라고 한다.

class Animal {
 public:
  Animal() {}
  virtual ~Animal() {}
  // 순수 가상 함수 // 반드시 오버라이딩 되어야 하는 함수
  virtual void speak() = 0;
};

 

참조:

https://modoocode.com/210

'C++' 카테고리의 다른 글

C++ Callable  (0) 2022.08.26
C++ RAII와 스마트포인터  (0) 2022.08.26
C++ 템플릿, 함수 객체(Functor)  (0) 2022.08.25
C++ r-value 참조와 move, 이동 생성자  (0) 2022.08.25
C++ 문자열 처리  (0) 2022.08.24

 

클래스 템플릿 정의

클래스 정의 위에 

template <typename T>

template <class T>

둘 중 하나를 붙여 템플릿을 정의한다.

 

템플릿은 인스턴스화 되기 전까지는 컴파일 시에 아무런 코드로 변환되지 않는다.

= 템플릿은 컴파일 시에 인스턴스화 된다.

 

템플릿 특수화

template <typename T>
class Vector {
  T* data;
  int capacity;
  int length;
}

// 템플릿 특수화 bool을 템플릿 인자로 전달하는 경우
template <>
class Vector <bool>{
  unsigned int* data; // bool* data로 쓰게 되면 원소 하나당 1byte씩 쓰기 때문에 1bit로 표현하기 위함이다.
  int capacity;
  int length;
}

 

함수 템플릿

template <typename T>
T max(T& a, T& b) {
  return a > b ? a : b;
}

int main(void){
  int a = 3;
  int b = 5;
  cout << max(a, b); // C++ 컴파일러가 max<int>(a, b)로 인식하여 처리해줌
}

 

 

함수 객체(Functor)

ㄴ 함수는 아니지만 operator()를 내부에 구현하여 함수인척 하는 객체

ㄴ sort 함수에 템플릿 인자로 넣어 오름차순, 내림차순을 설정할때 사용하기도 한다.

 

타입이 아닌 템플릿 인자

template <typename T, int num>
T add_num(T t) {
  return t + num;
}

템플릿 인자로 전달할 수 있는 타입들은 제한적이다.

ㄴ 정수 타입, (float, double) 제외

ㄴ 포인터 타입

ㄴ enum 타입

ㄴ 널포인터 (std::nullptr_t)

 

예시) 함수를 배열을 전달할 때 배열의 크기를 잃어버린다는 문제가 있었는데, C++11의 std::array를 사용하여 배열을 생성할 경우 템플릿 인자로 배열의 크기를 명시할 수 있게 되었다.

std::array<int, 5> arr = {1, 2, 3, 4, 5};

// arr.size()로 배열 크기에 대한 정보 획득 가능

 

디폴트 템플릿 인자

template <typename T>
struct Compare {
  bool operator()(const T& a, const T& b) const { return a < b; }
};

template <typename T, typename Comp = Compare<T>>
T Min(T a, T b) {
  Comp comp;
  if (comp(a, b)) {
    return a;
  }
  return b;
}

// Min함수 템플릿은 피연산자로 정수가 오든 문자열이 오든 처리가 가능하다

 

참조:

https://modoocode.com/219

 

l-value는 어떠한 메모리 위치를 가리키는데, & 연산자를 통해 그 위치를 참조할 수 있다.

r-value는 l-value가 아닌 값들, 대입 시 오른쪽에만 올 수 있는 value들이다. (임시 객체, 함수가 반환하는 객체, move()가 반환하는 값 등)

 

표현식 이후에 사라지면 r-value 사라지지 않으면 l-value

 

l-value reference 는 &하나로 정의

 

1. void foo(X&); 만 구현했을 경우

ㄴ foo는 좌측값만 인자로 받을 수 있다.

 

2. void foo(X const&);만 구현했을 경우

ㄴ foo는 좌측 값과 우측 값을 인자로 받을 수는 있지만 우측값일 때를 구별할 수가 없음

 

3.void foo(X &) or void foo(X const &); 와

   void foo(X &&); (우측 값만 받음) 모두 구현해야 구분할 수 있다.

 

우측값 참조라 선언되었더라도 이름이 있다면 좌측값 없다면 우측값이다.

예시 1.

void foo(X&& x) {
  X anotherX = x;  // x가 우측값 레퍼런스로 정의되었고 이름이 있기 때문에 좌측값이다. X(X const & rhs) 가 호출됨
}

 

예시 2.

X&& goo();
X x = goo();  // 이름이 없으므로 우측값. 즉 X(X&& rhs) 가 호출됨

 

std::move()

ㄴ 레퍼런스를 인자로 받고, 이것을 우측값 참조로 리턴하는 함수 (이름을 없애는 함수? 우측 값으로 바꿔주는 함수? 임시 객체를 리턴하는 함수?)

 

이동 생성자

MyString::MyString(MyString &&str) {
  std::cout << "이동 생성자 호출 !" << std::endl;
  string_length = str.string_length;
  string_content = str.string_content;
  memory_capacity = str.memory_capacity;

  // 임시 객체 소멸 시에 메모리를 해제하지
  // 못하게 한다.
  str.string_content = nullptr;
}


MyString::~MyString() {
  // 임시 객체의 string_content가 nullptr(0)이면 string_content를 해제하지 않는다.
  if (string_content) delete[] string_content; 
}

MyString str3 = str1 + str2; // operator+함수로 리턴된 임시 객체는 우측 값이다.

 

객체를 STL 컨테이너에 넣을 때, 이동 생성자 작성 시 주의할 점

STL 컨테이너들은 이동 생성자에서 예외가 발생했을 때 처리할 수 없기 때문에 이동 생성자를 noexcept로 명시하지 않으면 이동 생성자 대신 복사 생성자를 사용한다.

 

참조:

https://modoocode.com/227

 

string 클래스

string 생성

string str; string

str = "abcdef";

string str("abcedf");

string *str = new string("abcdef");

string str1(str2);

string 연산

string 객체 간에 >, <, ==, + 연산자를 사용할 수 있다.

>, < 는 사전 순 비교, ==는 동일 여부 확인

+는 문자열 연결

 

string 클래스의 멤버 함수

str.at( index ) : index번째 문자 반환 (유효한 범위인지 체크)

str[ index ] : index번째 문자 반환 (유효한 범위인지 체크X, at보다 빠름)

str.front() : 가장 앞 문자 반환

str.back() : 가장 뒤 문자 반환

str.c_str() : string 문자열을 const char* 로 접근할 수 있도록 해줌 ('\0' 포함)

 

str.length() : 문자열 길이 반환

str.size() : 문자열 길이 반환

str.capacity() : 문자열이 사용중인 메모리 크기 반환

str.resize(n) : string의 길이(용량 X)를 n으로 바꿈

str.reserve(n) : n만큼의 메모리를 미리 할당해줌

str.shrink_to_fit() : capacity를 문자열 길이에 맞게 조절해줌 (정확하게 길이와 일치하지는 않음)

 

str.append(str2) : 문자열을 이어 붙임 ('+' 연산과 동일)

str.insert(n, str2) : n번째 index 앞에 str2 문자열을 삽입

str.repleace(n, k, str2) : n번째 index부터 k개의 문자를 str2로 대체함

str.clear() : 문자열 지우기 (str이 size가 0인 문자열이 됨)

str.erase(n, m) : n ~ m index의 문자열을 지움

 

str.compare(str1) : str이 str1보다 더 크면 1을 반환, 같으면 0, 작으면 -1을 반환 ( 크기 비교는 숫자 < 대문자 < 소문자 )

str.find(str1) ,str.find (c) : 문자열 내에서 문자열이나 문자를 검색해 index를 반환 ( 없으면 -1을 반환)

 

참조 : 

https://rebro.kr/53

'C++' 카테고리의 다른 글

C++ 템플릿, 함수 객체(Functor)  (0) 2022.08.25
C++ r-value 참조와 move, 이동 생성자  (0) 2022.08.25
C++ memory order, atomic객체  (0) 2022.08.24
C++ thread, mutex, condition_variable  (0) 2022.08.23
C++ STL 컨테이너 정리  (0) 2022.08.22

 

atomic

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

여러 쓰레드에서 atomic 객체 자원을 수정할 때 원자적 연산(CPU가 명령어 한개로 처리하는 명령)을 사용하면 mutex를 사용하지 않아도 정상적인 결과가 나온다.

 

 

memory order

멀티 쓰레드 환경에서 컴파일러와 CPU가 명령어 순서를 재배치하여 환경에 따라 결과가 달라지는 문제를 막기 위해 메모리 재배치 순서를 강제할 수 있다.

 

memory_order_relaxed (자유)

// atomic<int> 객체 포인터 a, b, c에 대해

// memory_order_relaxed
// 스레드 내부에서 두 대입 명령들의 순서는 바뀔 수 있다. (CPU가 마음대로 재배치 가능하다.)
int x = a->load(memory_order_relaxed);  // x = a (읽기, 원자적 연산)
b->store(1, memory_order_relaxed);      // b = 1 (쓰기, 원자적 연산)

c->fetch_add(2, memory_order_relaxed); // c = c + 2 

c->is_lock_free() // c 객체의 연산들이 원자적으로 구현될 수 있는지 확인
// true인 경우 mutex의 lock unlock 없이도 연산을 올바르게 수행할 수 있다는 뜻

 

memory_order_acquire와 memory_order_release

// 두 쓰레드가 공유하는 변수 data의 release와 acquire을 사용하여 동기화를 수행하는 코드

void producer() {
  data[0].store(1, memory_order_relaxed);
  data[1].store(2, memory_order_relaxed);
  data[2].store(3, memory_order_relaxed);
  is_ready.store(true, std::memory_order_release);
}

void consumer() {
  // data 가 준비될 때 까지 기다린다. (스핀 락)
  while (!is_ready.load(std::memory_order_acquire)) {
  }

  std::cout << "data[0] : " << data[0].load(memory_order_relaxed) << std::endl;
  std::cout << "data[1] : " << data[1].load(memory_order_relaxed) << std::endl;
  std::cout << "data[2] : " << data[2].load(memory_order_relaxed) << std::endl;
}

 

memory_order_release는 해당 명령 이전의 모든 메모리 명령(읽기, 쓰기)들이 해당 명령 이후로 재배치 되는 것을 금지한다.

 

memory_order_acquire는 해당 명령 뒤에 오는 모든 메모리 명령들이 해당 명령 위로 재배치 되는 것을 금지한다.

 

memory_order_acq_rel

ㄴ acquire와 release를 모두 수행하는 것

ㄴ 읽기와 쓰기를 모두 수행하는 명령들, 예를 들어 fetch_add와 같은 함수에서 사용될 수 있다.

 

memory_order_seq_cst

ㄴ 메모리 명령의 순차적 일관성을 보장해준다. (모든 쓰레드에서 모든 시점에 동일한 값을 관찰할 수 있게 해준다.)

ㄴ atomic 객체를 사용할 때 memory_order를 지정해주지 않는다면 디폴트로 memory_order_seq_cst가 지정된다.

ㄴ memory_order_seq_cst를 사용하는 메모리 연산들끼리는 모든 쓰레드에서 동일한 연산 순서를 관찰할 수 있도록 보장해준다.

ㄴ 멀티 코어 시스템에서 비싼 연산이다. AMD 계열 CPU는 차이가 크지 않지만 ARM 계열은 동기화 비용이 크다.

 

참조:

https://modoocode.com/271

'C++' 카테고리의 다른 글

C++ r-value 참조와 move, 이동 생성자  (0) 2022.08.25
C++ 문자열 처리  (0) 2022.08.24
C++ thread, mutex, condition_variable  (0) 2022.08.23
C++ STL 컨테이너 정리  (0) 2022.08.22
C++ 언어 환경의 빌드 과정  (0) 2022.07.26

대기시간이 길고 의존관계가 없는 연산들을 병렬적으로 처리하기 위해 쓰레드를 사용한다.

 

C++11 이전에는 표준화된 쓰레드가 없어서 각 플랫폼마다 다른 구현을 사용해야 했지만, C++11부터 표준에 쓰레드가 추가되었다.

 

Thread 생성, join, detach

#include <thread>
#include <iostream>

using namespace std;
using std::thread;

void func1() {
    for (int i = 0; i < 10; i++) {
        cout << "스레드 1 작동중" << endl;
    }
}

void func3() {
 
    // 현재 코드를 실행중인 스레드의 id
    cout << "쓰레드 3 id : " << std::this_thread::get_id() << endl;

    for (int i = 0; i < 10; i++) {
        cout << "스레드 3 작동중" << endl;
    }
}


int main()
{
    // 내 컴퓨터의 논리 프로세서가 몇개인지 출력
    cout << "논리 프로세서의 개수 : " << std::thread::hardware_concurrency() << endl;

    // 현재 코드를 실행중인 스레드의 id
    cout <<  "메인 쓰레드 id : " << std::this_thread::get_id() << endl;

    thread t1(func1);
    thread t2([] {
        for (int i = 0; i < 10; i++) {
            cout << "스레드 2 작동중" << endl;
        }
    });

    thread t3(func3);


    t1.join();
    t2.detach();
    t3.detach();
    cout << "메인 함수 종료" << endl;
}

메인 쓰레드에서 join은 해당 쓰레드가 종료될 때까지 기다리지만, detach는 기다리지 않는다.

 

논리 프로세서의 개수는 작업 관리자에서도 확인할 수 있다.

출력

순간의 OS 환경마다 다른 결과를 출력한다.

 

쓰레드에 인자 전달하기

쓰레드가 실행하는 함수는 리턴 값이 없기 때문에 인자의 포인터를 활용하여 전달해야 한다.

 

void worker(vector<int>::iterator start, vector<int>::iterator end, int* result){
            // 반복자의 시작부터 끝까지의 합을 계산하여 result에 저장하는 코드
}

int main() {
	vector<int> data(10000);

	vector<int> partial_sums(4);
    
    // 각 스레드에서 2500개의 요소를 계산하도록 함
    // 합을 받기 위한 int* 를 인자로 보냄
    for(int i=0; i<4; i++){
    	worker.push_back(thread(worker, data.begin() + i * 2500, data.begin() + (i + 1) * 2500, &partial_sums[i]));
    }
	
}

 

여러 쓰레드에서 같은 메모리 공간(변수)에 접근하는 경우 (mutex lock, unlock, lock_guard)

경쟁 상태(race condition)가 발생한다.

뮤텍스를 사용하여 스레드에서 변수를 사용하기 위해서는 한 번에 한 쓰레드에서만 뮤텍스를 가지고 있도록(lock) 하고, 접근이 끝나면 뮤텍스를 풀어(unlock), 다른 쓰레드가 뮤텍스를 가질 수 있도록 한다.

 

뮤텍스의 lock()과 unlock() 사이에 한 쓰레드만이 유일하게 실행할 수 있는 코드 부분을 임계 영역(critical section)이라고 한다.

 

lock_guard 객체는 뮤텍스를 인자로 받아 생성되는데 생성될 때 뮤텍스의 lock을 실행하고, 범위를 벗어나 소멸되면 뮤텍스의 unlock을 자동으로 호출한다.

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

void worker(int & result, std::mutex& m){
    // case 1 : lock, unlock을 사용한 경우
    for(int i = 0; i< 10000; i++){
        m.lock();
       	/* 임계 구역 시작 */
        result += 1;
        /* 임계 구역 끝 */
        m.unlock();
    }
    
    /* case 2 : lock_guard를 사용한 경우
    for(int i = 0; i< 10000; i++){
        std::lock_guard<std::mutex> lock(m);
        result += 1;
        
        // scope를 빠져 나가면 lock이 소멸되면서 m을 알아서 unlock 한다.
        // 예외가 발생하는 상황에서도 try-catch 구문으로 잘 감싸주면 자동으로 lock을 해제한다.
    }
    */
}

int main() {
    int counter = 0;
    std::mutex m;

    std::vector<std::thread> workers;
    for (int i = 0; i < 4; i++) {
    	// 쓰레드에 레퍼런스를 넘길 때는 래퍼로 감싸야 한다.
        workers.push_back(std::thread(worker, std::ref(counter), std::ref(m)));
    }

    for (int i = 0; i < 4; i++) {
        workers[i].join();
    }
	
    // 40000을 출력한다.
    std::cout << "Counter 최종 값 : " << counter << std::endl;
}

 

생산자-소비자 패턴을 구현할 때 condition_variable을 사용하기 (+ unique_lock)

 

생산자 쓰레드는 공유하는 큐에 작업을 추가한다. (큐는 공유 자원이므로 임계 영역에서 수정)

소비자 쓰레드는 생산자 쓰레드가 생산한 작업을 처리한다. (큐에 작업이 있는 경우에 pop하여 처리한다.)

 

이때 condition_variable을 사용하지 않고 구현한다고 하면 소비자 쓰레드가 큐가 비었는지를 지속적으로 확인 해야 한다. (while문, std::this_thread::sleep_for(), 시간 객체 chrono, 큐의 empty() 사용) 이는 CPU 낭비다(스핀락). 지속적으로 mutex를 잠그고 큐를 확인해야 하기 때문이다.

 

소비자 쓰레드들을 특정 조건(큐가 empty가 아닌) 이 일어나기 전까지 재우도록(block) 하는 것이 condition_variable의 역할이다. 

 

생산자-소비자 패턴에서의 작업 진행 순서

1. 생산자 쓰레드에서 큐에 작업을 넣고, cv->notify_one()을 호출하여 cv->wait으로 인해 조건을 만족하지 못해 잠자고 있던 소비자 쓰레드중 하나를 깨운다.

 

1-1 cv->wait 함수는 unique_lock과 (조건자 혹은 람다 함수) (인자를 받지 않고 bool값을 리턴하는)을 인자로 받는다.

 

2. 깨어난 소비자 쓰레드에서는 cv->wait의 조건을 통과한다.(lock을 얻는다) 소비자 쓰레드는 큐에서 작업을 pop하고, unique_lock을 unlock() 한다.

 

3. 소비자 쓰레드는 작업을 처리한다.

 

lock_guard 대신 unique_lock을 사용하는 이유

ㄴ lock_guard는 생성자, 소멸자를 통해서만 mutex를 lock, unlock 할 수 있지만 unique_lock은 lock을 얻는 시점을 정할 수 있고 unlock을 직접 호출할 수도 있다.

 

wait 말고 wait_for나 wait_until도 있다.

 

참조:

https://modoocode.com/269

'C++' 카테고리의 다른 글

C++ r-value 참조와 move, 이동 생성자  (0) 2022.08.25
C++ 문자열 처리  (0) 2022.08.24
C++ memory order, atomic객체  (0) 2022.08.24
C++ STL 컨테이너 정리  (0) 2022.08.22
C++ 언어 환경의 빌드 과정  (0) 2022.07.26

+ Recent posts