기록공간

3장. 병행성 - 개요 본문

OS

3장. 병행성 - 개요

입코딩 2020. 4. 10. 12:49
반응형

이번 장에서는 프로세스를 위한 새로운 개념인 쓰레드(thread)를 소개한다. 프로그램에서 한 순간에 하나의 명령만을 실행하는 고전적인 관점에서 벗어나 멀티 쓰레드 프로그램은 하나 이상의 실행 지점(독립적으로 실행 가능한 Program Counter값)을 가지고 있다. 멀티 쓰레드와 프로세스의 차이가 있다면 주소 공간을 공유하기 때문에 동일한 값에 접근할 수 있다.

 

하나의 쓰레드 상태는 프로세스의 상태와 매우 유사하다. 쓰레드는 프로그램 카운터(PC)와 연산을 위한 레지스터들을 가지고 있다. 만약 두 개의 쓰레드(T1, T2)가 하나의 프로세서에서 실행 중이라면 실행하고자 하는 쓰레드(T1)는 반드시 문맥 교환(context switch)을 통해서 실행 중인 쓰레드(T2)와 교체되어야 한다. 문맥 교환은 T1이 사용하던 레지스터들을 저장하고 T2가 사용하던 레지스터의 내용으로 복원한다는 점에서 프로세스의 문맥 교환과 유사하다. 프로세스가 문맥 교환을 할 때 프로세스 상태를 프로세스 제어 블럭(PCB)에 저장하듯 쓰레드의 상태를 저장하기 위해서는 쓰레드 제어 블럭(Thread Control Block, TCB)이 필요하다. 차이점에 있다면 쓰레드 간의 문맥 교환은 주소 공간을 그대로 사용한다.(=사용하던 페이지 테이블을 그대로 사용)

 

쓰레드와 프로세스의 또 다른 차이는 스택에 있다. 간단한 모델인 단일 쓰레드 프로세스에서는 스택이 하나만 존재한다. 반면에 멀티 쓰레드 프로세스의 경우 각 쓰레드가 독립적으로 실행되며 쓰레드가 실행하기 위해 여러 루틴들을 호출할 수 있다. 주소 공간에는 하나의 스택이 아닌 쓰레드마다 스택이 할당되어 있다.

 

오른쪽 그림에서는 두 개의 스택이 프로세스 주소 공간에 존재하는 것을 볼 수 있다. 스택에서 할당되는 변수들이나 매개변수, 리턴 값, 그리고 그 외에 스택에 넣는 것들은 해당 스레드의 스택인 쓰레드 로컬 저장소(thread-local storage)라 불리는 곳에 저장된다.

 

예제 : 쓰레드 생성

한 쓰레드는 A라고 출력하고 다른 쓰레드는 B라고 출력하는 독립적인 두 개의 쓰레드를 생성하는 프로그램을 실행한다고 해 보자. 코드는 다음과 같다.

 

메인 프로그램은 각각 mythread() 함수를 실행할 두 개의 쓰레드를 생성한다. 이때 각 mythread() 함수는 서로 다른 인자를 전달받는다. 두 개의 쓰레드(P1, P2)를 생성한 후에 메인 쓰레드는 join()을 호출하여 특정 쓰레드 동작의 종료를 대기한다. 다음 그림은 출력 화면이다.

 

위 그림에서 멀티쓰레드의 문제가 나온다. A, B 출력되는 순서가 프로그램을 실행할때마다 항상 동일하지 않다. 생성된 쓰레드는 호출자와는 별개로 실행된다. 쓰레드 생성 함수가 리턴되기 전에 쓰레드가 실행될 수도 있고, 그보다 이후에 실행될 수도 있다. (스케줄러의 동작에 따라 달라진다)

 

이처럼 쓰레드는 일을 복잡하게 만든다. 이 간단한 예제에서도 어떤 쓰레드가 언제 실행되는지 알기 어렵다. 

 

훨씬 더 어려운 이유 : 데이터의 공유

앞의 간단한 예제를 통해 쓰레드의 생성 방법을 알아보았고, 실행 순서는 스케줄러의 동작에 따라 바뀔 수 있다는 것을 보았다. 예제에서 나타나지 않은 부분은 쓰레드가 공유 데이터를 접근하기 위해 상호작용하는 과정이다.

 

