운영체제 OStudy

[OStudy] 9주차 - 병행성(1)

Zeka_P 2025. 11. 6. 16:18

 이전 챕터까지 완료하며, 우리는 메모리 관리 시스템의 전반적인 절차에 대해 공부했습니다. 여러가지 방법이 있었죠. 페이징 기법으로 고정적인 공간 배치로 효율화를 추구하는 방법이라던지, 당장의 여유 공간을 유동적으로 유지해서, 필요한 공간대로, 또는 그 크기에 근접한 버디대로 배치하는 방법이 있었습니다. 이번 병행성 파트 부터는, 단순한 메모리만 관리되는 것이 아닌, 이들 개별의 프로세스들이 어떻게 CPU의 단독 사용 권한을 부여 받고, 또 양도하는지, 그리고 이런 과정에서 생길 수 있는 여러 논리적 문제들과 이들을 해결하여 안정된 체계를 구축할 수 있었는지 알아보겠습니다.

 

쓰레드(Thread)
 아마 처음 이 파트에 들어오게되면, 가장 많이 헷갈리는 부분이 스레드와 프로세스의 차이일 것입니다. 우선 우리가 이전에 배웠던 내용을 기억해 내야합니다. '프로세스 별로' 가상 메모리가 구축되어 있습니다. 즉, 프로세스는 운영체제에게서 이와 같은 자원들을 할당받아 동작하는 '작업'의 단위를 말합니다. 그리고 이 스레드가, 프로세스에서 동작이 이루어지는 하나의 '실행 흐름'의 단위를 말합니다. 전통적인 프로그램은 단 하나의 프로세스로, 하나의 실행흐름, 즉 단일 스레드 프로그램으로 존재했습니다. 오늘날에는 멀티 스레드 프로그램이 하나 이상의 실행 지점을 지니고, 각 스레드가 독립적으로 명령을 실행합니다. 쉽게 이야기하자면, 프로세스는 해당 실행 중인 프로세스에게 할당되는 모든 자원을 소유한 상태로 동작하는 하나의 단위이고, 스레드는 이 프로세스가 소유한 자원 범위 이내에서, 이들을 공유하고 오직 스레드에 할당된 스택이나 레지스터 값만 소유하는 하위 개념의 실행 단위입니다. 조금 더 생각을 확장하자면, 프로세스는 (당연하게도) 각자 다른 주소 공간을 가지고 있습니다. 그리고 각자의 메모리 공간은 타 프로세스로부터 보호됩니다. 이를 통해 독립성이 보장됩니다. 하지만 스레드의 경우 조금 다릅니다. 동일 프로세스 내에서 해당 프로세스 소유의 메모리 공간을 공유한다는 특성은, 이들이 전역 변수나 힙과 같은 영역을 함께 사용한다는 의미가 됩니다. 즉, 프로세스처럼 완전한 독립성이 보장되지 않습니다. 

 이 스레드들 이내에서, 문맥교환(Context Switch)이 발생합니다. 현재 실행중인 스레드가, 일정 조건이 충족되면 다음 대기 중인 스레드로 그 작업을 넘깁니다. 위에서 이야기했듯, 스레드들은 레지스터를 통해 필요한 값을 유지합니다. 따라서 문맥교환이 발생할 때, 현재 스레드의 레지스터 값들을 백업합니다. 그리고 대기 중인 다음 실행될 스레드도, 이전의 백업된 레지스터 값을 불러와 다시 동작시킵니다. 그리고 이를 저장하여 유지하는 메모리의 논리적 영역이 스레드 제어 블록(Thread Control Block, TCB)입니다.

스레드에는 추가로, 스택의 개념이 들어갑니다. 단일 스레드 프로세스에서는 하나의 스택만이 존재하고, 각 스레드가 독립적으로 실행되는 멀티 스레드에선 여러 함수가 호출되는 특성에 맞춰, 각 스레드마다 별도 스택이 할당됩니다. 그리고, 이 각 스레드의 스택에 저장되는 변수나 리턴 값 등을, '스레드-로컬 저장소(Thread-Local Storage)라고 부릅니다. 메모리 공간이 한정되어 있는 만큼, 멀티 스레드 프로세스에서는 재귀 호출로 인한 스택 증가의 한계치를 의식해야합니다. 

 

