개발 이야기 안하는 개발자

전문가를 위한 C++20 _ 4 (C++ 고급 기능 마스터하기) 본문

Book/전문가를 위한 C++20

전문가를 위한 C++20 _ 4 (C++ 고급 기능 마스터하기)

07e 2024. 3. 24. 15:56
반응형

25. 표준 라이브러리 커스터마이즈 및 확장 방법

 

find_all()

인수로 주어진 프레디케이트를 만족하는 원소를 주어진 범위에서 모두 찾는 알고리즘을 구현한다고 하자.

find()와 find_if() 모두 한 원소만 가리키는 반복자를 리턴한다.

이렇게 새롭게 알고리즘을 제작 및 확장하려면 프로토타입부터 정의해야 하는데, 이때 find_if()에 적용된 모델을 그대로 따르면 제작하기 쉽다. 비슷한 기능을 참고하면 된다.

template <typename InputIterator, typename OutputIterator, typename Predicate>
OutputIterator find_all(InputIterator first, InputIterator last,
	OutputIterator dest, Predicate pred)
{
	while (first != last) {
		if (pred(*first)) {
			*dest = first;
			++dest;
		}
		++first;
	}
	return dest;
}

이후 이를 실행하는 코드를 제작한다.

	vector vec{ 3, 4, 5, 4, 5, 6, 5, 8 };
	vector<vector<int>::iterator> matches;

	find_all(begin(vec), end(vec), back_inserter(matches), [](int i){ return i == 5; });

	cout << format("Found {} matching elements: ", matches.size()) << endl;

	for (const auto& it : matches) {
		cout << format("{} at position {}", *it, distance(begin(vec), it)) << endl;
	}
	cout << endl;	
    
출력:
Found 3 matching elements:
5 at position 2
5 at position 4
5 at position 6

find_all에 사용된 Predicate는 결국 람다 표현식을 복사해서 가져가기 때문에 값을 반환할때 매번 새롭게 하는 방식이 아니다. 진행이후에 이어서 재시작하기 때문에 back_inserter가 순차적으로 호출되기 때문에 matches에 쌓이는 방식으로 진행된다.

 

 



 

 

 

26. 고급 템플릿

 

템플릿을 사용할때 default값을 취할 수 있다.

또, Contatier를 사용하려고 할땐 ContainerType으로 조건을 달 수 있다.

template <typename T, ContainerType Container = std::vector<std::optional<T>>>
class Grid
{
	...
}

	
	...
	Grid<int, deque<optional<int>>> myDequeGrid;
	Grid<int, vector<optional<int>>> myVectorGrid;
	Grid<int> myVectorGrid2{ myVectorGrid };

 

템플릿 템플릿

템플릿 내에 템플릿을 하나 더 만드는 형식으로 생성자에서 생성할때 더 간단하고 가독성이 오른다.

template <typename T,
	template <typename E, typename Allocator = std::allocator<E>> class Container = std::vector>
class Grid
{
	...
}


	...
	Grid<int, vector> myGrid;
	myGrid.at(1, 2) = 3;
	cout << myGrid.at(1, 2).value_or(0) << endl;

	Grid<int, vector> myGrid2{ myGrid };

	Grid<int, deque> myDequeGrid;

 

템플릿에 영초기화 문법을 사용할 수 있다.

이렇게 하면 T의 초기값으로 초기화가 가능하다.

template <typename T, const T DEFAULT = T{}>

...

	Grid<int> myIntGrid;		// Initial value is 0
	Grid<int, 10> myIntGrid2;	// Initial value is 10

 

 



 

 

27. C++ 멀티스레드 프로그래밍

 

테어링 

두 스레드가 동시에 쓰고 읽을때 서로 다른 값을 보게 되는 경우 수행 결과가 달라진다.

 

스레드

기본적으로 C++에선 thread라는 클래스로 가변 인수 템플릿 생성자로 생성한다.

사용할 함수와 인수를 포함시키고 join으로 호출한다.

void counter(int id, int numIterations)
{
	...
}

int main()
{
	thread t1{ counter, 1, 6 };
	t1.join();
}

 

클래스를 제작하고 operator()를 활용해서 함수 객체로 만든다음 thread에 넣을 수 있다.

class Counter
{
public:
	Counter(int id, int numIterations)
		: m_id{ id }, m_numIterations{ numIterations }
	{
	}

	void operator()() const
	{
    	...
	}

	...
};

