기록공간

3-1장. 쓰레드 API 본문

OS

3-1장. 쓰레드 API

입코딩 2020. 4. 13. 13:09
반응형

쓰레드 생성

멀티 쓰레드 프로그램을 작성 시, 가장 먼저 할 일은 새로운 쓰레드 생성이다. 쓰레드 생성을 위해서는 해당 인터페이스가 존재해야 한다.

 

우선 thread 라이브러리를 포함시켜야 한다. 그리고 std::thread 클래스의 thread_object를 만든다. thread_function에는 생성된 쓰레드가 실행할 함수가 들어간다. arg는 thread_function에 넘길 매개변수이다. 다만 thread_function 선언에 사용된 매개변수와 일치해야 한다. 다음은 예시 코드이다.

#include <iostream>
#include <thread>

void thread_function(int a, int b)
{
    std::cout << a << ", " << b << std::endl;
}

int main()
{
    int a = 10;
    int b = 20;
    std::thread p {thread_function, a, b};
}

쓰레드에서 정수 2개를 출력하는 간단한 코드이다. 정수 출력 함수를 쓰레드를 생성할 때 실행할 함수로 세팅을 해주고 변수 a, b를 arg 부분에 매개변수로 넣어주었다. 출력 결과는 당연히 "10, 20"이 나올 것이다. 과연 그럴까?

 

출력 결과를 보니 아무것도 나오지 않는다. 왜 그럴까?

 

쓰레드 종료

위처럼 나오는 이유는 주 쓰레드(main을 실행하는 쓰레드)가 생성한 쓰레드 p의 실행을 기다리지 않았기 때문이다. 주 쓰레드는 p의 실행 여부와 상관없이 쓰레드 p를 생성하고 main의 끝에 도달해 프로그램을 종료한 것이다. 다른 쓰레드가 작업을 완료할 때까지 기다려야 한다면 어떻게 해야 할까? 다른 쓰레드의 완료를 기다리기 위해서는 뭔가 특별한 조치를 해야 한다.

 

join() 함수는 thread 클래스에 포함된 멤버함수이다. 쓰레드 객체의 종료를 busy waiting(권한을 얻을 때까지 루프로 계속해서 확인하는 것) 없이 기다린다. 다음 예제 코드를 살펴보자.

#include <iostream>
#include <thread>
using namespace std;

struct myret_t
{
	int x;
	int y;
};

void thread_func(int a, int b, myret_t* r)
{
	cout << a << ", " << b << endl;
	r->x = 1;
	r->y = 2;
}

int main()
{
	myret_t m;
	int a = 10;
	int b = 20;
	thread p{ thread_func, a, b, &m };
	p.join();
	cout << "returned: " << m.x << ", " << m.y << endl;
}

myret_t라는 구조체를 만들었고, 쓰레드 p에서 실행할 함수의 매개변수로 받는다. 실행 함수에서는 위 예제와 같은 정수 출력 코드와 구조체의 두 값을 각각 1, 2로 바꾼다. 그리고 main 문에서는 생성한 쓰레드 p의 종료를 기다린 후, 구조체의 값들을 출력한다. 출력 결과는 다음과 같다.

 

main 쓰레드에서 쓰레드 p를 기다리기 때문에 예상하고 있던 결과가 나왔다. 

 

모든 멀티 쓰레드 코드가 join()을 사용하는 것은 아니다. 예를 들어 멀티 쓰레드 웹서버의 경우 여러 작업자 쓰레드를 생성하고 메인 쓰레드를 이용하여 사용자 요청을 받아 작업자에게 전달하는 작업을 무한히 할 것이다. 이런 프로그램은 join()을 사용할 필요가 없다. 하지만, 특정 작업을 병렬적으로 실행하기 위해 쓰레드를 생성하는 병렬 프로그램의 경우 종료 전 혹은 계산의 다음 단계로 넘어가기 전병렬 수행 작업이 완료되었다는 것을 확인하기 위해 join()을 사용한다. 

 

락(Lock)

쓰레드 생성과 join() 다음으로 쓰레드 API가 제공하는 가장 유용한 함수는 락을 통한 임계 영역에 대한 상호 배제 기법이다. 락을 사용하기 위해서는 다음 작업이 우선된다.

 

우선 mutex 라이브러리를 포함시키고 락 객체를 생성해야 한다. 락 객체는 생성 시 자동으로 초기화된다. 인터페이스는 다음과 같다.

 

mutex 객체의 멤버 함수로 lock()과 unlock()이 있다. lock()을 호출했다면 반드시 unlock()을 호출해줘야 한다

 

사용법은 다음과 같다.

int x; // 전역변수
std::mutex mylock // mutex 객체

// 쓰레드에서 실행할 함수
void thread_func()
{
    mylock.lock();
    x = x + 1;    // 임계 영역
    mylock.unlock();
}

