지금까지 우리는 OS가 수행하는 기본적인 추상화의 발전 과정을 살펴보았다.
하나의 CPU를 여러 가상의 CPU로 변환하여 여러 프로그램이 동시에 실행되는 것처럼 보이게 하고, 각 프로세스에 대해 가상 메모리라는 환상을 만드는 방법도 살펴보았다.
이제부터는 thread(스레드) 라고 하는 새로운 추상화 방법을 소개한다. 한 프로그램 내에서 단일 PC(program counter)가 동작하는 대신 멀티 스레드 프로그램은 여러 PC가 동작한다. 멀티 프로세스와 멀티 스레드가 다른 점은 멀티 스레드의 경우 한 프로그램의 한 주소공간을 공유하여 동일한 데이터를 공유한다는 것이다.
단일 스레드의 상태는 프로세스의 상태와 유사하다. PC를 가지고 있고 두개의 스레드가 실행중일 때 스레드 전환을 위해 context switch가 필요하다. 이럴때, 프로세스의 경우 상태를 PCB에 저장했지만 스레드의 경우는 상태를 TCB(thread control blocks)에 저장한다.
스레드와 프로세스 간의 또 다른 차이점은 스택이다.
멀티 스레드 프로세스에서는 스레드마다 하나의 스택이 존재한다. 따라서 스택에 할당된 변수, 매개변수, 반환 값 등은 해당 스레드의 스택에 배치한다. 이것을 때로는 thread-local storage (스레드 로컬 스토리지) 라고 부른다. 스택이 여러개 생기기 때문에 주소공간이 부족해질 것 같지만 일반적으로 스택은 작기 때문에 문제가 되지 않는다.

스레드를 사용해야하는 이유가 두가지가 있다.
첫 번째는 parallelism(병렬성)이다. 예를 들어 배열에 값을 추가하는 과정이 있을 때 프로세서가 여러개가 있다면, 같은 프로그램을 여러 프로세스가 나눠서 처리할 수 있다. 이것을 병렬화라고 한다.
두 번째는 느린 I/O로 인해 프로그램 진행이 막히는 것을 피하는 것이다. I/O요청을 수행하는 프로그램에서 I/O요청을 기다리지 않고 다음 작업을 수행할 수 있다.
위의 두가지 작업들을 스레드가 아닌 프로세스로 해결할 수 있지만, 같은 프로그램의 같은 주소공간을 공유하는 스레드가 일을 맡는 것이 가장 적합하다.
A , B 를 출력하는 작업을 수행하는 두 개의 스레드를 생성하는 프로그램을 살펴보자
#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;
}
메인 프로그램은 두 개의 스레드를 생성하며 각각 다른 인수로 mythread()를 실행한다. 스레드가 생성되면 스케줄러의 결정에 따라 동작하게 된다. 두 개의 스레드 T1, T2 를 생성한 후, pthread_join() 가 호출되어 스레드가 완료되기 까지 기다린다. 따라서 T1,T2 스레드가 실행완료 된 후에 메인 스레드가 실행된다. 스레드가 실행되는 순서는 스케줄러가 정해주기 나름이라서 프로그램이 의도대로 동작하지 않을 수 있다.
두 개의 스레드가 글로벌 공유 변수를 업데이트를 하려고 할 때도 문제가 발생한다.
#include <stdio.h>
#include <pthread.h>
#include "common.h"
#include "common_threads.h"
static volatile int counter = 0;
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;
}
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;
}
각각의 스레드는 counter 에 숫자를 더한다. 이 프로그램에서 불행한 것은 실행할 때 마다 얻는 count의 값이 다르다는 것이다. 우리는 count가 20,000,000이 되기를 기대하지만 그 보다 더 적고 일관성 없는 값들을 얻는다.
이 문제의 원인을 알기 위해서는 한 명령에 숨겨진 어셈블리어를 이해해야한다.
mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
변수 counter 가 주소 0x8049a1c에 위치할 때, 위의 명령어들은 ,
1. mov 명령어는 주소의 메모리 값을 가져와 eax에 넣는다.
2. add 명령어는 eax에 1을 더한다.
3. mov 명령어는 eax의 값을 원래 주소에 저장한다.
를 수행한다.
하지만 이 과정을 수행하는 중간에 스레드의 전환이 일어난다면, 예를 들어 1번을 마치고 전환이 된다면, 스레드 1과 스레드 2는 둘 다 같은 값에서 1을 더한 값을 반환하므로 더하기 연산이 한 번 무시되는 것과 같은 일이 일어난다. 그리고 이런 일이 반복적으로 일어난다고 하면, count가 20,000,000 보다 작아지는 것을 이해할 수 있을 것이다.

우리가 살펴본 예제를 race condition이라고 한다. 코드를 실행하는 여러 스레드가 race condition을 유발할 수 있는 코드를 critical section이라고 부른다.
이 문제를 해결하기 위해서는 한 스레드가 critical section에서 실행 중 일때, 다른 스레드는 접근하지 못하도록 보장하는 것이다. 우리는 이 방법을 mutual exclusive라고 한다.
세개의 어셈블리 명령어를 실행중에 스레드 전환이 일어나 문제가 발생한 예시를 살펴보았다. 그렇다면, 이 명령들을 실행하는 도중에는 스레드 전환이 일어나지 않으면 문제가 해결될 것이다. 그렇게 하기 위해서 우리는 이 명령줄들을 하나의 단위로 묶어, 원자적으로(atomically) 실행시킬 것이다.
critical section에 접근하고, 원자적으로 실행시키는 것을 살펴보았지만 한 가지 고려사항이 남았다. 스레드가 어떤 작업을 완료할 때까지 기다리는 상황이다. 예를 들어 프로세스가 I/O를 수행하기 위해 대기 상태가 되는 경우이다.