티스토리 뷰

반응형

안녕하세요 Pingu입니다.

 

지난 글을 마지막으로 메모리 가상화를 끝내고! 드디어 OSTEP에서의 큰 2번째 단원인 Concurrency, 동시성에 대해 알아보려고 합니다. 이번 글에서는 동시성을 구현하기 위해 사용하는 Thread(스레드)라는 녀석에 대해 알아보려고 합니다. 스레드는 정말 자주 들어본 개념인 것 같은데 이번 글에서 한 번 구체적으로 무엇인지에 대해 알아보면 좋을 것 같아요. 제가 참고하고 있는 OSTEP 책에서는 Chapter 26 Concurrency and Threads 부분 입니다!

Concurrency: An Introduction

지난 글까지는 OS가 수행하는 abstraction(추상화)에 대해 살펴봤습니다. CPU, 메모리를 각 프로세스마다 존재하는 것처럼 해주는 가상화에 대해 알아봤었죠. 이번 글에서는 실행 중인 프로세스에 대한 새로운 추상화 방법인 Thread(스레드)에 대해 알아보려고 합니다. 이전에 배운 것처럼 여러 개의 프로세스를 동시에 실행해야 하며 이를 스레드를 사용하여 어떻게 수행할 수 있는지에 대해 살펴보겠습니다.

 

우선 스레드에 대해 설명하자면 하나의 스레드는 프로세스와 매우 유사합니다. 프로그램이 다음에 어디를 수행해야할지에 대한 정보인 program counter(PC)가 있으며 계산에 사용하는 자체 레지스터가 있습니다. 따라서 어떤 프로세스가 실행 중 다른 프로세스를 실행할 때 context switch를 해줬던 것 처럼 쓰레드도 마찬가지로 context switch를 수행하여 다른 쓰레드를 실행할 수 있습니다. 프로세스의 Context Switch에 대해 배운 것과 유사하게 쓰레드에서도 각 쓰레드의 레지스터 상태를 저장하고 실행을 위해서 레지스터 상태를 복원해야합니다. 또한 프로세스는 process control block(PCB)라는 자료구조에 프로세스의 상태를 저장했는데 쓰레드는 thread control block(TCB)라고 불리는 곳에 쓰레드의 상태를 저장합니다. 그렇다면 프로세스와 쓰레드는 동일하다고 보면 될까요? 그것은 아닙니다. 정말 큰 차이가 있는데요, 프로세스는 각자 독립적인 주소 공간을 가지지만 쓰레드는 주소 공간을 공유합니다. 즉 page table을 전환할 필요가 없는 것이죠!

 

따라서 프로세스와 쓰레드는 아래 그림과 같은 구조를 가지게 됩니다.

위와 같이 프로세스들은 각자 독립적인 주소 공간을 가지는데 비해 쓰레드들은 주소공간을 공유합니다. 여기서 눈여겨봐야 할 것은 Stack입니다. 스택을 눈여겨보시면서 다음 그림을 살펴보겠습니다.

위의 그림에서 왼쪽은 싱글 스레드, 오른쪽은 멀티 쓰레드의 주소공간입니다. 왼쪽은 그냥 평범한 것 같은데 오른쪽의 멀티 쓰레드 주소 공간이 신기하지 않나요? 몇 개의 스레드가 주소 공간을 공유하기 때문에 하나의 주소 공간만 사용하는 것을 볼 수 있습니다. 하지만 스레드들은 스택 공간은 개별적으로 갖기 때문에 위와 같이 주소 공간에 스택 부분이 분산되어 있습니다. 이는 지금까지 공부했던 주소 공간에 대한 개념을 모두 깨버리는 상황인 것이죠. 따라서 이를 어떻게 처리할 것인가에 대해 생각해야 합니다.

Why Use Threads?

그럼 이렇게 간단하게만 알아봤는데도 문제를 발생하는 이 스레드라는 녀석을 도대체 왜 사용하는 걸까요?

 