lock()을 실행하는 쓰레드에서 lock을 갖고 있지 않은 경우 lock을 얻고 임계 영역에 진입하게 된다. 만약 다른 쓰레드에서 이미 lock을 가지고 있는 경우 lock을 얻으려던 쓰레드는 lock을 얻을 때까지(즉 다른 쓰레드에서 unlock() 될 때까지) 함수를 리턴하지 않는다. 

 

다른 사용법(try_lock)

 

try_lock 역시 mutex의 멤버함수이다. try_lock은 락의 획득의 실패하면 false를 리턴하고, 락 획득에 성공했다면 true를 리턴한다. 하지만 락을 가지로 있는 쓰레드가 없다고 해서 무조건 성공하는 것은 아니다. 락을 이미 가지고 있는 쓰레드에서 호출했을 경우에는 어떻게 될지 알 수 없다.

 

컨디션 변수

쓰레드 API에서 제공하는 주요한 구성 요소로 컨디션 변수(condition variable)가 있다. 컨디션 변수는 쓰레드 사이에 신호를 주고 받을 때 유용하게 사용된다. 신호가 올 때 까지 기다려야 할 때나 대기 상태에서 기다리게 해서 CPU의 낭비를 줄여준다. 컨디션 변수는 쓰레드의 상태를 바꾸기 때문에 커널 호출이 필요하다.

 

컨디션 변수를 사용하기 위해서는 컨디션 변수 객체가 필요하다. 

wait를 호출하고 대기하는 쓰레드를 만든다면 다음과 같이 만들 수 있다.

mutex cv_m;
conditional_variable cv;

cv_m.lock();
while(false == initialized)
	cv.wait(cv_m)
cv.m.unlock();

여기서 wait을 호출한 쓰레드는 대기 상태가 되면서 lock을 반납한다. 그리고 wait이 깨어나서 복귀할 때 lock을 다시 획득한다. 다음은 쓰레드를 깨우는 코드이다.

cv_m.lock();
initialized = true;
cv.notify();
cv_m.unlock();

대기하는 쓰레드는 while 루프에서 조건을 계속해서 반복 검사한다. 단순히 if문으로 한 번만 검사하지 않는다.

while(initialized == 0)
    cv.wait(cv_m);

한 번만 검사하지 않는 이유는 대기하는 쓰레드는 컨디션이 변경되지 않았음에도 변경되었다고 판단할 수 있기 때문이다.

 

하지만 다음과 같은 프로그래밍은 지양해야 한다.

 

왜냐하면 busy waiting으로 인해 CPU 낭비가 생겨 성능이 좋지 않기 때문이다. 또한 컴파일러나, CPU 에러가 발생할 수 있다. 

 

다음 코드는 컨디션 변수의 사용 예이다. 

#include <iostream>
#include <condition_variable>
#include <thread>
#include <chrono>

using namespace std;
using namespace chrono;

condition_variable cv;
mutex cv_m;
int i = 0;

void waits()
{
	unique_lock<mutex> lk(cv_m);
	cout << "Waiting... \n";
	while (1 != i)
		cv.wait(lk);
	cout << "...finished waiting. i == 1\n";
}

void signals()
{
	this_thread::sleep_for(1s);
	cout << "Notifying...\n";
	cv.notify_all();
	this_thread::sleep_for(1s);
	i = 1;
	cout << "Notifying again...\n";
	cv.notify_all();
}

int main()
{
	thread t1(waits), t2(waits), t3(waits), t4(signals);
	t1.join();
	t2.join();
	t3.join();
	t4.join();
	system("pause");
}

 먼저 signals() 함수부터 살펴보도록 하겠다. 대기하는 시간을 표현하기 위해 chrono 라이브러리를 사용하였다. 1s는 <chrono>에서 1초를 뜻한다. this_thread::sleep_for(1s)는 1초 동안 쓰레드 자신을 대기 상태로 놓는 것이다. notify_all()은 모든 대기 상태의 쓰레드를 깨우는 함수이다. 여기서는 notify_all()을 i = 1 코드 전후로 두 번 실행하였다. (notify()는 대기 상태 쓰레드 하나를 깨우는 함수이다)

 

wain() 함수를 살펴보자. unique_lock은 유니크 포인터와 비슷한 원리로 락을 작동 시킨다. 지역을 빠져나가면 unlock을 자동적으로 해주는 객체이다. unlock이 자동으로 되기 때문에 프로그래밍 오류를 줄이기 위해서 사용한다.

 

 

i가 0인 경우 컨디션 변수를 통해 쓰레드 대기 상태에 놓는다.

 

출력 결과는 다음과 같다.

 

반응형

'OS' 카테고리의 다른 글

3-3장. 락(Lock) - 2  (0) 2020.04.24
3-2장. 락(Lock) - 1  (0) 2020.04.22
3장. 병행성 - 개요  (0) 2020.04.10
2-15장. 물리 메모리 크기의 극복 : 정책  (0) 2020.04.06
2-14장. 물리 메모리 크기의 극복 : 메커니즘  (0) 2020.04.03
Comments