SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++/Concurrency

[Concurrency] C++20 스레드 조정 메커니즘 std::latch와 std::barrier

보리남편 김 주부 2024. 1. 9. 09:00

C++20부터 latch and barrier 가 필요한 이유?

2023.11.28 - [L C++] - [Concurrency] std::jthread - join 없이 사용하기📌


C++ 20부터 join을 알아서 해주는 jthread가 생겼다. 하지만 기존 thread를 대체하기에는 아직 문제가 있는데 어떤 문제가 있는지 아래 의사코드(pseudocode)에서 확인해 보자.

 

main()
{
    std::thread()
    together_working()
    std::join()
    after_working()
}

: thread와 동시에 together_working이 실행되고, 스레드 종료 이후에 after_working이 실행되었던 것을

 

main()
{
    std::jthread()
    together_working()
    after_working()
}

 jthread로 변경하면 대기 없이 thread와 동시에 togerther_working, after_working 순으로 실행된다. (멈추게 할 수 있는 방법이 없다.) 주 스레드 종료 전 다른 스레드가 종료되는 걸 기다리기 위해 join이 존재하지만 사용하다 보면 이렇듯 (주 목적 외에) 다른 목적으로도 활용되기에 대안이 필요하다.

 

그래서 C++20에서는 jthread와 함께 스레드 조정 메커니즘 Latches과 Barriers를 제공하고 있다.
latch와 barrier는 카운팅 기반의 간단한 동기화 도구이며 하나씩 알아보자

이 도구는 내부에 condition_variable와 mutex로 동작하기에 이 도구가 없어도 condition_variable로 구현은 가능하다.

 

std::latch

since c++20

 

latch를 번역하면 '걸쇠'라는 뜻으로 걸쇠처럼 원하는 조건에서 스레드를 차단할 수 있다.

 

출처: 위키백과
 

File:Öffnen eines Riegels.gif - Wikimedia Commons

No higher resolution available.

commons.wikimedia.org

 

기존 thread 동작을 latch를 이용해서 join처럼(스레드가 끝날 때까지 기다리는 코드) 변경해 보자.

 

예제 1-1
kim, lee, park의 work가 각각의 thread로 시작하고 thread가 종료되면 go home 로그를 찍는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <latch>

void foo(const std::string& name)
{
	std::cout << "start  work : " << name << std::endl;
	std::cout << "finish work : " << name << std::endl;
}
int main()
{
	std::thread t1(foo, "kim"),
				t2(foo, "lee"),
				t3(foo, "park");

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

	std::cout << "go home" << std::endl;
}
결과
start  work : kim
start  work : park
finish work : park

finish work : kim
start  work : lee
finish work : lee
go home

 

이 코드를 latch를 이용하여 수정해 보자.

예제 1-2
latch를 이용하여 예제 1-1과 동일하게 동작하게 하는 프로그램 코드이다.
#include <iostream>
#include <thread>
//1.
#include <latch>

//2.
std::latch complete_latch(3);

void foo(const std::string& name)
{
	std::cout << "start  work : " << name << std::endl;
	std::cout << "finish work : " << name << std::endl;
	//4.	
	complete_latch.count_down();
}
int main()
{
	//std::thread t1(foo, "kim"),
	//			t2(foo, "lee"),
	//			t3(foo, "park");

	std::jthread t1(foo, "kim"), 
	 	         t2(foo, "lee"), 
	 	         t3(foo, "park");

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

	// 3.
	complete_latch.wait();
	std::cout << "go home" << std::endl;
}
결과
start  work : kim
start  work : park
finish work : park

finish work : kim
start  work : lee
finish work : lee
go home

 

foo 함수를 kim, lee, park 각각 스레드로 실행시키고 wait() 함수에서 대기를 하다 count_down() 함수에서 latch 카운터를 '1'씩 차감하고 latch 카운터 수(3) 만큼 호출이 되면(count ==0) 대기가 풀려서 wait() 아래 줄부터 실행이 된다.

코드리뷰

1. #include <latch>
별도의 클래스로 되어 있기에 include를 해야 한다.

2. std::latch complete_latch(3);
숫자를 입력하면 latch 내부적으로 '3'을 보관한다.

3. complete_latch.wait();
join의 역할은 아니지만 정의된 latch 카운트가 '0'이 될 때까지 대기한다.
 
4. complete_latch.count_down();
count_down 함수가 호출될 때마다 latch 카운트를 '1' 씩 차감한다.

 

멀티 스레드 내에서 실행되는 함수의 동작 싱크 시키기

멀티 스레드 내에서 실행되는 함수의 동작 싱크가 필요할 때가 있다. 이 때는 다음과 같이 사용하면 된다.

 

예제 2
3개(kim, lee, park)의 스레드로 실행되는 함수 foo 내부 동작을 싱크 시키기 위한 프로그램 코드이다.
#include <iostream>
#include <latch>
#include <thread>