결과부터 말씀드리자면 스레드를 사용하는 이유는 크게 2가지 입니다. 첫 번째는 병렬 처리가 간단하다는 점입니다. 예를 들어 두 개의 큰 배열에 요소를 추가한다거나 배열의 각 요소의 값을 증가시키는 작업을 한다고 생각해보겠습니다. 단일 프로세서에서 이를 실행하면 단순하게 차례대로 수행하면 되는데, 만약 프로세서가 여러개가 있다면 여러개의 프로세서가 일을 나눠서 하면서 시간을 절약할 수 있습니다. 이렇게 하나의 작업을 여러 프로세서에서 실행하는 것을 병렬화라고 하며 이를 구현할 때 쓰레드를 사용하는 것이 자연스럽고 일반적인 방법입니다.

 

스레드를 사용하는 두 번째 이유는 느린 I/O로 인해 프로그램 수행이 차단되는 것을 막기 위해서 입니다. 다양한 유형의 I/O를 수행하는 프로그램이 있을 때 I/O가 수행 중일 때 완료되기를 기다리기 보다는 다른 작업을 수행하는 것이 당연히 나을 것인데 이러한 것을 쓰레드를 사용하여 막을 수 있는 것이죠. 프로그램의 한 스레드가 대기하는 동안에 다른 스레드로 context switch 하여 수행하면 I/O와 다른 작업들을 함께 수행할 수 있습니다.

 

물론 위의 두 가지 상황을 프로세스를 여러 개 사용해서 해결할 수도 있습니다. 하지만 아까 말했듯 스레드는 주소 공간을 공유하기 때문에 이러한 작업을 잘 처리할 수 있습니다. 프로세스는 독립적인 작업들을 수행할 때 적합한 선택이 될 수 있습니다.

An Example: Thread Creation

그럼 스레드를 생성하는 예를 보며 쓰레드가 어떻게 생성되고 사용되는지 살펴보도록 하겠습니다.

#include <stdio.h>
#include <assert.h>
#include <pthread.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");
    // 쓰레드를 실행하고 완료되길 기다립니다.
    pthread_join(p1,NULL);
    pthread_join(p2,NULL);
    printf("main: end\n");
    return 0;
}

위의 코드는 A, B라는 2개의 스레드를 만드는 코드입니다. Pthread_create() 함수로 쓰레드를 만들 수 있으며 각 쓰레드에는 수행할 함수를 넣어주면 됩니다. Pthread_join()함수는 특정 쓰레드를 실행하고 완료되는 것을 기다리는 함수로 A, B 스레드가 종료된 뒤에 main 함수가 끝나게 됩니다. 이 코드를 실행하면 메인 쓰레드와 A, B 쓰레드 즉 총 3개의 쓰레드가 사용되게 됩니다. 그럼 이 세 개의 프로그램을 실행시키면 어떤 순서로 실행될까요? 보통의 C언어 프로그램처럼 순차적으로 처리될까요? 이 프로그램을 실제로 실행하면 아래와 같은 결과가 나타납니다.

결과는 예상과 다르게 A, B스레드가 완료되는 순서가 자기 맘대로입니다. 왜 이렇게 되는 것 일까요? 가능한 몇 가지 순서를 자세히 살펴보며 왜 이런 결과가 나오는 것인지 알아보겠습니다.

우선 A 스레드가 먼저 수행되고 B 쓰레드가 수행되는 상황입니다. 그럼 출력도 A, B 순서대로 되겠죠? 이게 처음 저희가 예상한 결과입니다. 코드 순서대로 A 쓰레드가 모두 완료 하고 난 뒤 B 쓰레드가 실행되는 것이죠. 하지만 아까 실제 결과에서도 봤듯 B가 먼저 수행되는 경우도 있었습니다.

위와 같은 상황이 B 스레드가 먼저 수행되어 B 쓰레드가 먼저 출력되는 상황입니다. 이렇게 쓰레드의 실행순서가 바뀌는 이유는 쓰레드가 실행되는 순서를 이전에 배웠던 OS의 스케줄러가 결정하기 때문입니다. 따라서 어떤 쓰레드가 먼저 실행될지 개발자는 알 수가 없죠. 물론 우선순위와 같은 것들을 조정하여 순서를 조절할 수는 있지만 아까의 예에서는 그러한 작업은 해주지 않았기 때문에 위와 같이 순서가 맘대로 결정되는 것 입니다. 