int main()
{
	thread t1{ Counter{ 1, 20 } };

	Counter c{ 2, 12 };
	thread t2{ c };

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

 

람다로도 표현할 수 있다.

	int id{ 1 };
	int numIterations{ 5 };
	thread t1{ [id, numIterations] {
    	...
	} };
	t1.join();

 

멤버 함수로 스레드를 만들 수 있다.

class Request
{
public:
	Request(int id) : m_id{ id } { }

	void process()
	{
    	...
	}
    ...
};

int main()
{
	Request req{ 100 };
	thread t{ &Request::process, &req };

	t.join();
}

 

C++에선 스레드 로컬 저장소란 개념을 제공한다. thread_local이란 키워드로 스레드 로컬 저장소로 제공하면 각 스레드마다 이 변수를 복제해서 없어질 때 까지 유지한다.

thread_local이 붙은 변수는 각 스레드마다 복제해서 가져간다.

int k;
thread_local int n;

void threadFunction(int id)
{
	cout << format("Thread {}: k={}, n={}\n", id, k, n);
	++n;
	++k;
}

int main()
{
	thread t1{ threadFunction, 1 };
	t1.join();

	thread t2{ threadFunction, 2 };
	t2.join();
}

//결과
//k = 0 , n = 0
//k = 1 , n = 0

 

 

스레드 취소하기

C++에선 취소하는 기능을 제공하지 않는다.

C++20 부터 제공하는 jthread 클래스를 사용하면 가능하다.

	jthread job{ [](stop_token token) {
		while (!token.stop_requested()) {
			//...
		}
	} };

	job.request_stop();

 

atomic 타입

아토믹 타입은 동기화 기법을 족용하지 않고 읽기와 쓰기를 동시에 처리하는 아토믹 접근 기능이 가능하다.

이로 인해서 변수값을 증가시킬때 위험했던 스레드 접근이 아토믹 접근으로 안전하게 사용할 수 있다.

컴파일러는 먼저 메모리에서 이 값을 읽고, 레지스터로 불러와서 값을 증가시킨 다음, 그 결과를 메모리에 저장한다.

이 과정에서 스레드가 등장하면 데이터 경쟁이 발생할 수 있는데, 아토믹은 명시적으로 동기화 기법을 사용하지 않고도 스레드에 안전하게 만들 수 있다.

std::atomic<int> g_sum = 0; // atomic객체 사용.
 
void Add()
{
    for (int inc = 0; inc < 1000000; ++inc)
        g_sum++;
}
void Sub()
{
    for (int inc = 0; inc < 1000000; ++inc)
        g_sum--;
}
 
 
int main()
{
    std::thread t1(Add);
    std::thread t2(Sub);
 
    t1.join();
    t2.join();
    std::cout << g_sum;
    
    return 0;
}

//결과 
//0

 

만약 아토믹을 사용하지 않았다면 결과값이 엄청 높거나 엄청 낮았을 가능성이 있다.

t1 스레드가 데이터를 읽어와서 t2에서 나온 결과를 덮을 수 있기 때문이다.

아토믹을 사용하면 연산 도중 다른 스레드가 끼어들 여지를 없앨 수 있기 때문에 안전하다.

 

Lock-free

cpu가 제공하는 명령사용으로 OS의 lock이 필요가 없다는 것을 뜻한다.

 

뮤텍스

멀티 스레드 프로그램에서 스레드끼리 데이터를 아예 공유하지 않게 만들수 있다.

뮤텍스가 호출하는 lock()은 결국 해당 뮤텍스의 소유권(사용 권한)을 갖는 것이다.

 

스핀락

스레드가 락을 얻기 위해 바쁘게 루프를 도는데 사용하는 뮤텍스의 일종으로서 주어진 작업을 수행한 후 락을 해제한다.

atomic_flag spinlock = ATOMIC_FLAG_INIT;

...

	while (spinlock.test_and_set()) {}
	...
	spinlock.clear();

ATOMIC_FLAG_INIT 은 atomic_flag가 항상 lock-free임을 보장한다는 내용이다.

test_and_set()은

while(use_flag);

use_flag = true; 를 하나의 메소드로 표현한 것이다.

set 되어 있지 않으면 플래그를 set하고 사용하고, 아니라면 clear 될때까지 무한 루프를 돈다.

즉, 다른 스레드가 사용중이라면 대기한다.

 

뮤텍스에는 시간 제약이 있는 버전이 있고 없는 버전이 있다.

 

lock

뮤텍스에 락을 정확히 걸거나 해제하는 작업을 쉽게 처리해준다.

try_lock을 통해 락을 시도하고 실패하면 false로 리턴한다.

 

- lock_guard

객체 생성시에 lock이 되며, 객체가 소멸시에 자동으로 unlock이 되는 특성이 있다.

 

- unique_lock

락을 선언하고 한참 뒤 실행될 때 락을 걸도록 지연시키는 고급기능을 제공한다.

owns_lock()을 통해 제대로 걸렸는지 확인이 가능하다.

mutex mut1;
mutex mut2;

void process()
{
	unique_lock lock1{ mut1, defer_lock };
	unique_lock lock2{ mut2, defer_lock };
	//unique_lock<mutex> lock1{ mut1, defer_lock };	//같은 내용
	//unique_lock<mutex> lock2{ mut2, defer_lock };
	lock(lock1, lock2);

} // Lock 자동 해지

int main()
{
	process();
}

defer_lock은 lock에 걸리지 않고 lock이 가능한 구조만 생성된다. 이후, lock()을 호출하면 잠긴다.

try_to_lock_t는 뮤텍스에 대한 레퍼런스가 가리키는 뮤텍스에 대해 락을 걸려고 시도한다. 실패할 경우에는 블록하지 않고 나중에 시도한다.

만약 설정이 없다면 자동으로 lock이 걸린다.

 

- shared_lock

유니크락과 똑같은 타입의 생성자와 메서드를 제공한다. 내부 공유 뮤텍스에 대해 공유 소유권에 관련된 메서드를 호출하는 점은 다르다. 

 

- scoped_lock

동시에 여러 뮤텍스를 묶을 수 있다.

mutex mut1;
mutex mut2;

void process()
{
	scoped_lock locks{ mut1, mut2 };
	//scoped_lock<mutex, mutex> locks{ mut1, mut2 };

} // Locks automatically released.

 

std::call_once

여러 스레드가 호출해도 한번만 호출하도록 막을 수 있다.

once_flag g_onceFlag;

void initializeSharedResources()
{
	cout << "Shared resources initialized." << endl;
}

void processingFunction()
{
	call_once(g_onceFlag, initializeSharedResources);

	cout << "Processing" << endl;
}

int main()
{
	// Launch 3 threads.
	vector<thread> threads{ 3 };
	for (auto& t : threads) {
		t = thread{ processingFunction };
	}

	// Join on all threads
	for (auto& t : threads) {
		t.join();
	}
}

//결과
shared resources initialized
processing
processing
processing

 

 

래치

다인 사용 스레드 조율 포인트.

주어진 수만큼의 스레드가 래치 포인트에 블록되고, 래치 포인트에 도달하면 모든 스레드에 대한 블록이 해제되면서 실행이 이어나간다.

스레드가 래치 포인트에 도달할 때마다 숫자를 감소시키는 카운터와 같다.

 

배리어

일련의 상태로 구성된 재사용 가능한 스레드 조율 메커니즘이다.

베리어 포인트에 블론된 스레드들이 배리어에 도달한 스레드 수가 지정한 수를 넘어서면 상태 완료 콜백이 실행되면서 블록된 상태의 스레드를 모두 해제하고, 스레드 카운터를 리셋한 뒤 다음 상태를 시작한다.

 

세마포어

세마포어는 슬롯 개수를 표현하는 카운터와 같으며, 생성자에서 초기화 된다. 슬롯을 하나 얻으면 카운터가 감소하고, 슬롯을 반납하면 카운터가 증가한다.

 

퓨처

스레드스를 실행하고 종료된 후 최종 결과를 쉽게 받아오고 익셉션을 다른 스레드로 전달해서 원하는 방식으로 처리할 수 있게 도와주는 기능.

프로미스는 스레드의 실행 결과를 저장하는 곳이며, 퓨처는 프로미스에 저장된 경로에 접근하기 위해 사용된다.

익셉션도 프로미스에 저장된다.

void doWork(promise<int> thePromise)
{
	thePromise.set_value(42);
}

int main()
{
	promise<int> myPromise;
	auto theFuture{ myPromise.get_future() };
	thread theThread{ doWork, move(myPromise) };

	// Do some more work...

	theThread.join();
    
	//myFuture.wait_for(0) 이 true면 계산이 끝남. false면 계산이 안끝남.
    
	int result{ theFuture.get() };
	cout << "Result: " << result << endl;

}

 

packaged_task

퓨처와 프로미스의 기능을 쉽게 사용하기 위해 제작된 기능.

int calculateSum(int a, int b)
{
	return a + b;
}

int main()
{
	packaged_task<int(int, int)> task{ calculateSum };
	auto theFuture{ task.get_future() };
	thread theThread{ move(task), 39, 3 };
	
	// Do some more work...

	theThread.join();
    
	int result{ theFuture.get() };
	cout << result << endl;

}

 

std::async

계산작업을 수행하는 스레드의 생성 여부를 런타임으로 좀 더 제어하고싶다면 async를 사용한다.

int calculate()
{
	return 123;
}

int main()
{
	auto myFuture{ async(calculate) };
	//auto myFuture{ async(launch::async, calculate) };
	//auto myFuture{ async(launch::deferred, calculate) };

	// Do some more work...

	// Get the result.
	int result{ myFuture.get() };
	cout << result << endl;
}

 

스레드 풀

스레드를 필요할때마다 생성,삭제하는 것이 아닌 필요한 수만큼 미리 제작하고 관리하는 스레드 풀을 제작할 수 있다.

스레드 수가 코어 수보다 많으면 스레드가 실행되는 동안 기다려야 하는 스레드가 생겨서 오버헤드가 증가할 수 있다.

반응형