[OS] 병행성: 개요

장선규·2023년 8월 20일
0

[OS] OSTEP Study

목록 보기
16/28

병행성: 개요

쓰레드(thread): 프로세스 내에서 실제로 작업을 수행하는 주체

  • 멀티 쓰레드 프로그램은 하나 이상의 실행 지점을 가지고있다.
  • 멀티 쓰레드 프로그램은 단일 PC값을 사용하는 고전적인 관점에서 벗어나 독립적으로 Load/Execute 될 수 있는 여러 PC 값을 갖는다.
  • 쓰레드는 프로세스와 유사하지만, 쓰레드들은 주소 공간을 공유하기 때문에 동일한 값에 접근할 수 있다.
  • 두 쓰레드 T1,T2가 한 프로세서에 있으면 반드시 문맥 교환(context switch)를 통해 쓰레드를 교체해야 한다.
  • 프로세스의 문맥교환 <- 프로세스 제어 블록(PCB)에 저장
  • 쓰레드의 문맥교환 <- 쓰레드 제어 블록(TCB)에 저장
  • 멀티 쓰레드 프로세스의 경우에는 쓰레드마다 스택이 할당된다.

1. 예제: 쓰레드 생성

#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");
    rc = pthread_create(&p2 , NULL , mythread , "B");
    
    // 종료 할 수 있도록 대기 중인 쓰레드 병합하기
    rc = pthread_join(p1 , NULL);
    rc = pthread_join(p2 , NULL);
    printf("main: end\n");
}
  • 이 예제에서 출력 순서를 보장할 수 없다.
    (B A 순으로 출력될 수 있음)
  • 스케줄러의 상황에 따라 실행의 순서가 달라진다.

2. 훨씬 더 어려운 이유: 데이터의 공유

전역 공유 변수를 갱신하는 두 쓰레드에 대한 예제를 살펴보자.

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

int max = 1000000;
static volatile int counter = 0; // shared global variable

/*
 * 반복문을 사용하여 단순히 1씩 더하는 함수
 * 멀티쓰레드의 공유 데이터 문제를 확인할 수 있다.
 */
void *mythread(void *arg) {
    char *letter = arg;
    int i; // stack (private per thread) 
    printf("%s: begin [addr of i: %p]\n", letter, &i);
    for (i = 0; i < max; i++) {
		counter = counter + 1; // shared: only one
    }
    printf("%s: done\n", letter);
    return NULL;
}
                                                                             
