Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.
지금까지는 단일 실행 지점만을 가지는 프로그램들만을 생각해왔지만, 멀티-스레드 프로그램(multi-threaded program)은 하나 이상의 실행 지점을 가질 수 있다. 여기서 스레드는 같은 주소 공간을 공유해서 같은 데이터에 접근할 수 있다는 점만 제외하면 별개의 프로세스와도 같다고 생각해도 좋다.
싱글 스레드의 상태는 프로세스와 아주 비슷하다고 생각할 수 있다. 이는 명령어를 가져오기 위한 PC를 가진다. 각 스레드는 고유의 레지스터 집합을 가진다. 여러 스레드가 한 프로세서에서 돌아간다면, 해당 스레드 사이에서는 문맥 전환이 일어난다. 스레드의 문맥 전환 또한 프로세스 사이에서의 문맥 전환과 비슷하다.
프로세스에는 PCB에 프로세스의 상태를 저장했는데, 이제는 프로세스의 스레드들의 각 상태를 담기 위해서 하나 이상의 스레드 제어 블럭(thread control blocks, TCBs)을 사용한다. 스레드 문맥 전환은 프로세스 문맥 전환과 한 가지 주요한 차이점을 가지는데, 주소 공간만은 변하지 않고 동일하다는 것이다.
스레드와 프로세스의 또 다른 차이점 하나는 스택에 관한 것이다. 지금까지 다뤄온 싱글-스레드 프로세스에서는 하나의 스택이 주소 공간의 맨 아래에 위치해왔지만, 멀티-스레드 프로세스에서 각 스레드들은 독립적으로 돌아가고, 각각이 여러 루틴들을 실행한다. 따라서 주소 공간에 하나의 스택만을 사용하기보다, 스택들도 스레드 별로 하나씩 주어진다.
그림에서 두 스택이 주소 공간에 퍼져있음을 볼 수 있다. 스택 할당 변수, 파라미터, 반환값 등 스택에 넣는 모든 것들은 이 스레드-로컬 저장소(thread-local storage)에 위치한다. 문제는 이전에는 스택과 힙이 독립적으로 자라서, 주소 공간에 여유 공간이 없어질 때에만 문제가 생겼는데, 이제는 그런 좋은 상황이 일어나지 않는다는 것이다. 다행히도 스택이 보통 그렇게 크게까지는 자라지는 않기 때문에 아직은 괜찮다.
왜 스레드를 써야할까? 스레드 사용에는 적어도 두 가지의 주된 이유가 있다.
아주 큰 배열에 연산을 수행하는 프로그램을 만든다고 해보자. 만약 단일 프로세서를 사용한다면, 그냥 연산들을 수행하면 된다. 그런데 만약 여러 개의 프로세서를 사용할 수 있다면 전체 연산의 각 부분들을 각 프로세서에 분배함으로써 전체적인 작업 속도를 높일 수 있을 것이다.
표준적인 싱글-스레드 프로그램을 여러 개의 CPU에서 돌아갈 수 있게 변환하는 것을 병렬화(parallelization)라 부른다. CPU마다 작업을 위한 스레드를 사용하는 것은 현대 하드웨어에서 프로그램을 더 빠르게 실행할 수 있게 하기 위해 사용하는 자연스러운, 전형적인 방법이다.
여러 I/O 작업들을 수행하는 프로그램을 작성한다고 해보자. I/O 작업을 수행할 때에는 프로그램은 그냥 대기하기보다는 다른 것을 하고 있기를 원하며, 스레드의 사용은 이를 위한 한 방법이다. 프로그램의 한 스레드가 대기중인 동안 CPU는 다른 스레드로 실행 중인 전환해 다른 유용한 일들을 할 수 있을 것이기 때문이다. 스레드 사용은 멀티프로그래밍에서 프로세스들이 그랬던 것과 유사하게, 한 프로그램 내에서 I/O와 다른 활동들의 중첩을 가능하게 한다.
물론 두 경우 모두에 대해, 여러 스레드 대신 여러 프로세스를 사용할 수도 있다. 하지만 스레드는 주소 공간을 공유해 데이터를 공유하기 쉽기 때문에, 이러한 종류의 프로그램들을 만드는 데에는 더 자연스러운 선택이다. 프로세스는 메모리 내 자료 구조의 공유가 거의 필요없는, 논리적으로 구분되는 별개의 작업들을 수행해야하는 경우에 쓰인다.
서로 독립적인 작업들을 하는 두 개의 스레드를 생성하는 프로그램을 실행하고 싶다고 하자. 코드는 아래와 같다.
#include <stdio.h>
#include <assert.h>
#include <pthread.h>
#include "common.h"
#include "common_threads.h"
void *mythread(void *arg) {
printf("%s\n", (char *) arg);
return NULL;
}
int main(int argc, char *argv[]) {
pthread_t p1, p2;
int rc;
printf("main: begin\n");
Pthread_create(&p1, NULL, mythread, "A");
Pthread_create(&p2, NULL, mythread, "B");
// join waits for the threads to finish
Pthread_join(p1, NULL);
Pthread_join(p2, NULL);
printf("main: end\n");
return 0;
}
pthread_join()
을 두 번 호출해, 각 스레드들이 완료되기까지 기다린다.이 프로그램의 가능한 여러 실행 순서들을 살펴보자. fork()
를 사용해서 프로세스를 생성했을 때와 같이 단순히 스레드를 새로 만드는 것은 프로그램이 일정한 실행 순서로 실행된다고 보장하지 못한다.
위의 두 경우는 모두 "A", "B"의 순서로 출력하지만, 사실 "B"가 "A"보다 먼저 출력되는 경우도 있을 수 있다. 먼저 생성된 스레드가 반드시 먼저 실행될 것이라고 가정할 이유는 없다.
스레드 생성을 함수 호출을 하는 것과 같다고 생각해도 된다. 단 먼저 함수를 실행하고 호출자로 리턴하는 게 아니라, 시스템은 호출자와는 독립적으로 실행되는 스레드를 새로 만들어서 해당 함수를 실행한다. 다음에 어떤 것이 실행될지는 OS의 스케줄러에 의해 결정되고, 스케줄러가 똑똑한 알고리즘들을 사용하기는 하지만, 특정 시점에 어떤 게 실행되고 있을지는 알기 어렵다.
위에서 사용한 예시는 어떻게 스레드들이 생성되고 실행되는지를 파악하는 데에는 쓸 만하지만, 스레드들이 공유 데이터들에 접근하면서 어떻게 상호 작용하지를 보여주지는 못한다.
두 스레드들이 있고, 이 스레드듣을 통해서 전역 공유 변수를 업데이트하려고 한다 해보자. 코드는 아래와 같다. 코드에 대해서 다음을 주의하자.
Pthread_create()
는 pthread_create()
를 호출하고 리턴 코드가 0인지를 확인한다. 만약 리턴 코드가 0이 아니면 Pthread_create()
는 메시지를 출력하고 종료한다.counter
에 수를 100만번 더한다. 이 작업을 수행하는 스레드가 두 개이니 원하는 결과값은 200만이다.#include <stdio.h>
#include <pthread.h>
#include "common.h"
#include "common_threads.h"
static volatile int counter = 0;
// mythread()
//
// Simply adds 1 to counter repeatedly, in a loop
// No, this is not how you would add 10,000,000 to
// a counter, but it shows the problem nicely.
//
void *mythread(void *arg) {
printf("%s: begin\n", (char *) arg);
int i;
for (i = 0; i < 1e7; i++) {
counter = counter + 1;
}
printf("%s: done\n", (char *) arg);
return NULL;
}
// main()
//
// Just launches two threads (pthread_create)
// and then waits for them (pthread_join)
//
int main(int argc, char *argv[]) {
pthread_t p1, p2;
printf("main: begin (counter = %d)\n", counter);
Pthread_create(&p1, NULL, mythread, "A");
Pthread_create(&p2, NULL, mythread, "B");
// join waits for the threads to finish
Pthread_join(p1, NULL);
Pthread_join(p2, NULL);
printf("main: done with both (counter = %d)\n", counter);
return 0;
}
하지만 실행 결과는 원하던 200만이 아닐 것이다. 심지어는 이전 실행 결과와 동일한 결과가 나오지 않올 수도 있다.
이런 일이 왜 일어나는지 보려면 counter
를 업데이트하기 위해 컴파일러가 만들어내는 코드 시퀀스를 이해할 필요가 있다. 변수 counter
가 주소 0x8049a1c에 있다고 가정하자. 아래의 코드 시퀀스는 mov
명령어로 해당 주소의 값을 가져오고 이를 eax
레지스터에 넣는다. 이후 해당 레지스터의 값에 1을 더하고, eax
레지스터의 값을 다시 0x8049a1c에 저장한다.
mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
우리가 가지고 있는 두 스레드 중 하나(T1)가 이 코드 영역에 진입해, 레지스터 eax
에 0x8049a1c의 값을 가져왔다고 해보자. T1에서 레지스터 eax
의 값을 하나 올렸을 때, 타이머 인터럽트가 발생했다고 하자. OS는 T1의 상태를 TCB에 저장한다.
만약 이때 T2가 선택되고 실행되어 같은 코드에 진입했다고 하자. 첫 번째 명령어를 실행해 counter
의 값을 eax
에 넣는다. counter
의 값은 현재 50이므로 T2의 eax
에 담기는 값도 50이다. 나머지 명령어들도 실행해 0x8049a1c에 51을 저장했다고 하자.
이때 다시 스레드 T1으로 문맥 전환이 일어나면 T1은 자신의 eax
에 담긴 값 51을 메모리에 저장한다. 저장될 것으로 기대됐던 값은 52다.
이렇게 결과가 코드 실행의 시점에 의존하는 상황을 경쟁 조건(race condition)이라 부른다. 운이 나쁘면 잘못된 결과를 가지게 될 것이며, 사실은 실행을 할 때마다 다른 결과를 가지게 될 것이다.
여러 스레드가 같은 코드를 실행할 때 경쟁 조건이 발생하므로 이 코드 부분을 임계 영역(critical section)이라 부른다. 공유 변수에 접근하지만, 하나보다 많은 스레드들에서 동시에 실행해서는 안되는 코드 조각들을 임계 영역이라 한다.
이 코드에 대해서 원하는 것을 상호 배제(mutual exclusion)라 부른다. 이 성질은 한 스레드가 임계 영역에서 실행되고 있을 때, 다른 스레드들은 임계 영역을 실행하지 못하는 것을 보장하는 것이다.
이 문제를 해결하기 위한 한 가지 방법은 강력한 명령어 하나로 의도한 동작을 수행해 인터럽트 발생 가능성을 없애는 것이다. 예를 들어 다음과 같은 명령어가 있다고 하자.
memory-add 0x809a1c, $0x1
이 명령어가 해당 메모리 위치에 값을 더하고, 하드웨어는 이 명령이 원자적으로(atomically) 실행되게 보장한다고 하자. 만약 이 명령어가 실행되면, 업데이트는 바라던대로 일어난다. 앞서의 코드 시퀀스와 달리 코드 실행 도중에 방해를 받을 일이 없다.
하지만 일반적으로 이러한 명령을 사용하지는 않는다. 복잡한 연산 및 작업은 그것들을 위한 별개의 명령어가 아니라, 간단한 작업을 하는 명령어들의 조합을 통해 수행된다. 하드웨어는 동기화를 위한 기본적인 명령어 집합(synchronization primitives set)을 제공하고, 이를 조합함으로써 복잡한 멀티-스레드 코드를 설계한다.
지금까지는 스레드 사이에서, 공유 변수 접근과 같은 상호 작용만을 생각했지만, 다른 상호 작용들도 있다. 한 스레드가 다른 스레드가 특정 행동을 마칠 때까지 대기하고, 이후에 재개하는 것이 그 일례다. 이 상호 작용은, 예를 들어, 프로세스가 디스크 I/O 작업을 수행하고 sleep 상태로 들어갔다가, I/O 작업이 끝나면 깨어나 작업을 재개할 때 일어난다.
이후의 장에서는 원자성을 지원하기 위해 synchronization primitives를 어떻게 설계해야하는지만이 아니라, 이런 sleeping/waking 상호작용을 지원하기 위한 메커니즘들도 배우게 될 것이다.
잘 읽었습니다.
업로드 주기 땡겨주세요.