프로세스 : 운영체제로부터 자원을 할당받은 작업의 단위
쓰레드 : 프로세스가 할당받은 자원을 이용하는 실행 흐름의 단위.
쓰레드를 사용하는 이유 : 동시성을 보장하기 위해 사용한다. 예를 들어 서버를 생성한다고 하자. 하나의 프로세스로 구성된 서버는 한번에 한명의 클라이언트의 요청밖에 처리할 수 없다. 여러 개의 프로세스로 구동되는 서버를 사용할 수도 있지만, 프로세스는 개별 공간이 크고, Context Switching에도 시간이 오래 걸린다.(프로세스의 Context 스위칭 시, CPU 레지스터, RAM과 CPU 사이의 캐시 메모리까지 초기화된다. 쓰레드의 경우 개별 컨텍스트만 Switching 해주면 된다.)
기본적으로 하나의 프로세스는 하나의 쓰레드를 가지고 생성되며, 생성된 프로세스 안에서 쓰레드를 추가하면 멀티쓰레딩이라고 한다. 멀티쓰레드는 한개 프로세스의 컨텍스트 안에서 돌아간다. 각각 쓰레드는 자신만의 별도의 쓰레드 컨텍스트를 가지며, 쓰레드 ID, 스택, 스택 포인터, 프로그램 카운터, 조건 코드, 범용 레지스터 값들이 포함된다. 나머지 자원은 다른 쓰레드와 공유한다.(만일 A 쓰레드가 B 쓰레드의 스택을 가리키는 포인터를 획득한다면, 해당 포인터를 활용하여 다른 쓰레드의 스택데이터를 조작하는게 가능하다.)
1. Stack : 프로그램 실행 과정에서 생성되는 지역변수, 주소값 등을 저장하는 공간(순차적으로 주소가 낮아지는 방향으로 쌓임)
2. Heap : 동적으로 할당되는 메모리 공간 (By malloc ...)
3. Static : 1) Data : 초기화된 전역변수가 저장된다.
2) BSS : 초기화되지 않은 전역변수가 저장된다.
4. Code : 프로그램 실행 코드
쓰레드의 장점 : 다수의 쓰레드가 같은 프로그램의 변수들을 공유하기 쉽다.
쓰레드의 단점 :
1. 쓰레드 간의 동기화 문제가 발생한다. A 스레드가 어떤 자원을 사용하다가, B스레드로 제어권이 넘어간 후, B 스레드가 해당 자원을 수정했을 때, 다시 제어권을 받은 A스레드가 해당 자원에 접근하지 못하거나 바뀐 자원에 접근하게 되는 문제가 발생할 수 있다.
2. 멀티프로세싱은 운영체제의 스케쥴러가 관장하는 반면, 멀티스레드는 운영체제가 처리하지 않기 때문에 프로그래머가 직접 동기화 문제에 대응할 수 있어야 한다. --> 일반적으로, 운영체제가 여러분의 쓰레드를 위해서 정확한 순서를 선택하게 될지 예측하는 방법은 없다.
3. 디버깅이 까다롭다.
4. 하나의 쓰레드에 문제가 발생하면 전체 프로세스가 영향을 받는다.
한 프로세스 내에서 쓰레드간 공유하는 공간은 반드시 관리되어야 한다. 하나의 쓰레드가 수정하는 경우 다른 쓰레드는 잠시 대기해야 한다. 이런 공간을 Critical Region이라고 하고, 해당 영역을 관리하기 위해 사용하는 키를 Mutex라고 한다.(Mutex는 1과 0의 값만을 가지는 Binary Mutex이다.)
아래의 예시를 보자. (공유된 메모리에 대한 잘못된 접근의 예시 : 출처 : CSAPP)
[ badcnt.c ]
0. niters라는 변수를 입력받는다.
1. cnt=0 라는 전역변수를 선언하고, thread()함수를 실행하는 쓰레드를 2개 생성한다.
thread 함수 : niters만큼 cnt를 증가시킨다.
프로그램의 의도 : 프로세스를 두개 생성한 뒤 전역변수 cnt를 niters만큼씩 증가시킨다.
/*
* badcnt.c - An improperly synchronized counter program
*/
/* $begin badcnt */
/* WARNING: This code is buggy! */
#include "csapp.h"
void *thread(void *vargp); /* Thread routine prototype */
/* Global shared variable */
volatile long cnt = 0; /* Counter */
int main(int argc, char **argv)
{
long niters;
pthread_t tid1, tid2;
/* Check input argument */
if (argc != 2) {
printf("usage: %s <niters>\n", argv[0]);
exit(0);
}
niters = atoi(argv[1]);
/* Create threads and wait for them to finish */
pthread_create(&tid1, NULL, thread, &niters);
pthread_create(&tid2, NULL, thread, &niters);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
/* Check result */
if (cnt != (2 * niters))
printf("BOOM! cnt=%ld\n", cnt);
else
printf("OK cnt=%ld\n", cnt);
exit(0);
}
/* Thread routine */
void *thread(void *vargp)
{
long i, niters = *((long *)vargp);
for (i = 0; i < niters; i++) //line:conc:badcnt:beginloop
cnt++; //line:conc:badcnt:endloop
return NULL;
}
/* $end badcnt */
하지만 실제로 프로그램을 실행해보면, 아래와 같이 의도치 않은 결과가 나오는 것을 볼 수 있다.
무엇이 문제일까?
우리가 사용하는 C언어 아래에는 또 어셈블리어가 있다. C언어에서는 최소 단위라고 생각되는 함수들이, 실제로 어셈블리어 단계에서는 여러 단계를 거쳐 수행된다. 해당 예제에서 문제가 되는 아래 부분이 실제로 CPU에서 어떻게 수행되는지 보자.
for (i = 0; i < niters; i++) //line:conc:badcnt:beginloop
cnt++; //line:conc:badcnt:endloop
간단히 정리해보면, cnt++; 이라는 작업은 CPU 입장에서 아래와 같은 세 개의 작업으로 구분된다.
1) Load(L) : 메모리에 저장된 cnt를 불러와서 레지스터 r1에 저장한다.
2) Update(U) : r1에 있는 데이터에 1을 더한다.
3) Store(S) : r1에 있는 데이터를 메모리에 다시 저장한다.
1번 쓰레드가 수행해야 하는 작업을 L1, U1, S1이라고 하고 2번 쓰레드가 수행해야 하는 작업을 L2, U2, S2라고 하자.
원래는 L1, U1, S1 -> L2, U2, S2 순서대로 수행되는 것이 작성자의 의도이지만, 쓰레드 간의 실행 순서는 우리가 조작할 수 있는 범위를 벗어난다. 위와 같이 L1->U1->L2->S1->U2->S2순서대로 작업이 일어나면, cnt는 2가 아닌 1이 되면서 의도와 다른 결과가 나오게 된다.
따라서 이런 공유 영역들을 조절할 때는 아래와 같이 Mutex를 사용한다. Mutex는 공유된 자원(Critical Section)에 접근하는 키이다. 키를 가진 사람만 접근할 수 있도록 1이면 접근 가능, 0이면 사용중을 의미한다. 쓰레드가 공유 자원에 접근하려고 할 때 Mutex를 확인하고, 누군가가 사용중이면(Mutex == 0이면) 다음 쓰레드에게 실행 순서를 넘겨준다.(술집의 화장실 키를 생각하면 된다. 키가 없으면 기다리고, 키가 있으면 들고간다.)
P 함수는 Mutex가 1이면 0으로, V 함수는 Mutex가 0이면 1로 만들어주는 함수이다. 위의 코드를 작성자의 의도대로 수정하면 아래와 같다.
/*
* goodcnt.c - properly synchronized counter program
*/
#include "csapp.h"
void *thread(void *vargp); /* Thread routine prototype */
/* Global shared variable */
volatile long cnt = 0; /* Counter */
sem_t mutex;
int main(int argc, char **argv)
{
long niters;
pthread_t tid1, tid2;
Sem_init(&mutex, 0, 1);
/* Check input argument */
if (argc != 2) {
printf("usage: %s <niters>\n", argv[0]);
exit(0);
}
niters = atoi(argv[1]);
/* Create threads and wait for them to finish */
pthread_create(&tid1, NULL, thread, &niters);
pthread_create(&tid2, NULL, thread, &niters);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
/* Check result */
if (cnt != (2 * niters))
printf("BOOM! cnt=%ld\n", cnt);
else
printf("OK cnt=%ld\n", cnt);
exit(0);
}
/* Thread routine */
void *thread(void *vargp)
{
long i, niters = *((long *)vargp);
for (i = 0; i < niters; i++){
P(&mutex); // Mutex += 1 if Mutex == 0
cnt++;
V(&mutex); // Mutex -= 1 if Mutex == 1
}
}
/* $end goodcnt */