int main(int argc, char *argv[]) {                    
    pthread_t p1, p2;
    printf("main: begin [counter = %d] [%x]\n", counter, (unsigned int) &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\n [counter: %d]\n [should: %d]\n", counter, max*2);
    return 0;
}
  • 이 예제는 전역 변수인 counter에 두 쓰레드가 각각 반복문으로 1000만회 증가연산을 실행시키는 예제이다.
  • 두 쓰레드에서 1000만회 증가시키면 counter의 값이 2천만이 되어야 하지만, 기대처럼 되지 않을 수 있다.
    • 내가 짠 코드에서는 counter의 값이 2천만이 아니라 1만에 가까운 값이다.

3. 제어 없는 스케줄링

왜 위와 같은 현상이 일어났는지 이해하기 위해 컴파일러가 생성한 코드의 실행 순서를 알아보자.

// counter 변수의 주소 = 0x8049a1c
mov 0x8049a1c, %eax // counter 읽고 eax 레지스터로 옮김
add $0x1, %eax      // eax 레지스터에 1 더함
mov %eax, 0x8049a1c // eax 레지스터의 값을 counter에 넣음
  1. 쓰레드 1에서 counter를 50까지 성공적으로 증가시켰다고 하자.
  2. 이후 1을 더하는 과정에서 add 명령어까지는 수행이 되어 eax=51이 되었다.
  3. 이때 타이머 인터럽트가 발생하였다고 가정하여 현재 상태를 쓰레드 1의 TCB에 저장한다.
    (mov 명령을 수행하지 않았음)
  4. 쓰레드 2가 선택되고 counter의 값을 증가시키지만, 이때 쓰레드 2에서는 counter값이 아직 50을 나타내고 있으므로 counter의 값은 51이 된다.
  5. 또다시 문맥교환이 발생하여 쓰레드 1이 리턴되고 실행되었다.
  6. 이전 상황을 기억해보면 쓰레드 1은 mov와 add 동작을 실행하였고, 이제 마지막의 mov 명령어를 수행하려고 한다. 쓰레드 1의 eax=51 이었으므로 이를 counter에 저장하여 counter가 51이 된다.

두 쓰레드에서 counter의 값을 늘리는 작업을 수행하였지만, counter의 최종 값은 51이다. 즉 "정확하게" 동작되어야 한다면 counter의 값이 52가 되어야 한다.

  • 위의 예시를 표로 나타낸 것이다.
  • 명령어의 실행 순서에 따라 결과가 달라지는 상황을 경쟁 조건(race condition)이라고 부른다.
    • 경쟁 조건에 처한 경우 실행할 때마다 다른 결과를 얻는다. 이를 비결정적(indeterminate)인 결과라고 한다.
  • 멀티 쓰레드가 같은 코드를 실행할 때 경쟁 조건이 발생하기 때문에 이러한 코드 부분을 임계 영역(critical section)이라고 부른다.
  • 이러한 코드에서 필요한 것은 상호 배제(mutual exclusion)이다.
    • 상호 배제: 하나의 쓰레드가 임계 영역 내의 코드를 실행 중일 때는 다른 쓰레드가 실행할 수 없도록 보장하는 것

4. 원자성에 대한 바람

우리는 다음 세 개의 명령어가 원자적으로 실행되기를 원한다.

// counter 변수의 주소 = 0x8049a1c
mov 0x8049a1c, %eax // counter 읽고 eax 레지스터로 옮김
add $0x1, %eax      // eax 레지스터에 1 더함
mov %eax, 0x8049a1c // eax 레지스터의 값을 counter에 넣음

임계 영역 문제에 대한 해결 방법 중 하나는 "강력한 명령어" 한 개로 의도한 동작을 수행하고, 인터럽트 발생 가능성을 원천적으로 차단하는 것이다.

  • 예를 들어 memory−add 0x8049a1c, $0x1 와 같은 강력한 명령어가 있다고 하자.
  • 이 명령어는 하드웨어가 원자적으로 실행되는 것을 보장하고, 특정 메모리 위치에 어떤 값을 더하는 명령이다.

하지만 일반적인 상황에서는 저런 명령어는 없다고 봐야한다...

따라서 우리는 동기화 함수(synchronization primitives)가 필요하고, 이는 하드웨어적으로 어셈블리 명령어 몇개로 구현할 수 있다.

결과적으로 하드웨어 동기화 명령어와 운영체제의 지원을 통해 "제대로 잘 작동하는" 멀티 쓰레드 프로그램을 작성할 수 있다.

핵심 질문: 동기화를 지원하는 방법

  • 유용한 동기화 함수를 만들기 위해 어떤 하드웨어 지원이 필요?
  • 운영체제는 어떤 지원?
  • 어떻게 하면 이런 함수를 정확하고 효율적으로?

5. 또 다른 문제: 상대 기다리기

실제론 하나의 쓰레드가 다른 쓰레드의 동작이 완료될 때까지 대기해야 하는 상황이 빈번하게 발생한다.

  • ex) 프로세스가 디스크에 I/O 요청을 하고 응답이 올 때까지 잠든 경우

이후 멀티 쓰레드 프로그램에서 배울 내용

  • 원자성 지원을 위한 동기화 함수 제작
  • sleep/wake 동작에 대한 지원 기법
  • 컨디션 변수 (나중에 배울 것)

6. 정리: 왜 운영체제에서?

운영체제는 최초의 병행 프로그램이었고, 운영체제 내에서 사용을 목적으로 다양한 기법들이 개발되었다.
(나중에는 멀티 쓰레드 프로그램이 등장하면서 응용 프로그래머들도 이러한 문제를 고민하게 되었다.)

즉, 왜 운영체제에서 이러한 것들은 다루는지는 한 단어로 "역사"이기 때문이다.

profile
코딩연습

0개의 댓글