스레드 형성 예제

 그럼 이제 본격적으로, 스레드를 구현하는 예제를 보겠습니다. 먼저 아래의 코드를 통해 스레드를 형성합니다. 여기서 사용되는 함수 pthread_creat()는 첫번째 인자를 식별자로 스레드를 생성하는 함수이며, pthread_join() 은, 인자로 입력한 식별자에 해당하는 스레드의 완료를 기다리는 함수입니다.  

#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");

    rc = pthread_create(&p1, NULL, mythread, "A");
    assert(rc == 0);

    rc = pthread_create(&p2, NULL, mythread, "B");
    assert(rc == 0);

    // 쓰레드가 종료될 때까지 대기하기 위해 join 사용
    rc = pthread_join(p1, NULL); assert(rc == 0);
    rc = pthread_join(p2, NULL); assert(rc == 0);

    printf("main: end\n");
    return 0;
}

 

해당 코드대로 동작시킬 경우 발생하는 시나리오는 다음과 같을 것입니다. 먼저 Main이 실행되며, main: begin이 출력됩니다. 그리고 스레드 1과 스레드 2가 생성되며, 스레드 1로 pthread_join로 인한 Main의 대기 통해 우선권이 넘어갑니다. 스레드 1에서 A가 출력되며 종료되고, 이번엔 pthread_jion이 스레드 2를 대기하는 것으로 동작하며 같은 방식으로 스레드 2로 문맥 전환 후, B가 출력되며 최종적으로 main: end 출력으로 모든 프로세스가 종료됩니다. 흥미로운 것은 이 실행 순서에 있습니다. 방금 언급한 실행 순서는, 코드대로 해석된 작동 순서일 뿐, 실제 동작은 이를 관리하는 스케줄러의 상태에 따라 달라질 수 있습니다. pthread_create그 동작한지 얼마 안되서 Main의 과도한 동작시간으로 인해 바로 스레드 1이 실행될지도 모를 일입니다. 그렇다면 스레드 2가 생성되기도 전에 스레드 1은 이미 종료되었을지도 모를 일이죠. 심지어는 늦게 생성된 B가 더 높은 우선순위를 받아 B가 먼저 출력되고 A가 출력될 수도 있습니다. 이처럼 여러 스레드가 생성되는 프로세스의 경우, 이 스케줄러의 동작으로 인해 동작 구조가 굉장히 복잡해집니다. 

 

스레드의 데이터 공유

 심지어 복잡함은 동작 순서에서 멈추지 않습니다. 위에서 우리는 각 스레드들이 한 프로세스의 메모리 자원을 공유한다고 했습니다. 읽는 것 자체는 문제가 되지 않을 수 있습니다. 하지만 2번 스레드가 어떤 메모리 영역에 쓰기 작업을 했는데 이것을 스레드 1이 읽어야 한다면 어떨까요? 근데 우리는 이 순서를 임의로 조절하지 못합니다(적어도 위 예제에서는요) 그렇다면 예를 들어, 스레드 1은 수정 후 정보만 필요하다고 가정한다면, 스레드 1이 해당 정보만 무조건 받을 수 있다고 보장할 수 없습니다. 아래의 코드를 살펴볼까요? 확실하게, 가시적으로 알 수 있게 하기 위해, 함수를 1e7번 반복시켜, 해당 반복된 수만큼 counter를 증가시키고 이를 출력시키는 함수로 구성되어 있습니다.

#include <stdio.h>
#include <pthread.h>
#include "mythreads.h"

static volatile int counter = 0;

// mythread()
// 인자로 전달된 문자열을 출력하고
// 10000000번 반복하며 counter에 1을 더하는 함수
// 끝나면 다시 인자 문자열을 출력
void *mythread(void *arg) {
    printf("%s: begin\n", (char *) arg);

    for (int i = 0; i < 1e7; i++) {
        counter = counter + 1;
    }

    printf("%s: done\n", (char *) arg);
    return NULL;
}

// main()
// 두 개의 쓰레드를 생성하고 (pthread_create)
// 기다린다 (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 사용
    Pthread_join(p1, NULL);
    Pthread_join(p2, NULL);

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

 