멀티쓰레드 프로그래밍은 하나의 작업을 여러 개로 나누어 여러 개의 쓰레드에서 동시 병렬적으로 수행하는 것이다. 이말은 즉, 쓰레드 사이에서 정보 교환이 필수적이라는 것이다. 데이터 공유를 위해 전역 변수를 사용한다.

 

다음 예제 코드를 보자. 1억 번의 루프를 돌아 카운터를 증가시키는 프로그램이다. 이때 쓰레드 두 개를 생성하여 반씩 나누어 루프를 실행하도록 하였다. 이렇게 하면 결과는 당연히 1억이 나와야 한다. 과연 그럴까?

 

아무리 실행을 계속하더라도 1억이라는 결과는 나오지 않았다. 이것이 바로 멀티 쓰레드 프로그래밍이 앞의 경우보다 훨씬 더 어려운 이유라고 할 수 있다. 바로 데이터 공유에 의한 경쟁 때문이다.  

 

이것은 위 코드에서 카운터가 50이 되었을 때 두 스레드에서 한 루프 카운터를 증가시키는 순간을 표로 표현한 것이다. 위 모든 과정이 올바르게 끝난다면 카운터 값은 52여야 한다. 하지만 51이 증가하였다.

 

처음부터 보면 Thread1에서 50이 저장된 주소 값을 메인 메모리로부터 연산을 위해 가져왔고 그곳에 1을 더하였다. 그리고 그 값을 메인 메모리에 다시 저장하려 찰나, 운영체제가 문맥 교환을 실행한다. Thread1의 상태가 그대로 저장되고 Thread2를 실행하게 된다. Thread2 역시 메인 메모리로 부터 카운터 값이 저장된 주소를 가져온다. 하지만 Thread1에서 더한 카운터 값을 메인 메모리에 저장해주지 않았기 때문에, Thread2 역시 50이라는 카운터 값을 가지고 온다. 그리고 1을 더해 준 후 메인 메모리에 값을 저장한다. 또 운영체제에서 문맥 교환이 실행되고 Thread1은 남은 작업(카운터 값 저장)을 수행한다.  

 

이 순서는 매 순간 달라질 수도 있기에 출력 결과가 항상 다르게 나오는 것이다. 이 상태를 경쟁 상태(Race condition)라고 부른다.

 

임계 영역(critical section)

위 예제에서 쓰레드에서 서로 공유하는 전역 변수 counter에 접근하는 부분이 문제였다. 공유 변수에 접근하는 코드로 여러 스레드에서 동시에 수행되면 안 되는 부분, 이 부분을 임계 영역이라고 한다. 

 

임계 영역을 여러 스레드가 동시에 수행하면 경쟁 조건이 발생하고 위와 같은 일이 벌어지게 된다. 임계 영역은 원자적(atomic)으로 수행해야 한다. 즉, 의도한 동작을 수행하여, 임계 영역에서의 경쟁 상태를 원천적으로 차단하는 것이다. 이것을 상호 배제(mutaul exlusion)라고 부른다.

 

락(lock)

락(lock)은 이러한 임계 영역들이 하나의 원자적 명령어인 것처럼 실행되게 해주는 함수이다. 여러 개의 명령어를 원자적으로 실행한다. 락을 사용하여 위 코드를 고치면 다음과 같다.

#include <iostream> 
#include <thread> 
#include <mutex> // 락 함수가 들어있는 라이브러리
using namespace std;

volatile int counter = 0; 
int loops = 100000000;

// 뮤텍스 객체 전역으로 생성
mutex m;

void mythread(int count) 
{
	for (int i = 0; i < count; ++i)
	{
		// 락을 해준다.
		m.lock();
		// critical section
		counter++;
		m.unlock();
		// 락을 푼다.
	}
}

int main() { 
	thread p1, p2;
	p1 = thread{ mythread, loops / 2 };
	p2 = thread{ mythread, loops / 2 }; 
	p1.join(); 
	p2.join(); 
	cout << "counter = " << counter << endl; 
}

Thread1에서 lock을 하는 경우 Thread2가 lock이 걸린 부분을 접근하지 못하며 unlock이 될 때까지 대기한다. 이것이 반복되며 원자적으로 임계 영역 안에서의 코드를 수행하게 된다.

 

반응형
Comments