CH25 병행성에 대한 대화
멀티 쓰레드 프로그램에서 각 쓰레드는 독립적인 객체로 프로그램 내에서 프로그램 대신 일을 한다
이 쓰레드들이 메모리에 접근하는데, 접근하는 것을 조정하지 않으면 프로그램이 예상처럼 동작하지 않을 수 있다
운영체제는 락 Lock 과 컨디션 변수 Contidional Variables 같은 기본 동작으로 멀티쓰레드 프로그램을 지원한다
🐣 빠르고 정확해야 한다
CH26 - 병행성 개요
쓰레드 Thread
멀티 쓰레드 프로그램은 하나 이상의 실행 지점 (독립적으로 실행될 수 있는 여러 개의 PC 값) 을 가지고 있다
쓰레드들은 주소 공간을 공유하기 때문에 동일한 값에 접근이 가능하다 -> 프로세스와의 다른 점
하나의 쓰레드의 상태는 프로세스와 매우 유사하고,
쓰레드는 다음에 실행할 명령어의 주소를 추적하는 프로그램 카운터 PC 와 연산을 위한 레지스터들을 가지고 있다
🐣 프로세스는 독립적 실행 단위, 자체적인 메모리 공간을 가지며 각 프로세스는 PC, 레지스터, 스택 등 실행에 필요한 리소스도 포함된다
두 개의 쓰레드가 하나의 프로세서에서 실행 중일 때 문맥 교환 Context Switch 도 하고
프로세스의 상태를 PCB에 저장하는 것처럼, 쓰레드도 쓰레드 제어 블럭 Thread Control Block, TCB을 사용한다
🐣 TCB 는 운영체제의 커널이나 쓰레드 라이브러리가 관리하는 메모리 영역에 있다
쓰레드 간의 문맥 교환에서는 주소 공간을 그대로 사용한다는 것이 가장 큰 차이 중 하나이다 (사용하고 있던 페이지 테이블을 그대로 사용)
그리고 스택의 차이도 있는데, 고전 프로세스 주소 공간에는 스택이 하나만 존재하는 반면
멀티 쓰레드 프로세스의 경우 각 쓰레드가 독립적으로 실행되며 쓰레드마다 스택이 할당된다
스택에서 할당되는 변수들이나 매개변수, 리턴 값, 그 외에 스택에 넣는 것들은 해당 쓰레드의 스택인 쓰레드-로컬 저장소에 저장된다
요소 | 스레드 로컬 저장소 (TLS) | 스레드 제어 블록 (TCB) |
주 목적 | 스레드별 고유 데이터 저장 (변수 값 등) | 스레드의 상태 저장 (프로그램 카운터, 레지스터 등) |
역할 | 스레드가 다른 스레드와 격리된 데이터를 안전하게 사용할 수 있게 함 | 스레드 간 컨텍스트 스위치를 지원해 스레드 스케줄링 관리 |
위치 | 힙 또는 스택의 스레드별 메모리 영역 | 운영체제나 스레드 라이브러리가 관리하는 별도 메모리 구조 |
26.1 왜 쓰레드를 사용하는가?
1. 병렬 처리 Parallelism
표준 단일 쓰레드 Single-Threaded 프로그램을 멀티 프로세서에서 같은 작업을 하는 프로그램으로 변환하는 작업을 병렬화 Parallelization 라고 한다
2. 느린 I/O 로 인해 프로그램 실행이 멈추지 않도록 하기 위해
프로그램 중 하나의 쓰레드가 I/O 수행을 대기하는 동안 다른 쓰레드로 CPU를 전환하여 작업을 수행할 수 있게 한다
쓰레딩은 하나의 프로그램 안에서 I/O와 다른 작업이 중첩 Overlap 될 수 있게 한다
이는 여러 프로그램을 대상으로 프로세스를 멀티프로그래밍 Multiprogramming 하는 것과 비슷하다
26.2 예제 - 쓰레드 생성
쓰레드 생성은 함수 호출과 비슷하지만, 함수 호출에서는 함수 실행 후에 호출자 caller 에게 리턴하는 반면에
쓰레드의 생성에서는 실행할 명령어들을 갖고 있는 새로운 쓰레드가 생성되고, 생성된 쓰레드는 호출자와는 별개로 실행된다
다음에 실행될 쓰레드는 OS 스케줄러 Scheduler 에 의해 결정되지만, 특정 순간에 어떤 쓰레드가 실행될지는 알 수 없다
26.3 훨씬 더 어려운 이유 - 데이터의 공유
아래 예제를 보기 전에 짚고 넘어가야 하는 것 1) 이 예제는 에러 발생 여부만 관심이 있어서 루틴 실패 시 간단하게 종료한다
2) 하나의 단일 코드를 사용하였다 3) 각 작업자가 무엇을 하려는지 알 수 있다 4) 최종적으로 얻으려는 값은 20,000,000이다
하지만 이 예제 코드를 실행할 때마다 실행의 결과값이 다르게 나온다
26.4 문제의 핵심 - 제어 없는 스케줄링
명령어의 실행 순서에 따라 결과가 달라지는 상황을 경쟁 조건 Race Condition 또는 데이터 경쟁 Data Race 라고 부른다
문맥 교환이 때에 맞지 않게 실행되는 경우 잘못된 결과를 얻게 된다
결정적 결과 - 컴퓨터의 작동에서 일반적으로 발생하며, 동일한 입력에 대해 항상 같은 결과를 생성
비결정적 Indeterminate 결과 - 결과가 어떨지 알 수 없거나 실행할 때마다 결과가 다른 경우
임계 영역 Critical Section - 공유 변수/ 공유 자원을 접근하고, 하나 이상의 쓰레드에서 동시에 실행되면 안 되는 코드
상호 배제 Mutual Exclusion - 하나의 쓰레드가 임계 영역 내의 코드를 실행 중일 때 다른 쓰레드가 실행할 수 없도록 보장해 준다
임계 영역 - 경쟁 조건이 발생하는 코드
26.5 원자성에 대한 바람
임계 영역 문제에 대한 해결 방법 중 하나로, 강력한 명령어 한 개로 의도한 동작을 수행하여
인터럽트 발생 가능성을 원천적으로 차단할 수 있다
하드웨어가 어떤 명령어에 대해서 원자적으로 실행되는 것을 보장한다고 할 때, 해당 명령어 수행 중에는 인터럽트가 발생하지 않는다
하지만 일반적인 상황에서 사용할 수 있는 명령어는 없기 때문에, 하드웨어에 동기화 함수 Synchronization Primitives 구현에 필요한 몇 가지 유용한 명령어를 요청하면 된다, 하드웨어 지원을 사용하고 운영체제의 도움을 받아 한 번에 하나의 쓰레드만 임계 영역에서 실행하도록 구성된 멀티 쓰레드 프로그램을 작성할 수 있다
핵심 질문 - 동기화를 지원하는 방법
유용한 동기화 함수를 만들기 위해 어떤 하드웨어 지원이 필요한가?
26.6 또 다른 문제 - 상대 기다리기
하나의 쓰레드가 다른 쓰레드의 동작 종료를 대기하는 상황도 빈번하게 발생한다
프로세스가 디스크 I/O를 요청하고 응답이 올 때까지 잠들었다가 I/O 완료 후 깨어나 이후 작업을 시작한다
이후에서는 원자성 지원을 위한 동기화 함수 제작에 대한 내용과 멀티 쓰레드 프로그램에서 흔한 잠자기/ 깨우기 동작에 대한 지원 기법에 대해서 다룬다
26.7 정리 - 왜 운영체제에서?
운영체제는 최초의 병행 프로그램이었고, 운영체네 내에서 사용을 목적으로 다양한 기법들이 개발되었다
나중에는 멀티 쓰레드 프로그램이 등장하면서 응용 프로그래머들도 이러한 문제를 고민하게 되었다
페이지 테이블, 프로세스 리스트, 파일 시스템 구조, 대부분의 커널 자료 구조들이 올바르게 동작하기 위해서는
적절한 동기화 함수들을 사용하여 조심스럽게 다루어져야 한다
주요 병행성 관련 용어
1. 임계 영역 Critical Section - 보통 변수나 자료 구조와 같은 공유 자원을 접근하는 코드의 일부분
2. 경쟁 조건 Race Condition or 데이터 경쟁 Data Race - 멀티 쓰레드가 거의 동시에 임계 영역을 실행하려고 할 때 발생하며, 공유 자료 구조를 모두가 갱신하려고 시도한다면 의도하지 않은 결과를 만들 수 있다
3. 비결정적 Indeterminate - 하나 또는 그 이상의 경쟁 조건을 포함하여 그 실행 결과가 각 쓰레드가 실행된 시점에 의존하기 때문에 프로그램의 결과가 실행할 때마다 다르다 (일반적으로 기대하는 바와 달리 결정적이지 않다)
이와 같은 문제들을 회피하기 위해 쓰레드는 상호 배제 Mutual Exclusion 라는 기법의 일종을 사용하여, 하나의 쓰레드만이 임계 영역에 진입할 수 있도록 보장한다
CH27 막간 - 쓰레드 API
핵심 질문 - 쓰레드를 생성하고 제어하는 방법
운영체제가 쓰레드를 생성하고 제어하는 데 어떤 인터페이스를 제공해야 할까?
어떻게 이 인터페이스를 설계해야 쉽고 유용하게 사용할 수 있을까?
27.1 쓰레드 생성
멀티 쓰레드 프로그램을 작성하려면, 가장 먼저 해야 할 일은 새로운 쓰레드의 생성이다
쓰레드 생성을 위해서는 해당 인터페이스가 존재해야 하며, POSIX 에서 쉽게 할 수 있다
thread - pthread_t 타입 구조체를 가리키는 포인터, 쓰레드 초기화 시 pthread_create() 에 이 구조체를 전달한다
attr - 쓰레드의 속성을 지정하는 데 사용, 스택의 크기/ 쓰레드의 스케줄링 우선순위 같은 정보 지정, 개별 속성은 pthread_attr_init() 함수를 호출하여 초기화한다
세번째 인자 - 쓰레드가 실행할 함수, 함수 포인터 (void * 타입의 인자를 한 개 전달 받고 void * 타입의 값을 반환한다)
arg - 실행할 함수에게 전달할 인자
void 포인터를 start_routine 함수의 인자로 사용하면, 어떤 데이터 타입도 인자로 전달할 수 있고
반환 값의 타입으로 사용하면 쓰레드는 어떤 타입의 결과도 반환할 수 있다
27.2 쓰레드 종료
pthread_join() 는 특정 스레드가 종료될 때까지 호출한 스레드를 대기 상태로 만들고,
해당 스레드가 종료되면 반환 값을 얻어온다.
POSIX 쓰레드에서는 다른 쓰레드의 완료를 기다리기 위해 pthread_join() 을 부른다
int pthread_join(pthread_t thread, void **value_ptr);
pthread_t 타입 인자는 어떤 쓰레드를 기다리려고 하는지 명시 (쓰레드 생성 루틴에 의해 초기화)
**value_ptr 인자는 반환 값에 대한 포인터 타입으로 정의 (종료된 쓰레드가 반환하는 값을 받을 수 있는 포인터)
pthread_join() 루틴은 그 값에 대한 포인터를 전달하여 유연하게 쓰레드를 활용할 수 있게 한다
위의 예제에서 알 수 있는 점은
1) 여러 인자를 한 번에 전달하기 위해 항상 묶을 필요는 없다
인자가 없는 쓰레드를 생성할 때는 NULL을 전달하여 쓰레드를 생성할 수도 있다
2) 값 하나만 전달해야 한다면 인자를 전달하기 위해 묶을 필요가 없다
3) 쓰레드에서 값이 어떻게 반환되는지에 대해 각별한 신경을 써야 한다
특히, 쓰레드의 콜 스택에 할당된 값을 가리키는 포인터를 반환하지 마라
4) pthread_create()를 사용하여 쓰레드를 생성하고 직후에 pthread_join() 을 호출하는 건 이상한 방법이다
모든 멀티 쓰레드 코드가 조인 루틴을 사용하지는 않는다, 하지만 특정 작업을 병렬적으로 실행하기 위해
쓰레드를 생성하는 병렬 프로그램의 경우에는, 종료 전 혹은 계산의 다음 단계로 넘어가기 전에
병렬 수행 작업이 모두 완료되었다는 것을 확인하기 위해 join 을 사용한다
27.3 락 Lock
락 Lock 을 통한 임계 영역에 대한 상호 배제 기법을 사용하기 위해서 사용되는 가장 기본적인 루틴은 다음과 같이 제공된다
🐣 락 - 여러 쓰레드가 동시에 공유 자원에 접근할 때 발생할
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
POSIX 쓰레드를 사용할 때 락을 초기화하는 방법은 두 가지이다
1) PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
2) 동적으로 초기화하는 방법 (실행 중에)
int rc = pthread_mutex_init(&lock, NULL);
assert(rc == 0); // 성공했는지 꼭 확인해야 한다
- 멀티스레드 환경에서 상호 배제를 통해 특정 코드 블록이 한 번에 하나의 스레드에서만 실행되도록 보장해야한다.
- pthread_mutex는 여러 스레드가 동시에 실행되더라도 특정 코드 블록을 동시에 실행되지 않도록 제어한다.
- pthread_mutex_lock() 이 호출될 때 다른 쓰레드가 락을 갖고 있지 않으면 이 스레드가 락을 얻어 임계 영역에 접근한다.
락 사용이 끝난 후에는 초기화 API 와 상응하는 pthread_mutex_destroy) 도 호출해야 하니 주의
락 사용 시 주의 사항
오류 체크를 하는 것이 중요하다.
락을 해제하지 않으면 데드락이 발생할 수 있다. 하나의 스레드가 락을 획득한 상태에서 해제하지 않으면 다른 스레드들이 기다리게되면서 프로그램이 멈출 수 있다.
27.4 컨디션 변수
컨디션 변수 Condition Variable - 쓰레드 간의 시그널 교환 매커니즘의 일종
int pthread_cond_wait (pthread_cond_t *cond, pthread_mutex_t * mutex);
int pthread_cond_signal (pthread_cond_t *cond);
컨디션 변수 사용을 위해서는 이 컨디션 변수와 연결된 락이 "반드시" 존재해야 한다
첫번째 루틴은 호출 쓰레드를 수면 sleep 상태로 만들고 다른 쓰레드로부터의 시그널을 대기한다
다른 쓰레드에서 실행될 잠자는 쓰레드를 깨우는 코드는 다음과 같다
Pthread_mutex_lock (&lock);
ready= 1;
Pthread_cond_signal (&cond);
Pthread_mutex_unlock (&lock)
27.5 컴파일과 실행
이 장에서 사용한 예제 코드를 컴파일하기 위해서는 pthread.h 헤더를 포함시켜야 한다
그리고 컴파일하기 위한 명령어는 아래와 같다
prompt> gee -o main main.c -Wall -pthread
27.6 요약
좀 더 많은 정보를 원한다면 Linux 시스템에서 man -k pthread 를 실행시켜
백여개가 넘는 API들을 인터페이스를 확인해 보면 좋다
💡 스레드 API 지침
- 간단하게 작성하라
- 스레드 간의 상호 동작을 최소화 하라
- 락과 컨디션 변수를 초기화하라
- 반환 코드를 확인하라
- 스레드 간에 인자를 전달하고 반환받을 때는 조심해야 한다.
- 각 쓰레드는 개별적인 스택을 가진다.
- 쓰레드 간에 시그널을 보내기 위해 항상 컨디션 변수를 사용하라.
- 메뉴얼을 사용하라.
'크래프톤정글 > 운영체제' 카테고리의 다른 글
[OSTEP] 병행성 CH29 - CH30 (0) | 2024.10.30 |
---|---|
[OSTEP] 병행성 CH28 락 Lock (1) | 2024.10.29 |
[운영체제] CH16 - CH17 (0) | 2024.10.24 |
[OSTEP] 가상화 CH14 - CH15 (1) | 2024.10.19 |
[OSTEP] 가상화 CH10-CH13 (1) | 2024.10.18 |