main 함수에서 mythread가 각각 스레드 1과 2로 형성됩니다. 그렇다면, 전역변수인 counter의 경우, 나이브한 접근으로는 1e7이 두번, 2e7로 나와야할 것입니다.(2천만!!) 하지만 실제로 실행한다면 어떻게 될까요? 정직하게 2천만이 나올수도 있지만, 실제로는 이보다 작은 값들이 나오는 경우가 많을 겁니다. 왜 이런 일이 일어나는걸까요? 자 여기서부터, 다시 어셈블러 코드까지 살펴보는 작업으로 들어갑니다. 실제 counter를 증가시키는 코드는, 어셈블리 코드에서는 먼저, counter가 저장된 메모리 주소를 %eax와 같은 레지스터로 복사합니다. 그리고 add 0x1으로 해당 레지스터의 값을 1 증가시킵니다. 그리고 이 레지스터의 값을 메모리에 덮습니다. 벌써부터 문제가 보이죠? 값을 '레지스터'에 더하고, 이 레지스터를 메모리에 저장합니다. 거장 요한.E.바흐 선생님께서는(전국 Handclap 자랑 등) "연료는 수입이지만 전기는 국산"이라 하셨습니다. 레지스터는 로컬이지만, 메모리는 전역입니다. 한 스레드가 CPU를 점유하고 있을 때, 레지스터에 저장된 값은, 오염되지 않았습니다. 적어도 레지스터에 존재하는 그 순간만큼은, 이는 독립적이며, 어떤 실행 흐름을 겪든, 이것이 오염되지 않습니다. 하지만 문제는 이 값을 증가시키는 코드는, 어셈블리 관점으로 봤을 땐, 레지스터에 저장 -> 메모리에 복사로 이루어집니다. 이것이 문제가 됩니다.

스레드 1이 counter를 50증 가시키고, 따라서 메모리에는 50이 저장되어 있다고 하겠습니다. 타임 인터룹트로 인해 스레드 2가 문맥전환으로 CPU 바톤을 이어 받습니다. 스레드 2가 레지스터를 40 증가시킵니다. 50이었던 counter를 넘겨 받고, 40이 증가했으니, 이제 값은 90입니다. 이것이 메모리에 저장되고 다시 스레드 1에게 넘어갑니다. 그런데 이제, 이번엔 스레드 1이 1을 증가시키고, 바로 스레드 2에게 넘겼다고 해보겠습니다. 즉, 아직 메모리에 저장되지 않았습니다. 스레드 2는 이를 넘겨받고, 메모리로부터 90값을 넘겨받아, 20을 증가시키고 이를 저장하고 1에게 넘겼습니다. 이제 스레드 1의 관점에서 보겠습니다. 현재 메모리에 저장된 counter의 값은 90+20으로 110입니다. 하지만 지금 당장의 %eax에 저장된 값은, 이전 턴에 저장된 counter의 값인 90에 1만 더해진 91인 상태입니다. TCB에 저장된 91값이 %eax로 백업됩니다. 여기서 일치가 깨집니다. 메모리는 110이고, %eax는 91입니다. 심지어는, 스레드 1은 중단된 작업부터 이어갑니다. 중단된 작업이 뭐였죠? 네, 메모리 저장을 시행할 차례입니다. counter는 110이었지만, 스레드 1의 %eax에 저장된 91로 덮어 씌워지게됩니다!! 따라서 이러한 공유 데이터 관련 원자성의 미흡 문제로 인하여 본래 의도하였던 데이터와 완전히 다른 결과값으로 이저는 위험이 발생하게 됩니다. 최종적으론, 이런 감소가 쌓이고 쌓여서, 완전히 2e7의 값을 가지지 못하고 프로그램이 종료되는 현상을 볼 수 있습니다. 아래 사진을 통해 직관적으로 이를 확인할 수 있습니다. 실제로는 값을 총 2만큼 올려주었으나, 메모리의 counter는 최종적으로는 51로 저장되었습니다. 

 

이러한 문제를 통틀어서, 병합문제(Race Condition)으로 부릅니다. 이는 모든 곳에서 나타날 수 있습니다. 문맥교환이 부적절한 시점에서 시행되는, 흔히 말하는, 원자성이 보장되어야 하는 곳에서, 이것이 보장되지 않아, 발생하는 문제입니다. 이는 최종적으로, 기존 의도한 흐름대로 작동하지 않고, 스케줄러의 상태와 정책에 따라 유동적으로 그 흐름이 달라져 나타나는 비결정적(Indeterminate) 결과를 초래합니다. 해당 병합문제가 나타나는 지점은 임계 영역(Critical Section)이라고 부릅니다. 

 

그렇다면 이 원자성이라는 것은 어떻게 보장할까요? 병합 문제는 어떻게 해결 할 수 있을까요? 이에 대한 해결 방법이. 기존 PintOS에서 정말 머리 싸가면서 했던 그 세마포어/락과 같은 논리적 정책입니다. 이는 다음 주차에서 상세히 다뤄보겠습니다.