Why It Gets Worse: Shared Data

아까의 예는 스레드가 실행되는 순서를 알지 못한다라는 것을 알기위한 예였고, 이번에는 그럼 순서는 몰라도 결과는 알 수 있는 예로 쓰레드가 어떻게 동작하는지 살펴보겠습니다.

 

스레드는 아까 언급한 대로 Stack 부분을 제외하고는 주소 공간을 공유하기 때문에 전역 변수도 공유하게 됩니다. 그럼 이번 예에서 동일한 전역 변수에 2개의 스레드가 접근하도록 만들어 보겠습니다.

#include <stdio.h>
#include <assert.h>
#include <pthread.h>

static volatile int counter = 0;

void *mythread(void *arg) {
    printf("%s: begin\n", (char *) arg);
    int i;
    // 전역변수 counter를 1씩 100만번 증가시킵니다.
    for (i = 0; i < 1000000; i++){
        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");

    pthread_join(p1,NULL);
    pthread_join(p2,NULL);

    printf("main: done (counter = %d)\n",counter);
    return 0;
}

위와 같이 counter라는 전역변수에 A, B 쓰레드가 각각 1을 100만 번씩 더하는 코드를 만들었습니다. 수행 순서는 아까 말했듯 모를 수는 있지만 1을 100만번씩 2번 더하니까 결과는 200만이 나와야겠죠? 하지만... 실제 실행을 하면 아래와 같이 나옵니다.

스레드의 수행 순서는 아까도 봤듯 랜덤이지만 이번에 집중해서 볼 것은 counter라는 전역 변수의 값입니다. 분명 1을 100만 번씩 2번 더해줬는데 값이 겨우 100만을 조금 넘습니다. 지금까지 배운 것들이 모두 물거품이 되는 듯한.. 결과입니다. 도대체 왜 이런 결과가 발생하는 것일까요?

The Heart Of The Problem: Uncontrolled Scheduling

아까 200만이 나오지 않은 이유를 알아보기 위해서는 실제 덧셈이 어떻게 수행되는지에 대한 어셈블리어를 살펴봐야 합니다.

mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c

위의 어셈블리 코드는 전역 변수 counter에 1을 더하는 과정입니다. 우선 메모리에 있는 전역 변수를 가지고 와서 레지스터에 넣습니다. 레지스터의 값을 1 증가시키고 증가시킨 값을 다시 메모리에 로드합니다. 여기서 알 수 있는 것은 메모리에 있는 값을 바로 증가하는 것이 아닌 레지스터에 옮겨서 값을 증가한다는 점이죠. 레지스터에 옮긴 뒤 값을 증가시키는 점과 예전 다중 프로그래밍을 구현하기 위한 메커니즘이었던 time sharing 기법을 다시 떠올리면 이 문제를 이해할 수 있습니다. Time sharing 기법은 프로세스가 스케줄링되어 실행될 때 한 번에 실행할 수 있는 시간이 정해져 있어서 해당 시간이 지나면 다른 프로세스를 수행할 수 있도록 하는 메커니즘이었습니다. 그럼 이를 아까 전의 1을 100만 번 더하는 프로그램으로 가지고 오면 왜 200만이 되지 않았는지 이해할 수 있습니다.

 

처음 counter 값이 0이었고 A 스레드가 counter를 증가시키기 위해 레지스터에 값을 옮겼습니다. 따라서 레지스터에는 0이 존재합니다. 근데 이 때 주어진 시간이 끝나서 현재 상태를 저장하고 B 쓰레드가 수행이 됩니다. B 쓰레드는 옮기고 증가하고 다시 메모리에 적재까지 했습니다. 따라서 메모리에 존재하는 counter 값은 1이 된 것이죠. 그런뒤 다시 A 쓰레드가 실행됩니다. Context Switch는 레지스터의 상태도 저장하고 아까 진행하지 못한 부분부터 진행하므로 레지스터에 저장한 값 0에 1을 더한 뒤 메모리에 적재합니다. 여기서 벌써 문제가 발생하는 것이 보이나요? 분명 2번의 1을 더하는 작업을 수행했는데도 불구하고 counter의 값은 1입니다. 

 

이를 보기 좋게 그림으로 나타내면 위와 같이 그려집니다. Context switch가 진행될 때 레지스터의 상태도 저장하기 때문에 이러한 문제가 발생하게 됩니다. 이렇게 전역 변수와 같이 스레드에서 공유하는 데이터에 접근하는 상황을 race condition(경쟁상태)라고 하며 여기서 전역 변수와 같은 공유되는 값을 critical section(임계 영역)이라고 하며 현재와 같이 그냥 스레드를 실행하게 되면 임계 영역에 여러 개의 스레드가 동시에 접근하게 되어 원하는 결과를 얻지 못하는 상황이 나타나게 됩니다. 따라서 어떤 쓰레드가 임계 영역에 접근하는 중이라면 다른 스레드는 접근할 수 없도록 만들어줘야 하며 이를 mutal exclusion(상호 배제)라고 합니다. 

The Wish For Atomicity

경쟁 상태를 해결하는 방법에 상호 배제 말고도 하나의 명령으로도 해결하는 방법이 있습니다. 아까처럼 덧셈을 세 번의 명령어에 걸쳐 진행하는 것이 아닌 한 번에 진행하여 방해를 받지 않고 원하는 작업을 확실하게 끝내는 것이죠. 혹은 세 개의 명령어를 하나의 단위로 처리하는, 즉 atomically(원자적)하게 실행하여 문제를 해결할 수 있습니다.

 

이러한 것을 구현하려면 하드웨어의 도움을 받아야 합니다. 하드웨어와 OS의 힘을 합쳐 동기화되고 제어된 방식으로 임계 역역에 접근하는 멀티 스레드 코드를 빌드하여 안정적으로 결과를 낼 수 있습니다.

 

따라서 책의 Concurrency(동시성) 단원에서는 이러한 것들을 해결하는 방법을 알아보려고 합니다.

One More Problem: Waiting For Another

지금까지 스레드를 사용할 때 발생하는 동시성 문제, 임계 영역에 접근하는 문제, 원자성을 지원해야 하는 문제에 대해 살펴봤습니다. 여기서 남은 또 하나의 문제는 어떤 스레드가 다른 작업이 끝날 때 까지 기다려야하는 상황도 해결해야합니다. 예를 들어 프로세스가 I/O를 수행하고 있는 동안에는 쓰레드가 수행되지 않도록 만들고 I/O가 끝나면 다시 수행하도록 만드는 것이죠.

 

이러한 것을 sleeping/waking이라고 하며 이후의 글에서 이것 역시 알아볼 예정입니다.

Summary: Why in OS Class?

그럼 이번 글에서 알아본 Concurrency에서 중요한 용어 몇 개를 짚어보겠습니다.

 

Critical Section : 스레드들이 공유하는 리소스

Race Condition : 여러 스레드가 동시에 임계 영역이 접근하려고 할 때 발생하는 문제

Indeterminate : 어떤 쓰레드가 먼저 실행될지 모르는 불확실성

Mutual exclusion : 경쟁 상태를 해결하기 위한 방법 중 하나인 상호 배제

 

지금 이 4개의 용어는 Concurrency 단원이 끝날 때까지 계속해서 등장하게 될 것입니다.

 

OS는 지금 알아보고 있는 동시성에 대한 개념들이 모두 들어있는 하나의 프로그램입니다. 앞으로 이번 글에서 살펴본 문제점들을 하나씩 해결해나가며 동시성을 구현하는 방법을 살펴볼 예정입니다. 그럼 이러한 문제를 해결하고 스레드를 사용하기 위한 C언어에서 제공해주는 쓰레드 API에 대해 다음 글에서 알아보도록 하겠습니다.

 

감사합니다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함