std::latch sync_point(3);
//std::latch sync_point{ 3 }; //동일한 코드

void foo(std::string name)
{
	std::cout << "start  work : " << name << std::endl;
	std::cout << "finish work : " << name << std::endl;

	sync_point.count_down();
	sync_point.wait();
//	sync_point.arrive_and_wait();

	std::cout << "go home : " << name << std::endl;
}

int main()
{
	std::jthread t1(foo, "kim"), t2(foo, "lee"), t3(foo, "park");

}
결과
start  work : kim
start  work : park
finish work : park
start  work : lee
finish work : lee

finish work : kim
go home : kim
go home : park
go home : lee

 

latch 카운터로 정의한 수(3)에서 각 스레드 별로 count_down()로 '1'을 차감하고 wait() 함수에서 대기를 하다 3번째로 count_down()가 호출될 때 일괄적으로 wait()이 풀려서 go home 로그는 이후에 같이 실행되는 것을 확인할 수 있다.

 

위 코드는 대기카운터 감소 함수가 나눠져 있는데 arrive_and_wait() 함수는 두 가지를 한 번에 하는 함수로 위 코드를 아래와 같이 수정하면 된다.

//as-is	
    sync_point.count_down();
	sync_point.wait();
//	sync_point.arrive_and_wait();

//to-be
//	sync_point.count_down();
//	sync_point.wait();
	sync_point.arrive_and_wait();

 

 

std::barrier

since c++ 20

 

barrier는 여러 번 사용가능한 latch이다.
만약 멀티 스레드 내에서 실행되는 함수의 동작 싱크가 한 번이 아닌 여러 번 해야 한다면 latch는 불가능하고 barrier를 사용해야 한다.

 

그래서 latch일회용 스레드 장벽, barrier재사용 가능한 스레드 장벽이다.

 

예제 3
예제 2처럼 함수 foo 내부 동작을 싱크 시키기 위해 스레드를 멈추게 할 수 있는데 여러 번 싱크를 맞출 수 있게 barrier를 사용한 프로그램 코드이다.
#include <iostream>
#include <mutex>
#include <thread>
//1.
#include <barrier>

// 3.
void arrive_sync_point() noexcept
{
	std::cout << "synchronize!" << std::endl;
}

// 2.
//std::barrier sync_point(3);
std::barrier sync_point(3, arrive_sync_point );

void foo(std::string name)
{
	std::cout << "start  work : " << name << std::endl;
	std::cout << "finish work : " << name << std::endl;

	sync_point.arrive_and_wait();

	std::cout << "have dinner : " << name << std::endl;

	//4.
	// latch에서 arrive_and_wait 하면 대기가 풀리지 않는다.
	sync_point.arrive_and_wait();

	std::cout << "go home : " << name << std::endl;
}

int main()
{
	std::jthread t1(foo, "kim"), t2(foo, "lee"), t3(foo, "park");

}
결과
start  work : kim
start  work : park
finish work : park
start  work : lee
finish work : lee
finish work : kim
synchronize!
have dinner : kim
have dinner : park
have dinner : lee
synchronize!
go home : lee
go home : park
go home : kim

 

synchronize! 로그가 찍혀 있는 곳을 보면 start/finish work -> synchronize, have dinner -> synchronize, go home으로 스레드 별 두 번 싱크가 맞춰진 것을 확인할 수 있다.

 

코드리뷰

1. #include <barrier>
별도의 탬플릿 클래스로 되어 있기에 include를 해야 한다.

2. std::barrier sync_point(3, arrive_sync_point );
barrier가 템플릿 클래스인 이유가 두 번째 인자로 받는 함수가 템플릿으로 되어 있기 때문이고,  latch처럼 아래와 같이 카운터 숫자만 정의도 가능하다.
std::barrier sync_point(3);
두 번째 인자는 카운터가 '0'이 될 때 호출이 된다.

3. void arrive_sync_point() noexcept
barrier에 2번째 인자로 전달되는 함수는 대기가 풀릴 때 호출 되며 예외가 없는 함수만 가능하다. (noexcept 필요)

4. sync_point.arrive_and_wait();
첫 번째 arrive_and_wait()에서 카운터가 '0'이 되어 대기가 풀리면, 카운터가 다시 '3'이 되어 다음 arrive_and_wait()에서 설정된 카운터만큼 호출될 때까지 대기를 하게 된다. ( 만약 위 예제에서 latch를 사용한다면 두 번째 arrive_and_wait에서 대기가 풀리지 않음)

 

ps. 회사에서는 C++20을 사용하지 않아서 jthread를 정리할 때 본 문제를 고려하지 못했는데 C++20 이상을 지원하는 환경을 사용하는 분들께 도움이 되었으면 한다.

 

참고


* 본 글에 예제 코드는 코드누리 교육을 받으면서 제공된 샘플코드를 활용하였습니다.